Compare commits

..
Author SHA1 Message Date
Claude 68cd799f89 Merge dev into Seafarers
CI / Build and check (pull_request) Successful in 9m1s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8evMuUxrft6TQj6NdAoqQ
2026-10-29 03:04:13 +00:00
devadmin 6c549a8b76 Merge pull request 'Yahtzee: end-of-game stats dialog' (#23) from yahtzee-stats into dev
CI / Build and check (push) Successful in 8m46s
CI / Deploy to Cloudflare Pages (push) Successful in 35s
CI / Build and check (pull_request) Successful in 8m46s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
2026-10-26 22:36:19 -06:00
Claude ebab7a8415 Yahtzee: end-of-game stats dialog compared with previous games
CI / Build and check (pull_request) Successful in 8m51s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0177JQTtqM7EzJyXYiZYEJTX
2026-10-27 04:26:52 +00:00
devadmin a0b15b3140 Merge pull request 'Yahtzee: two-column score card, larger text' (#22) from yahtzee-card-layout into dev
CI / Build and check (push) Successful in 8m43s
CI / Deploy to Cloudflare Pages (push) Successful in 30s
2026-10-26 22:00:12 -06:00
Claude 8990dde363 Yahtzee: two-column score card with larger text, scrolls sideways when needed
CI / Build and check (pull_request) Successful in 8m45s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0177JQTtqM7EzJyXYiZYEJTX
2026-10-27 03:50:51 +00:00
devadmin c2b21a07f5 Merge pull request 'Yahtzee: pip dice, confirm zero scores; text size setting' (#21) from yahtzee-improvements into dev
CI / Build and check (push) Successful in 8m43s
CI / Deploy to Cloudflare Pages (push) Successful in 29s
2026-10-26 20:03:26 -06:00
Claude e98afbfd8d Yahtzee: pip dice, confirm zero scores; add text size setting
CI / Build and check (pull_request) Successful in 8m45s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0177JQTtqM7EzJyXYiZYEJTX
2026-10-27 01:52:15 +00:00
devadmin 2c90de66d1 Merge pull request 'Release Katan and Meowdoku to production' (#19) from fix/release-katan-meowdoku into dev
CI / Build and check (push) Successful in 8m50s
CI / Build and check (pull_request) Successful in 8m50s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
CI / Deploy to Cloudflare Pages (push) Successful in 34s
2026-10-26 12:23:15 -06:00
Claude f93eba88e5 Release Katan and Meowdoku: open the production gate
CI / Build and check (pull_request) Successful in 8m43s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8evMuUxrft6TQj6NdAoqQ
2026-10-26 18:14:12 +00:00
devadmin 36ab518309 Merge pull request 'Scorekeeper: name players at setup; refocus after a round' (#17) from feature/scorekeeper-setup-names into dev
CI / Build and check (push) Successful in 9m36s
CI / Build and check (pull_request) Successful in 9m34s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
CI / Deploy to Cloudflare Pages (push) Successful in 36s
2026-10-26 10:35:39 -06:00
Claude 77cc6c5a05 Scorekeeper: ask for each player's name at setup; refocus first cell after a round
CI / Build and check (pull_request) Successful in 9m40s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8evMuUxrft6TQj6NdAoqQ
2026-10-26 04:24:25 +00:00
devadmin 166f85734b Merge pull request 'Scorekeeper: remember the last 10 player names' (#16) from feature/scorekeeper-memory into dev
CI / Build and check (push) Successful in 8m44s
CI / Deploy to Cloudflare Pages (push) Successful in 30s
2026-10-25 21:34:23 -06:00
Claude 25e38c4bf8 Scorekeeper: remember the last 10 player names and offer them when naming a column
CI / Build and check (pull_request) Successful in 8m50s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8evMuUxrft6TQj6NdAoqQ
2026-10-26 03:16:53 +00:00
devadmin 6bf7522042 Merge pull request 'Meowdoku: rules, AI and screen refinements' (#15) from meow2 into dev
CI / Build and check (push) Successful in 20m52s
CI / Deploy to Cloudflare Pages (push) Successful in 33s
2026-10-25 20:29:24 -06:00
Claude 7942adeaf7 Meowdoku: rules, AI and screen refinements
CI / Build and check (pull_request) Successful in 10m1s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8evMuUxrft6TQj6NdAoqQ
2026-10-26 01:14:45 +00:00
Claude 401ebe0118 Docs index: Seafarers
CI / Build and check (pull_request) Successful in 8m59s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
2026-10-24 17:19:11 +00:00
Claude 3c0469fc81 Seafarers: interaction tests, rules and UI docs 2026-10-24 17:18:59 +00:00
Claude 5d36b7bd62 Seafarers: rules tests and engine self-play, AI heads for new islands 2026-10-24 17:15:16 +00:00
Claude b5b4afa731 Merge remote-tracking branch 'origin/dev' into Seafarers 2026-10-24 17:13:14 +00:00
Claude f88099f4e2 WIP Seafarers engine, board and screen plumbing 2026-10-24 17:13:14 +00:00
devadmin 488f600f15 Merge pull request 'Katan: Catan Universe style HUD and trade board (2D)' (#13) from CatanImprovements into dev
CI / Build and check (push) Successful in 8m49s
CI / Deploy to Cloudflare Pages (push) Successful in 31s
2026-10-24 11:13:07 -06:00
Claude 9b938ffd8b Merge remote-tracking branch 'origin/dev' into CatanImprovements
CI / Build and check (pull_request) Successful in 8m41s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
2026-10-24 16:58:58 +00:00
Claude ccf8d5a1ba Katan HUD: compact landscape layout for phone heights
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WrH771kLJmTHnEfcmV2YDx
2026-10-24 16:58:58 +00:00
devadmin a00df43a44 Merge pull request 'Meowdoku: cat puzzle engine, screen and docs' (#12) from meow into dev
CI / Build and check (push) Successful in 8m38s
CI / Deploy to Cloudflare Pages (push) Successful in 30s
2026-10-24 10:54:17 -06:00
Claude fae031d2b5 Katan: Catan Universe style landscape HUD and trade board
Drag-to-build with confirm/cancel, pinch/pan board, round turn and trade
buttons, and a trade board panel (bank, player, reply). Findings from the
Catan Universe 2.7.5 reverse engineering are in docs/RE-catan-universe.md.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WrH771kLJmTHnEfcmV2YDx
2026-10-24 16:49:18 +00:00
Claude d568ff7750 Meowdoku: one cat per row, column and colour, no two touching
CI / Build and check (pull_request) Successful in 8m34s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
2026-10-24 16:32:39 +00:00
devadmin 2e66567643 Merge pull request 'Fix flaky ScorekeeperSessionTest' (#11) from fix/scorekeeper-test-flake into dev
CI / Build and check (push) Successful in 8m25s
CI / Deploy to Cloudflare Pages (push) Successful in 29s
2026-10-17 14:01:39 -06:00
Claude a8ff9863a6 Fix a race in ScorekeeperSessionTest's admittedGuest helper
CI / Build and check (pull_request) Successful in 8m32s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
A guest counts as connected before the host's sheet has arrived over the socket, so
tests that read the guest's players straight away could see an empty list. The helper
now waits for the guest to hold the host's sheet. 0 failures in 12 runs (was ~1 in 3).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016eTor2tpxKb4dXbd8RqnvZ
2026-10-17 19:52:44 +00:00
devadmin bd12a7a70b Merge pull request 'Save state for every game between sessions' (#10) from feature/saved-games into dev
CI / Build and check (push) Successful in 8m28s
CI / Deploy to Cloudflare Pages (push) Successful in 31s
2026-10-17 13:19:05 -06:00
52 changed files with 5282 additions and 277 deletions
+8 -2
View File
@@ -52,9 +52,15 @@ integration:
## Katan
The island game is catalogue id `catan`, shown as **Katan**. It has two rule
sets chosen at setup: the base game (`docs/RULES-catan.md`) and Cities & Knights
sets chosen at setup: the base game (`docs/RULES-catan.md`), Cities & Knights
(`docs/RULES-catan-ck.md`, code in `game/catan/CatanCk*.kt`, state behind
`CatanState.ck`, which is null in the base game). The screen is layered; see
`CatanState.ck`, which is null in the base game) and Seafarers
(`docs/RULES-catan-seafarers.md`, code in `game/catan/CatanSea*.kt`, state behind
`CatanState.sea`). The base and Seafarers rules read the board from `state.topo`, not from
`CatanBoard` directly, because a Seafarers map is not the 19-hex island
(Cities & Knights is always the standard island and still uses `CatanBoard`). The
Catan Universe reverse engineering that shaped the landscape HUD and trade board
is in `docs/RE-catan-universe.md`. The screen is layered; see
`docs/UI-catan.md` before changing it.
## Bash output is filtered before it reaches the model
@@ -10,6 +10,7 @@ import org.prolibertate.games.net.LobbyController
import org.prolibertate.games.net.ScorekeeperSession
import org.prolibertate.games.score.ScorekeeperRepository
import org.prolibertate.games.settings.SettingsRepository
import org.prolibertate.games.settings.YahtzeeStatsRepository
import org.prolibertate.games.ui.AppEnv
import org.prolibertate.games.ui.AppRoot
import org.prolibertate.games.ui.theme.ProLibertateTheme
@@ -43,6 +44,7 @@ class MainActivity : ComponentActivity() {
),
peerId = peerId,
saves = SavedGameStore(File(filesDir, "saved-games")),
yahtzeeStats = YahtzeeStatsRepository(applicationContext),
)
setContent {
@@ -106,6 +106,7 @@ object GameCatalog {
const val TAYU = "tayu"
const val MORRIS = "morris"
const val PIRATES = "pirates"
const val MEOWDOKU = "meowdoku"
/**
* The island game the collection is named for. Listed under Board Games
@@ -361,12 +362,18 @@ object GameCatalog {
maxPlayers = 4,
teamBased = false,
blurb = "Trade, build, and settle the island, in the base game or Cities & Knights.",
// Engine and screen exist but have not been verified on two real
// devices yet. Dev builds can open it as a *Preview*; a production
// build does not list it. Flip `available` on, and `devPreview`
// off, once two-device play has been verified.
available = false,
devPreview = true,
available = true,
),
GameDescriptor(
id = MEOWDOKU,
title = "Meowdoku",
menus = setOf(GameMenu.BOARD, GameMenu.SOLITAIRE),
minPlayers = 1,
maxPlayers = 1,
teamBased = false,
blurb = "One cat in every row, column and colour, and no two touching. " +
"Wrong taps cost a heart; the board only ever accepts a cat that belongs.",
available = true,
),
)
@@ -34,6 +34,11 @@ class CatanAi : GameAi<CatanState, CatanMove> {
CatanPhase.AWAIT_ROLL -> legal.first { it is RollDice }
// Gold: take what the seat wants most.
CatanPhase.GOLD -> legal.filterIsInstance<PickGold>().maxByOrNull { pick ->
pick.cards.sumOf { want(it) }
} ?: legal.first()
CatanPhase.DISCARDING -> chooseDiscard(state, seat, legal.filterIsInstance<Discard>())
CatanPhase.ROBBER -> chooseRobber(state, seat, legal.filterIsInstance<MoveRobber>())
@@ -70,9 +75,10 @@ class CatanAi : GameAi<CatanState, CatanMove> {
* worth is what it reaches past it.
*/
private fun placeDirection(state: CatanState, place: SetupPlace): Double {
val far = CatanBoard.otherEnd(place.edge, place.vertex)
return CatanBoard.VERTEX_NEIGHBORS[far]
.filter { it != place.vertex && it !in CatanBoard.VERTEX_NEIGHBORS[place.vertex] }
val topo = state.topo
val far = topo.otherEnd(place.edge, place.vertex)
return topo.VERTEX_NEIGHBORS[far]
.filter { it != place.vertex && it !in topo.VERTEX_NEIGHBORS[place.vertex] }
.maxOfOrNull { spotValue(state, it) }
?: 0.0
}
@@ -94,9 +100,9 @@ class CatanAi : GameAi<CatanState, CatanMove> {
require(moves.isNotEmpty()) { "No legal robber move" }
val points = CatanRules.publicVictoryPoints(state)
return moves.maxByOrNull { move ->
val hex = CatanBoard.hexIndex.getValue(move.hex)
val hex = state.topo.hexIndex.getValue(move.hex)
var score = 0.0
for (v in CatanBoard.HEX_CORNERS[hex]) {
for (v in state.topo.HEX_CORNERS[hex]) {
val building = state.buildings[v] ?: continue
if (building.owner == seat) continue
val worth = if (building.type == BuildingType.CITY) 2 else 1
@@ -124,6 +130,12 @@ class CatanAi : GameAi<CatanState, CatanMove> {
spotValue(state, it.vertex)
}?.let { return it }
// Seafarers: a ship toward an island nobody has landed on is worth more than a card.
legal.filterIsInstance<BuildShip>()
.filter { shipValue(state, seat, it.edge) > 0.5 }
.maxByOrNull { shipValue(state, seat, it.edge) }
?.let { return it }
// A knight is worth playing whenever one is playable: it blocks an
// opponent's best hex and marches toward Largest Army.
legal.filterIsInstance<PlayKnight>().firstOrNull()?.let { return it }
@@ -142,6 +154,12 @@ class CatanAi : GameAi<CatanState, CatanMove> {
legal.filterIsInstance<BuyDevCard>().firstOrNull()?.let { return it }
// Seafarers: a ship that heads for a settlement spot worth having.
legal.filterIsInstance<BuildShip>()
.filter { shipValue(state, seat, it.edge) > 0.15 }
.maxByOrNull { shipValue(state, seat, it.edge) }
?.let { return it }
// A paid road only when it reaches toward a spot worth settling.
legal.filterIsInstance<BuildRoad>()
.filter { roadValue(state, it) > 0.1 }
@@ -301,13 +319,13 @@ class CatanAi : GameAi<CatanState, CatanMove> {
if (state.buildings[vertex] != null) return -1.0
var value = 0.0
val kinds = mutableSetOf<Resource>()
for (h in CatanBoard.VERTEX_HEXES[vertex]) {
for (h in state.topo.VERTEX_HEXES[vertex]) {
val resource = state.terrains[h].produces ?: continue
value += CatanBoard.numberOdds(state.numbers[h]) / 36.0 * want(resource)
kinds += resource
}
value += 0.12 * kinds.size // variety keeps a build plan alive
for (e in CatanBoard.VERTEX_EDGES[vertex]) {
for (e in state.topo.VERTEX_EDGES[vertex]) {
val port = state.ports.firstOrNull { it.edge == e } ?: continue
value += if (port.resource == null) 0.1 else 0.2
}
@@ -320,15 +338,52 @@ class CatanAi : GameAi<CatanState, CatanMove> {
*/
internal fun roadValue(state: CatanState, move: BuildRoad): Double {
var best = 0.0
val (from, to) = CatanBoard.EDGE_VERTICES[move.edge]
val (from, to) = state.topo.EDGE_VERTICES[move.edge]
for (endpoint in listOf(from, to)) {
for (near in CatanBoard.VERTEX_NEIGHBORS[endpoint]) {
for (near in state.topo.VERTEX_NEIGHBORS[endpoint]) {
best = maxOf(best, spotValue(state, near) * 0.8)
}
}
return best
}
/**
* What a ship on [edge] is heading for: the best open settlement spot reachable by sea from
* its far end (the end the fleet does not already hold), discounted by how far off it is,
* with a bonus for an island nobody has landed on.
*/
internal fun shipValue(state: CatanState, seat: Int, edge: Int): Double {
val topo = state.topo
val sea = state.sea ?: return 0.0
val (a, b) = topo.EDGE_VERTICES[edge]
fun held(v: Int): Boolean =
state.buildings[v]?.owner == seat || topo.VERTEX_EDGES[v].any { sea.ships[it] == seat }
val front = listOf(a, b).filter { !held(it) }.ifEmpty { return 0.0 }
var best = 0.0
val seen = mutableSetOf<Int>()
var frontier = front
var depth = 0
while (frontier.isNotEmpty() && depth <= 5) {
val next = mutableListOf<Int>()
for (v in frontier) {
if (!seen.add(v)) continue
if (CatanRules.vertexOpenForSettlement(state, v)) {
val island = topo.vertexIsland[v]
val fresh = island >= 0 && island != sea.home && sea.islandBonus[island] == NO_PLAYER
val value = (spotValue(state, v) + if (fresh) 0.5 else 0.0) * Math.pow(0.85, depth.toDouble())
best = maxOf(best, value)
}
for (e in topo.VERTEX_EDGES[v]) {
if (!topo.edgeSea[e] || sea.ships[e] != NO_PLAYER) continue
next += topo.otherEnd(e, v)
}
}
frontier = next
depth++
}
return best
}
/** How much the AI wants to hold one card of [resource]. */
internal fun want(resource: Resource): Double = when (resource) {
Resource.ORE -> 1.20 // the city
@@ -5,13 +5,14 @@ import kotlin.math.atan2
import kotlin.random.Random
/**
* The island as geometry: the 19 hexes, the 54 shared vertices, the 72
* shared edges, the coast and the nine harbour positions.
* The geometry of one board: its hexes, the shared vertices and edges between
* them, and which hexes are land.
*
* Everything is derived once, deterministically, from axial coordinates —
* there is no randomness and no state here; the layout of a particular
* game (which terrain and number sits on which hex) is [generate], and the
* live state lives in [CatanState].
* game (which terrain and number sits on which hex) is generated elsewhere
* ([CatanBoard.generate], [SeaBoards.generate]), and the live state lives in
* [CatanState].
*
* Coordinates: hexes are addressed axially ((q, r), pointy-top). Every
* position is kept on an exact integer lattice so vertices shared by
@@ -20,41 +21,25 @@ import kotlin.random.Random
* six corners follow the offsets in [CORNER_OFFSETS]. Vertex positions are
* in half-units of the hex grid; rendering scales and flips them (lattice
* +Y is toward the top of the board).
*
* The standard island ([CatanBoard.base]) is the 19-hex hexagon. A Seafarers
* map is a larger frame whose hexes are mostly sea; [land] says which.
*/
object CatanBoard {
/** The 19 hexes: every (q, r) within distance 2 of the centre. */
val HEXES: List<HexCoord> = buildList {
for (r in -2..2) {
for (q in -2..2) {
if (hexDistance(q, r, 0, 0) <= 2) add(HexCoord(q, r))
}
}
}
class Topology(
/** Every hex of the frame, land and sea alike. */
val HEXES: List<HexCoord>,
/** Whether each hex is land. The base island is all land; Seafarers maps are mostly sea. */
val land: List<Boolean> = List(HEXES.size) { true },
/** The standard 19-hex island: checks its counts and uses the fixed nine-harbour frame. */
private val standardFrame: Boolean = false,
/** Harbours wanted on a non-standard map. */
harbours: Int = 0,
) {
val HEX_COUNT: Int = HEXES.size
val hexIndex: Map<HexCoord, Int> = HEXES.withIndex().associate { (i, hex) -> hex to i }
/**
* Corner k of a hex, as a lattice offset from its centre. Pointy-top:
* corner 0 is the top corner, then clockwise.
*/
private val CORNER_OFFSETS = listOf(
0 to 2, 1 to 1, 1 to -1, 0 to -2, -1 to -1, -1 to 1,
)
/** Unit steps to a hex's six neighbours, matching the corner order. */
private val NEIGHBOR_STEPS = listOf(
0 to 1, 1 to 0, 1 to -1, 0 to -1, -1 to 0, -1 to 1,
)
/** The six board-corner hexes: pairwise non-adjacent by construction. */
private val CORNER_HEXES = listOf(
HexCoord(2, 0), HexCoord(0, 2), HexCoord(-2, 2),
HexCoord(-2, 0), HexCoord(0, -2), HexCoord(2, -2),
)
// -----------------------------------------------------------------------
// Vertices and edges
// -----------------------------------------------------------------------
@@ -93,9 +78,28 @@ object CatanBoard {
/** The coastal edges (those with only one hex) in ring order. */
val COASTAL: List<Int>
/** The nine fixed harbour positions, spread around the coast. */
/** The harbour positions: nine on the standard frame. */
val PORT_POSITIONS: List<Int>
// --- Land and sea (all land on the standard island) --------------------
/** A settlement may stand here: at least one of its hexes is land. */
val vertexLand: List<Boolean>
/** A road may lie here: at least one of its hexes is land. */
val edgeLand: List<Boolean>
/** A ship may sail here: at least one of its hexes is sea. */
val edgeSea: List<Boolean>
/** Which island each hex is on (a connected group of land hexes), or -1 for sea. */
val islandOf: List<Int>
val islandCount: Int
/** Which island each vertex touches, or -1 for open water. */
val vertexIsland: List<Int>
init {
// Vertices: assign ids in first-seen order over hexes then corners.
val vertexIds = mutableMapOf<Pair<Int, Int>, Int>()
@@ -169,27 +173,99 @@ object CatanBoard {
// The coast in walking order: sort the coastal edges by the angle of
// their midpoint around the board centre, which is the lattice
// origin. The board is a hexagon, so this yields one closed ring.
// origin. The standard board is a hexagon, so this yields one closed ring.
COASTAL = EDGE_HEXES.withIndex()
.filter { it.value.size == 1 }
.map { (e, _) -> e }
.sortedBy { e -> angleOf(midpoint(e)) }
// Harbours sit roughly every third or fourth coastal edge, like the
// physical frame: nine positions, steps summing to the whole ring.
PORT_POSITIONS = buildList {
var at = 0
for (step in listOf(3, 4, 3, 3, 4, 3, 3, 4, 3)) {
add(COASTAL[at % COASTAL.size])
at += step
}
}.distinct()
check(HEX_COUNT == 19) { "A Catan island has 19 hexes, found $HEX_COUNT" }
check(VERTEX_COUNT == 54) { "A Catan island has 54 vertices, found $VERTEX_COUNT" }
check(EDGE_COUNT == 72) { "A Catan island has 72 edges, found $EDGE_COUNT" }
check(COASTAL.size == 30) { "The coast has 30 edges, found ${COASTAL.size}" }
check(PORT_POSITIONS.size == 9) { "Nine harbours, found ${PORT_POSITIONS.size}" }
check(PORT_POSITIONS.all { EDGE_HEXES[it].size == 1 }) { "Harbours must be coastal" }
vertexLand = List(VERTEX_COUNT) { v -> VERTEX_HEXES[v].any { land[it] } }
edgeLand = List(EDGE_COUNT) { e -> EDGE_HEXES[e].any { land[it] } }
edgeSea = List(EDGE_COUNT) { e -> EDGE_HEXES[e].any { !land[it] } }
// Islands: connected groups of land hexes.
val island = IntArray(HEX_COUNT) { -1 }
var islands = 0
for (h in 0 until HEX_COUNT) {
if (!land[h] || island[h] != -1) continue
val stack = ArrayDeque(listOf(h))
island[h] = islands
while (stack.isNotEmpty()) {
val at = stack.removeLast()
for (n in HEX_NEIGHBORS[at]) {
if (land[n] && island[n] == -1) {
island[n] = islands
stack.addLast(n)
}
}
}
islands++
}
islandOf = island.toList()
islandCount = islands
vertexIsland = List(VERTEX_COUNT) { v ->
VERTEX_HEXES[v].firstOrNull { land[it] }?.let { island[it] } ?: -1
}
PORT_POSITIONS = if (standardFrame) {
// Harbours sit roughly every third or fourth coastal edge, like the
// physical frame: nine positions, steps summing to the whole ring.
buildList {
var at = 0
for (step in listOf(3, 4, 3, 3, 4, 3, 3, 4, 3)) {
add(COASTAL[at % COASTAL.size])
at += step
}
}.distinct()
} else {
seaPorts(harbours)
}
if (standardFrame) {
check(HEX_COUNT == 19) { "A Catan island has 19 hexes, found $HEX_COUNT" }
check(VERTEX_COUNT == 54) { "A Catan island has 54 vertices, found $VERTEX_COUNT" }
check(EDGE_COUNT == 72) { "A Catan island has 72 edges, found $EDGE_COUNT" }
check(COASTAL.size == 30) { "The coast has 30 edges, found ${COASTAL.size}" }
check(PORT_POSITIONS.size == 9) { "Nine harbours, found ${PORT_POSITIONS.size}" }
check(PORT_POSITIONS.all { EDGE_HEXES[it].size == 1 }) { "Harbours must be coastal" }
}
}
/**
* Harbour sites on a map with islands: edges where land meets sea, taken
* round the map by angle about the land's centre, [count] of them as evenly
* spaced as the coast allows, never two sharing a corner.
*/
private fun seaPorts(count: Int): List<Int> {
if (count <= 0) return emptyList()
val shore = (0 until EDGE_COUNT).filter { e ->
EDGE_HEXES[e].size == 2 && EDGE_HEXES[e].any { land[it] } && EDGE_HEXES[e].any { !land[it] }
}
if (shore.isEmpty()) return emptyList()
val landCentre = HEXES.indices.filter { land[it] }.let { hs ->
val pts = hs.map { hexCenter(it) }
(pts.sumOf { it.first } / pts.size.toDouble()) to (pts.sumOf { it.second } / pts.size.toDouble())
}
val ring = shore.sortedBy { e ->
val m = midpoint(e)
atan2(m.second - landCentre.second, m.first - landCentre.first)
}
val picked = mutableListOf<Int>()
val used = mutableSetOf<Int>()
val step = ring.size.toDouble() / count
for (i in 0 until count) {
val start = (i * step).toInt()
// The first edge at or after the ideal spot whose corners are free.
val e = (0 until ring.size).map { ring[(start + it) % ring.size] }.firstOrNull { cand ->
val (a, b) = EDGE_VERTICES[cand]
a !in used && b !in used && cand !in picked
} ?: continue
picked += e
val (a, b) = EDGE_VERTICES[e]
used += a
used += b
}
return picked
}
/** The other endpoint of [edge], given one [vertex] on it. */
@@ -207,19 +283,14 @@ object CatanBoard {
/** Whether two hexes share an edge. */
fun hexesAdjacent(a: Int, b: Int): Boolean = b in HEX_NEIGHBORS[a]
/** Hex-grid distance between two axial positions. */
fun hexDistance(q1: Int, r1: Int, q2: Int, r2: Int): Int {
val dq = q1 - q2
val dr = r1 - r2
return maxOf(kotlin.math.abs(dq), kotlin.math.abs(dr), kotlin.math.abs(dq + dr))
/** Lattice extent of the whole frame: min x, max x, min y, max y. */
val lattice: IntArray by lazy {
intArrayOf(
VERTEX_POS.minOf { it.first }, VERTEX_POS.maxOf { it.first },
VERTEX_POS.minOf { it.second }, VERTEX_POS.maxOf { it.second },
)
}
/**
* How often a number is expected to come up, in thirty-sixths. A 6 or an
* 8 comes up five times in thirty-six, a 2 or a 12 once.
*/
fun numberOdds(number: Int): Int = 6 - kotlin.math.abs(7 - number)
private fun midpoint(edge: Int): Pair<Double, Double> {
val (a, b) = EDGE_VERTICES[edge]
val pa = VERTEX_POS[a]
@@ -230,6 +301,84 @@ object CatanBoard {
private fun angleOf(point: Pair<Double, Double>): Double =
atan2(point.second, point.first)
companion object {
/**
* Corner k of a hex, as a lattice offset from its centre. Pointy-top:
* corner 0 is the top corner, then clockwise.
*/
private val CORNER_OFFSETS = listOf(
0 to 2, 1 to 1, 1 to -1, 0 to -2, -1 to -1, -1 to 1,
)
/** Unit steps to a hex's six neighbours, matching the corner order. */
private val NEIGHBOR_STEPS = listOf(
0 to 1, 1 to 0, 1 to -1, 0 to -1, -1 to 0, -1 to 1,
)
/** Hex-grid distance between two axial positions. */
fun hexDistance(q1: Int, r1: Int, q2: Int, r2: Int): Int {
val dq = q1 - q2
val dr = r1 - r2
return maxOf(kotlin.math.abs(dq), kotlin.math.abs(dr), kotlin.math.abs(dq + dr))
}
/**
* How often a number is expected to come up, in thirty-sixths. A 6 or an
* 8 comes up five times in thirty-six, a 2 or a 12 once.
*/
fun numberOdds(number: Int): Int = 6 - kotlin.math.abs(7 - number)
}
}
/**
* The standard island: the 19 hexes, the 54 shared vertices, the 72 shared
* edges, the coast and the nine harbour positions. Every table below is the
* standard frame's [Topology]; the rules read the topology of the *state* they
* are working on ([CatanState.topo]), which is this one in every game but Seafarers.
*/
object CatanBoard {
/** The standard island's geometry. */
val base: Topology = Topology(
HEXES = buildList {
for (r in -2..2) {
for (q in -2..2) {
if (Topology.hexDistance(q, r, 0, 0) <= 2) add(HexCoord(q, r))
}
}
},
standardFrame = true,
)
val HEXES: List<HexCoord> get() = base.HEXES
val HEX_COUNT: Int get() = base.HEX_COUNT
val hexIndex: Map<HexCoord, Int> get() = base.hexIndex
val VERTEX_POS: List<Pair<Int, Int>> get() = base.VERTEX_POS
val VERTEX_COUNT: Int get() = base.VERTEX_COUNT
val HEX_CORNERS: List<List<Int>> get() = base.HEX_CORNERS
val EDGE_VERTICES: List<Pair<Int, Int>> get() = base.EDGE_VERTICES
val EDGE_COUNT: Int get() = base.EDGE_COUNT
val VERTEX_EDGES: List<List<Int>> get() = base.VERTEX_EDGES
val VERTEX_NEIGHBORS: List<List<Int>> get() = base.VERTEX_NEIGHBORS
val VERTEX_HEXES: List<List<Int>> get() = base.VERTEX_HEXES
val EDGE_HEXES: List<List<Int>> get() = base.EDGE_HEXES
val EDGE_NEIGHBORS: List<List<Int>> get() = base.EDGE_NEIGHBORS
val HEX_NEIGHBORS: List<List<Int>> get() = base.HEX_NEIGHBORS
val COASTAL: List<Int> get() = base.COASTAL
val PORT_POSITIONS: List<Int> get() = base.PORT_POSITIONS
fun otherEnd(edge: Int, vertex: Int): Int = base.otherEnd(edge, vertex)
fun hexCenter(h: Int): Pair<Int, Int> = base.hexCenter(h)
fun hexesAdjacent(a: Int, b: Int): Boolean = base.hexesAdjacent(a, b)
fun hexDistance(q1: Int, r1: Int, q2: Int, r2: Int): Int = Topology.hexDistance(q1, r1, q2, r2)
fun numberOdds(number: Int): Int = Topology.numberOdds(number)
/** The six board-corner hexes: pairwise non-adjacent by construction. */
private val CORNER_HEXES = listOf(
HexCoord(2, 0), HexCoord(0, 2), HexCoord(-2, 2),
HexCoord(-2, 0), HexCoord(0, -2), HexCoord(2, -2),
)
// -----------------------------------------------------------------------
// Island generation
// -----------------------------------------------------------------------
@@ -273,6 +273,9 @@ object CatanCk {
CatanPhase.FREE_ROADS ->
CatanRules.roadMoves(state, seat) + listOf<CatanMove>(EndFreeRoads)
// Seafarers is never combined with Cities & Knights.
CatanPhase.GOLD -> emptyList()
CatanPhase.TRADE_PENDING -> {
val offer = state.ck!!.goodsOffer
if (offer == null) {
@@ -35,7 +35,7 @@ internal class CatanCkAi(private val ai: CatanAi) {
CatanPhase.PLAYING -> choosePlaying(state, seat, legal)
CatanPhase.DISCARDING, CatanPhase.GAME_OVER -> legal.firstOrNull()
CatanPhase.DISCARDING, CatanPhase.GOLD, CatanPhase.GAME_OVER -> legal.firstOrNull()
?: error("Nothing to choose in phase ${state.phase}")
}
@@ -39,6 +39,12 @@ enum class Terrain(val produces: Resource?) {
HILLS(Resource.BRICK),
MOUNTAINS(Resource.ORE),
DESERT(null),
/** Seafarers: open water. Ships sail on it; nothing is built on it and nothing produces. */
SEA(null),
/** Seafarers: a gold field. A settlement beside it takes any resource its owner likes when its number rolls. */
GOLD(null),
}
/** Axial coordinate of one hex on the board. See [CatanBoard]. */
@@ -114,6 +120,9 @@ enum class CatanPhase {
/** The player whose turn it is must roll the dice. */
AWAIT_ROLL,
/** Seafarers: the first seat in [SeaState.gold] is choosing the resources its gold fields paid. */
GOLD,
/** Players holding more than 7 resource cards discard half. */
DISCARDING,
@@ -150,6 +159,9 @@ enum class CatanRuleSet {
/** Cities & Knights: commodities, knights, barbarians, city improvements. */
CITIES_AND_KNIGHTS,
/** Seafarers: ships, a pirate, gold fields and islands to settle (see `docs/RULES-catan-seafarers.md`). */
SEAFARERS,
}
@Serializable
@@ -230,9 +242,17 @@ data class CatanState(
val log: List<String>,
/** Everything Cities & Knights adds; null in the base game. */
val ck: CkState? = null,
/** Everything Seafarers adds (ships, the pirate, islands); null in the other rule sets. */
val sea: SeaState? = null,
) {
val playerCount: Int get() = options.playerCount
/** The geometry this game is played on: the standard island, or a Seafarers map. */
val topo: Topology get() = sea?.let { SeaBoards.topology(it.scenario) } ?: CatanBoard.base
/** Whether this table plays Seafarers. */
val isSea: Boolean get() = sea != null
/** Whether this table plays Cities & Knights. */
val isCk: Boolean get() = ck != null
@@ -377,3 +397,48 @@ data object EndTurn : CatanMove
@Serializable
@SerialName("endFreeRoads")
data object EndFreeRoads : CatanMove
/**
* Seafarers state: the ships, the pirate and the islands, alongside the base game's
* roads, robber and buildings (which keep working exactly as before).
*/
@Serializable
data class SeaState(
val scenario: SeaScenario,
/** Owning seat per edge, or [NO_PLAYER]: a ship on that edge. Roads stay in [CatanState.roads]. */
val ships: List<Int>,
/** The hex the pirate occupies: always a sea hex. */
val pirate: Int,
/** Ships built this turn (they may not sail until the next) and whether a ship has sailed this turn. */
val builtThisTurn: List<Int> = emptyList(),
val shipMoved: Boolean = false,
/** Who holds the "new island" bonus for each island, or [NO_PLAYER]. The home island's is never awarded. */
val islandBonus: List<Int>,
/** The island the players start on. */
val home: Int,
/** Gold owed after a roll, front first: each seat chooses [GoldOwed.count] cards. */
val gold: List<GoldOwed> = emptyList(),
)
@Serializable
data class GoldOwed(val seat: Int, val count: Int)
/** Build a ship on [edge]: 1 lumber and 1 wool, on a sea edge joined to your fleet or a harbour town. */
@Serializable
@SerialName("ship")
data class BuildShip(val edge: Int) : CatanMove
/** Sail the ship on [from] to [to]: once a turn, never the turn it was built, only from the open end of a route. */
@Serializable
@SerialName("moveShip")
data class MoveShip(val from: Int, val to: Int) : CatanMove
/** Move the pirate to the sea hex [hex] and rob [victim], a seat with a ship beside it, if there is one. */
@Serializable
@SerialName("pirate")
data class MovePirate(val hex: HexCoord, val victim: Int? = null) : CatanMove
/** Choose the [cards] a gold field paid. */
@Serializable
@SerialName("gold")
data class PickGold(val cards: List<Resource>) : CatanMove
@@ -54,7 +54,12 @@ object CatanRules : GameRules<CatanState, CatanMove> {
"Seat count ${config.seats.size} does not match ${options.playerCount} players"
}
val board = CatanBoard.generate(config.seed)
val sea = options.ruleSet == CatanRuleSet.SEAFARERS
val scenario = SeaScenario.NEW_SHORES
val seaBoard = if (sea) SeaBoards.generate(scenario, config.seed) else null
val topo = if (seaBoard != null) SeaBoards.topology(scenario) else CatanBoard.base
val board = seaBoard?.let { CatanBoard.Generated(it.terrains, it.numbers, it.robber, it.ports) }
?: CatanBoard.generate(config.seed)
// A different random stream than the island, so the same seed still
// produces both a fixed island and a fixed deck.
val ck = options.ruleSet == CatanRuleSet.CITIES_AND_KNIGHTS
@@ -79,8 +84,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
numbers = board.numbers,
robber = board.robber,
ports = board.ports,
buildings = List(CatanBoard.VERTEX_COUNT) { null },
roads = List(CatanBoard.EDGE_COUNT) { NO_PLAYER },
buildings = List(topo.VERTEX_COUNT) { null },
roads = List(topo.EDGE_COUNT) { NO_PLAYER },
resources = List(options.playerCount) {
Resource.entries.associateWith { 0 }
},
@@ -98,10 +103,19 @@ object CatanRules : GameRules<CatanState, CatanMove> {
rngStep = 0,
winner = null,
log = listOf(
"Catan${if (ck) " — Cities & Knights" else ""} — ${options.playerCount} players, " +
"Catan${if (ck) " — Cities & Knights" else if (sea) " — Seafarers: ${scenario.title}" else ""} — ${options.playerCount} players, " +
"first to ${options.pointsToWin} victory points.",
),
ck = if (ck) CatanCk.initial(options, config.seed) else null,
sea = seaBoard?.let {
SeaState(
scenario = scenario,
ships = List(topo.EDGE_COUNT) { NO_PLAYER },
pirate = it.pirate,
islandBonus = List(topo.islandCount) { NO_PLAYER },
home = it.home,
)
},
)
}
@@ -114,6 +128,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
?: state.ck?.goodsOffer?.asking?.takeIf { it >= 0 }
CatanPhase.TRADE_COUNTERS -> state.turn
CatanPhase.CHORE -> state.ck?.chores?.firstOrNull()?.seat
CatanPhase.GOLD -> state.sea?.gold?.firstOrNull()?.seat
CatanPhase.AWAIT_ROLL, CatanPhase.ROBBER,
CatanPhase.PLAYING, CatanPhase.FREE_ROADS,
-> state.turn
@@ -162,12 +177,17 @@ object CatanRules : GameRules<CatanState, CatanMove> {
CatanPhase.DISCARDING -> discardMoves(state, seat)
CatanPhase.ROBBER -> robberMoves(state, seat)
CatanPhase.ROBBER -> robberMoves(state, seat) +
(if (state.isSea) SeaRules.pirateMoves(state, seat) else emptyList())
CatanPhase.PLAYING -> playingMoves(state, seat)
CatanPhase.FREE_ROADS ->
roadMoves(state, seat) + listOf<CatanMove>(EndFreeRoads)
roadMoves(state, seat) +
(if (state.isSea) SeaRules.buildShipMoves(state, seat) else emptyList()) +
listOf<CatanMove>(EndFreeRoads)
CatanPhase.GOLD -> SeaRules.goldMoves(state)
CatanPhase.TRADE_PENDING -> tradeResponses(state, seat)
@@ -179,10 +199,12 @@ object CatanRules : GameRules<CatanState, CatanMove> {
internal fun setupMoves(state: CatanState): List<SetupPlace> {
val moves = mutableListOf<SetupPlace>()
for (v in 0 until CatanBoard.VERTEX_COUNT) {
for (v in 0 until state.topo.VERTEX_COUNT) {
if (!vertexOpenForSettlement(state, v)) continue
for (e in CatanBoard.VERTEX_EDGES[v]) {
if (state.roads[e] != NO_PLAYER) continue
// Seafarers: everyone starts on the home island.
if (state.sea != null && state.topo.vertexIsland[v] != state.sea.home) continue
for (e in state.topo.VERTEX_EDGES[v]) {
if (state.roads[e] != NO_PLAYER || !state.topo.edgeLand[e]) continue
moves += SetupPlace(v, e)
}
}
@@ -215,18 +237,18 @@ object CatanRules : GameRules<CatanState, CatanMove> {
private fun robberMoves(state: CatanState, seat: Int): List<MoveRobber> {
val moves = mutableListOf<MoveRobber>()
for (h in 0 until CatanBoard.HEX_COUNT) {
if (h == state.robber) continue
val victims = CatanBoard.HEX_CORNERS[h]
for (h in 0 until state.topo.HEX_COUNT) {
if (h == state.robber || !state.topo.land[h]) continue
val victims = state.topo.HEX_CORNERS[h]
.mapNotNull { state.buildings[it] }
.map { it.owner }
.filter { it != seat }
.distinct()
.filter { state.resourceCount(it) > 0 }
if (victims.isEmpty()) {
moves += MoveRobber(CatanBoard.HEXES[h])
moves += MoveRobber(state.topo.HEXES[h])
} else {
victims.forEach { moves += MoveRobber(CatanBoard.HEXES[h], it) }
victims.forEach { moves += MoveRobber(state.topo.HEXES[h], it) }
}
}
return moves
@@ -245,6 +267,10 @@ object CatanRules : GameRules<CatanState, CatanMove> {
if (state.devDeck.isNotEmpty() && affordable(state, seat, DEV_COST)) {
moves += BuyDevCard
}
if (state.isSea) {
if (affordable(state, seat, SeaRules.SHIP_COST)) moves += SeaRules.buildShipMoves(state, seat)
moves += SeaRules.moveShipMoves(state, seat)
}
if (!state.devCardPlayedThisTurn) {
for (card in state.devHands[seat]) {
@@ -301,8 +327,12 @@ object CatanRules : GameRules<CatanState, CatanMove> {
if (state.roads.count { it == seat } >= MAX_ROADS) {
emptyList()
} else {
(0 until CatanBoard.EDGE_COUNT)
.filter { state.roads[it] == NO_PLAYER && roadConnects(state, seat, it) }
(0 until state.topo.EDGE_COUNT)
.filter {
state.roads[it] == NO_PLAYER && state.topo.edgeLand[it] &&
state.sea?.ships?.get(it).let { ship -> ship == null || ship == NO_PLAYER } &&
roadConnects(state, seat, it)
}
.map { BuildRoad(it) }
}
@@ -316,9 +346,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
if (piecesOf(state, seat, BuildingType.SETTLEMENT) >= MAX_SETTLEMENTS) {
emptyList()
} else {
(0 until CatanBoard.VERTEX_COUNT).filter { v ->
(0 until state.topo.VERTEX_COUNT).filter { v ->
vertexOpenForSettlement(state, v) &&
CatanBoard.VERTEX_EDGES[v].any { state.roads[it] == seat }
state.topo.VERTEX_EDGES[v].any { state.roads[it] == seat || state.sea?.ships?.get(it) == seat }
}
}
@@ -326,7 +356,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
if (piecesOf(state, seat, BuildingType.CITY) >= MAX_CITIES) {
emptyList()
} else {
(0 until CatanBoard.VERTEX_COUNT).filter { v ->
(0 until state.topo.VERTEX_COUNT).filter { v ->
state.buildings[v] == CatanBuilding(BuildingType.SETTLEMENT, seat)
}
}
@@ -373,8 +403,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
/** An empty vertex with no building on it or one edge away. */
internal fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean =
state.buildings[v] == null && !hasKnight(state, v) &&
CatanBoard.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null }
state.buildings[v] == null && !hasKnight(state, v) && state.topo.vertexLand[v] &&
state.topo.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null }
/**
* A road on [edge] would join [seat]'s network of roads and buildings.
@@ -385,7 +415,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
* of your own roads ending at the same crossing.
*/
private fun roadConnects(state: CatanState, seat: Int, edge: Int): Boolean {
val (a, b) = CatanBoard.EDGE_VERTICES[edge]
val (a, b) = state.topo.EDGE_VERTICES[edge]
return networkReaches(state, seat, a) || networkReaches(state, seat, b)
}
@@ -397,7 +427,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
if (knight != null && knight.owner != seat) return false
// 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 }
return state.topo.VERTEX_EDGES[vertex].any { state.roads[it] == seat }
}
private fun offerLegal(state: CatanState, seat: Int, move: OfferTrade): Boolean {
@@ -423,6 +453,10 @@ object CatanRules : GameRules<CatanState, CatanMove> {
is RollDice -> applyRoll(state, seat, move)
is Discard -> applyDiscard(state, seat, move)
is MoveRobber -> applyRobber(state, seat, move)
is BuildShip -> applyBuildShip(state, seat, move)
is MoveShip -> applyMoveShip(state, seat, move)
is MovePirate -> applyMovePirate(state, seat, move)
is PickGold -> applyPickGold(state, seat, move)
is BuildRoad -> applyBuildRoad(state, seat, move)
is BuildSettlement -> applyBuildSettlement(state, seat, move)
is BuildCity -> applyBuildCity(state, seat, move)
@@ -461,7 +495,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
if (second) {
// The second settlement earns one card from each adjacent
// producing hex, supply permitting.
val adjacent = CatanBoard.VERTEX_HEXES[move.vertex].mapNotNull { state.terrains[it].produces }
val adjacent = state.topo.VERTEX_HEXES[move.vertex].mapNotNull { state.terrains[it].produces }
val granted = adjacent.groupingBy { it }.eachCount().filter { (res, need) ->
need <= bankSupply(state, res)
}
@@ -517,11 +551,11 @@ object CatanRules : GameRules<CatanState, CatanMove> {
val gains = List(state.playerCount) { mutableMapOf<Resource, Int>() }
for (resource in Resource.entries) {
var demand = 0
for (h in 0 until CatanBoard.HEX_COUNT) {
for (h in 0 until state.topo.HEX_COUNT) {
if (state.numbers[h] != number) continue
if (h == state.robber) continue
if (state.terrains[h].produces != resource) continue
for (v in CatanBoard.HEX_CORNERS[h]) {
for (v in state.topo.HEX_CORNERS[h]) {
val building = state.buildings[v] ?: continue
val amount = if (building.type == BuildingType.CITY) 2 else 1
gains[building.owner][resource] = (gains[building.owner][resource] ?: 0) + amount
@@ -535,6 +569,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
}
}
// Seafarers: gold fields pay a card of the owner's choice, asked for after the rest is dealt.
val goldOwed = if (state.isSea) goldOwed(state, number) else emptyList()
var s = state.copy(phase = CatanPhase.PLAYING)
val whoGot = mutableListOf<String>()
for (seat in 0 until s.playerCount) {
@@ -543,10 +580,31 @@ object CatanRules : GameRules<CatanState, CatanMove> {
s = s.copy(resources = withCounts(s.resources, seat, got, 1))
whoGot += "seat $seat takes ${describe(got)}"
}
val said = if (whoGot.isEmpty()) "nothing for anyone" else whoGot.joinToString(", ")
return s.copy(
val goldLine = goldOwed.joinToString("") { ", seat ${it.seat} is owed ${it.count} gold" }
val said = if (whoGot.isEmpty() && goldOwed.isEmpty()) "nothing for anyone" else
(whoGot.joinToString(", ") + goldLine).trimStart(',', ' ')
s = s.copy(
log = s.log + "Seat ${state.turn} rolls ${state.lastDice.joinToString(" + ")} — $said.",
)
if (goldOwed.isEmpty()) return s
return SeaRules.settleGold(s.copy(sea = s.sea!!.copy(gold = goldOwed)))
}
/** What each seat is owed for gold fields showing [number], in seat order from the roller. */
private fun goldOwed(state: CatanState, number: Int): List<GoldOwed> {
val topo = state.topo
val owed = IntArray(state.playerCount)
for (h in 0 until topo.HEX_COUNT) {
if (state.terrains[h] != Terrain.GOLD || state.numbers[h] != number || h == state.robber) continue
for (v in topo.HEX_CORNERS[h]) {
val building = state.buildings[v] ?: continue
owed[building.owner] += if (building.type == BuildingType.CITY) 2 else 1
}
}
return (0 until state.playerCount)
.map { (state.turn + it) % state.playerCount }
.filter { owed[it] > 0 }
.map { GoldOwed(it, owed[it]) }
}
private fun applyDiscard(state: CatanState, seat: Int, move: Discard): CatanState {
@@ -565,7 +623,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
private fun applyRobber(state: CatanState, seat: Int, move: MoveRobber): CatanState {
if (state.isCk) return CatanCk.applyRobber(state, seat, move)
val hex = CatanBoard.hexIndex[move.hex]
val hex = state.topo.hexIndex[move.hex]
?: throw IllegalArgumentException("No hex at ${move.hex}")
var s = state.copy(robber = hex, rngStep = state.rngStep + 1)
var stolen: String? = null
@@ -599,6 +657,68 @@ object CatanRules : GameRules<CatanState, CatanMove> {
return checkWin(s.copy(log = s.log + "Seat $seat builds a road on ${move.edge}."))
}
private fun applyBuildShip(state: CatanState, seat: Int, move: BuildShip): CatanState {
val free = state.phase == CatanPhase.FREE_ROADS
val sea = state.sea!!
var s = state.copy(
sea = sea.copy(
ships = sea.ships.toMutableList().also { it[move.edge] = seat },
builtThisTurn = sea.builtThisTurn + move.edge,
),
)
if (!free) s = payCost(s, seat, SeaRules.SHIP_COST)
s = refreshLongestRoad(s)
if (free) {
val left = s.pendingFreeRoads - 1
s = s.copy(pendingFreeRoads = left)
if (left == 0) s = s.copy(phase = phaseAfterRoads(s))
}
return checkWin(s.copy(log = s.log + "Seat $seat launches a ship on ${move.edge}."))
}
private fun applyMoveShip(state: CatanState, seat: Int, move: MoveShip): CatanState {
val sea = state.sea!!
val ships = sea.ships.toMutableList().also {
it[move.from] = NO_PLAYER
it[move.to] = seat
}
// The ship is on a new edge, but it has sailed for the turn and may not sail again.
val s = state.copy(
sea = sea.copy(ships = ships, shipMoved = true, builtThisTurn = sea.builtThisTurn + move.to),
)
return checkWin(refreshLongestRoad(s).let {
it.copy(log = it.log + "Seat $seat sails a ship from ${move.from} to ${move.to}.")
})
}
private fun applyMovePirate(state: CatanState, seat: Int, move: MovePirate): CatanState {
val hex = state.topo.hexIndex[move.hex] ?: throw IllegalArgumentException("No hex at ${move.hex}")
var s = state.copy(sea = state.sea!!.copy(pirate = hex), rngStep = state.rngStep + 1)
var stolen = ""
if (move.victim != null) {
val rng = Random(state.seed * 1_000_003L + state.rngStep)
val victimHand = state.handOf(move.victim)
val card = victimHand[rng.nextInt(victimHand.size)]
val less = withCards(s.resources, move.victim, listOf(card), -1)
s = s.copy(resources = withCards(less, seat, listOf(card), 1))
stolen = " and takes a ${card.name.lowercase()} from seat ${move.victim}"
}
return s.copy(
phase = CatanPhase.PLAYING,
log = s.log + "Seat $seat sends the pirate to (${move.hex.q},${move.hex.r})$stolen.",
)
}
private fun applyPickGold(state: CatanState, seat: Int, move: PickGold): CatanState {
val sea = state.sea!!
val s = state.copy(
resources = withCards(state.resources, seat, move.cards, 1),
sea = sea.copy(gold = sea.gold.drop(1)),
log = state.log + "Seat $seat takes ${describe(countOf(move.cards))} from the gold.",
)
return SeaRules.settleGold(s)
}
private fun applyBuildSettlement(state: CatanState, seat: Int, move: BuildSettlement): CatanState {
var s = state.copy(
buildings = state.buildings.toMutableList().also {
@@ -606,6 +726,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
},
)
s = payCost(s, seat, SETTLEMENT_COST)
s = SeaRules.claimIsland(s, seat, move.vertex)
// A settlement can break another player's road, so the award is
// recomputed for everyone.
s = refreshLongestRoad(s)
@@ -831,6 +952,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
phase = CatanPhase.AWAIT_ROLL,
devCardPlayedThisTurn = false,
pendingTrade = null,
sea = state.sea?.copy(builtThisTurn = emptyList(), shipMoved = false),
)
// Someone can cross the target on another player's turn (a broken
// Longest Road re-awarded to a third player); they win when their
@@ -869,6 +991,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
}
state.longestRoadOwner?.let { points[it] += 5 }
state.largestArmyOwner?.let { points[it] += 5 }
if (state.isSea) for (seat in points.indices) points[seat] += SeaRules.bonusPoints(state, seat)
return points.toList()
}
@@ -927,16 +1050,17 @@ object CatanRules : GameRules<CatanState, CatanMove> {
* one road, not two — the intersection is a branch).
*/
fun longestRoadFor(state: CatanState, seat: Int): Int {
if (state.isSea) return SeaRules.longestRouteFor(state, seat)
var best = 0
val blocked = BooleanArray(CatanBoard.VERTEX_COUNT)
for (v in 0 until CatanBoard.VERTEX_COUNT) {
val blocked = BooleanArray(state.topo.VERTEX_COUNT)
for (v in 0 until state.topo.VERTEX_COUNT) {
val building = state.buildings[v]
if (building != null && building.owner != seat) blocked[v] = true
}
state.ck?.knights?.forEach { if (it.owner != seat) blocked[it.vertex] = true }
for (start in 0 until CatanBoard.VERTEX_COUNT) {
if (CatanBoard.VERTEX_EDGES[start].none { state.roads[it] == seat }) continue
val visited = BooleanArray(CatanBoard.VERTEX_COUNT)
for (start in 0 until state.topo.VERTEX_COUNT) {
if (state.topo.VERTEX_EDGES[start].none { state.roads[it] == seat }) continue
val visited = BooleanArray(state.topo.VERTEX_COUNT)
best = maxOf(best, roadDfs(state, seat, start, blocked, visited))
}
return best
@@ -951,9 +1075,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
): Int {
visited[v] = true
var best = 0
for (e in CatanBoard.VERTEX_EDGES[v]) {
for (e in state.topo.VERTEX_EDGES[v]) {
if (state.roads[e] != seat) continue
val w = CatanBoard.otherEnd(e, v)
val w = state.topo.otherEnd(e, v)
if (visited[w]) continue
if (blocked[w]) {
// The segment may run up to an opponent's building, but not
@@ -1039,7 +1163,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
for (port in state.ports) {
if (port.resource != null && port.resource != give) continue
if (port.ratio >= best) continue
val (a, b) = CatanBoard.EDGE_VERTICES[port.edge]
val (a, b) = state.topo.EDGE_VERTICES[port.edge]
val mine = state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat
if (mine) best = port.ratio
}
@@ -1050,7 +1174,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
fun genericPortRatio(state: CatanState, seat: Int): Int {
for (port in state.ports) {
if (port.resource != null) continue
val (a, b) = CatanBoard.EDGE_VERTICES[port.edge]
val (a, b) = state.topo.EDGE_VERTICES[port.edge]
if (state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat) return port.ratio
}
return 4
@@ -0,0 +1,144 @@
package org.prolibertate.games.game.catan
import kotlinx.serialization.Serializable
import kotlin.random.Random
/** The Seafarers maps on offer. Each is a fixed arrangement of land and sea; the tiles and numbers on it are shuffled. */
@Serializable
enum class SeaScenario(val title: String, val blurb: String) {
NEW_SHORES(
"Heading for New Shores",
"One large home island and three small ones to find by ship. Gold fields on two of the small islands.",
),
}
/**
* The Seafarers maps as geometry plus a generator for one game's tiles.
*
* A map is drawn below as text: `.` is sea, `#` a land hex that gets a random resource,
* `$` a gold field, `D` the desert. Odd rows are shifted half a hex to the right (the usual
* "odd-r" offset layout). The outermost ring is always sea, so every coast is closed.
*/
object SeaBoards {
private val NEW_SHORES_MAP = listOf(
"..........",
"...##.....",
"..####.$#.",
".#####..#.",
".##D###...",
"..####..#.",
".......##.",
".$#.......",
"..........",
)
private fun rows(scenario: SeaScenario): List<String> = when (scenario) {
SeaScenario.NEW_SHORES -> NEW_SHORES_MAP
}
private val topologies = HashMap<SeaScenario, Topology>()
/** The frame of [scenario]: every hex, with its land mask. Built once and shared. */
fun topology(scenario: SeaScenario): Topology = synchronized(topologies) {
topologies.getOrPut(scenario) { build(scenario) }
}
/** Hex (row, col) of the text map as an axial coordinate (odd-r offset). */
private fun coord(row: Int, col: Int): HexCoord = HexCoord(col - (row - (row and 1)) / 2, row)
private fun build(scenario: SeaScenario): Topology {
val map = rows(scenario)
val hexes = mutableListOf<HexCoord>()
val land = mutableListOf<Boolean>()
map.forEachIndexed { row, text ->
text.forEachIndexed { col, ch ->
hexes += coord(row, col)
land += ch != '.'
}
}
return Topology(hexes, land, standardFrame = false, harbours = 9)
}
/** Terrain kind the map asks of each hex: sea, gold, desert, or "pick a resource". */
private fun kinds(scenario: SeaScenario): List<Char> = rows(scenario).flatMap { it.toList() }
/** One game's tiles on a Seafarers map. */
@Serializable
data class Generated(
val terrains: List<Terrain>,
val numbers: List<Int>,
val robber: Int,
val pirate: Int,
val ports: List<Port>,
val home: Int,
)
private val RESOURCE_TILES = listOf(
Terrain.FOREST, Terrain.PASTURE, Terrain.FIELDS, Terrain.HILLS, Terrain.MOUNTAINS,
)
/** Number tokens: the standard 18 plus ten more, so every land hex but the desert has one. */
private val TOKENS = listOf(2, 3, 3, 4, 4, 5, 5, 6, 6, 8, 8, 9, 9, 10, 10, 11, 11, 12) +
listOf(3, 4, 5, 6, 8, 9, 10, 11, 5, 9)
fun generate(scenario: SeaScenario, seed: Long): Generated {
val topo = topology(scenario)
val kinds = kinds(scenario)
val random = Random(seed)
// Resources fill the '#' hexes as evenly as the count allows, in shuffled order.
val plain = kinds.indices.filter { kinds[it] == '#' }
val pool = List(plain.size) { RESOURCE_TILES[it % RESOURCE_TILES.size] }.shuffled(random)
val terrains = MutableList(kinds.size) { Terrain.SEA }
kinds.forEachIndexed { h, ch ->
terrains[h] = when (ch) {
'$' -> Terrain.GOLD
'D' -> Terrain.DESERT
'#' -> pool[plain.indexOf(h)]
else -> Terrain.SEA
}
}
val numbered = terrains.indices.filter { terrains[it] != Terrain.SEA && terrains[it] != Terrain.DESERT }
check(numbered.size <= TOKENS.size) { "Not enough number tokens for ${numbered.size} hexes" }
val tokens = TOKENS.take(numbered.size).shuffled(random)
val numbers = IntArray(terrains.size)
var placed = false
var attempt = 0
while (!placed && attempt < 200) {
val order = numbered.shuffled(random)
val assigned = IntArray(terrains.size)
order.forEachIndexed { i, h -> assigned[h] = tokens[i] }
val reds = numbered.filter { assigned[it] == 6 || assigned[it] == 8 }
val clash = reds.any { a -> reds.any { b -> a < b && topo.hexesAdjacent(a, b) } }
if (!clash || attempt == 199) {
assigned.copyInto(numbers)
placed = true
}
attempt++
}
val portTypes = (
listOf(Resource.LUMBER, Resource.BRICK, Resource.WOOL, Resource.GRAIN, Resource.ORE) + List(4) { null }
).shuffled(random)
val ports = topo.PORT_POSITIONS.mapIndexed { i, edge ->
val resource = portTypes[i % portTypes.size]
if (resource == null) Port(edge, null, 3) else Port(edge, resource, 2)
}
val desert = terrains.indexOf(Terrain.DESERT).takeIf { it >= 0 }
?: numbered.first()
val home = (0 until topo.islandCount).maxByOrNull { island -> topo.islandOf.count { it == island } } ?: 0
// The pirate starts in the open sea: the sea hex farthest from every shore.
val sea = terrains.indices.filter { terrains[it] == Terrain.SEA }
val pirate = sea.maxByOrNull { h ->
val c = topo.HEXES[h]
terrains.indices.filter { terrains[it] != Terrain.SEA }.minOfOrNull {
Topology.hexDistance(c.q, c.r, topo.HEXES[it].q, topo.HEXES[it].r)
} ?: 0
} ?: sea.first()
return Generated(terrains, numbers.toList(), desert, pirate, ports, home)
}
}
@@ -0,0 +1,205 @@
package org.prolibertate.games.game.catan
/**
* The rules Seafarers adds to the base game, kept apart from [CatanRules] so the base
* game's code reads as it always did. [CatanRules] calls in here only when a game has
* [CatanState.sea]. See `docs/RULES-catan-seafarers.md`.
*/
internal object SeaRules {
const val MAX_SHIPS = 15
/** Points for being the first to settle an island other than the home island. */
const val ISLAND_BONUS = 2
val SHIP_COST: Map<Resource, Int> = mapOf(Resource.LUMBER to 1, Resource.WOOL to 1)
// -----------------------------------------------------------------------
// Ships
// -----------------------------------------------------------------------
private fun fleetOf(state: CatanState, seat: Int): Int = state.sea!!.ships.count { it == seat }
/**
* Whether [seat]'s ships and towns reach [vertex], so a ship may start there. A town
* of anyone else is a wall; [ignoring] is a ship that is being lifted off the board.
*/
private fun shipReaches(state: CatanState, seat: Int, vertex: Int, ignoring: Int): Boolean {
val building = state.buildings[vertex]
if (building != null) return building.owner == seat
val sea = state.sea!!
return state.topo.VERTEX_EDGES[vertex].any { it != ignoring && sea.ships[it] == seat }
}
/** A sea edge a ship could sit on, joined to [seat]'s fleet or a town of theirs. */
private fun shipSites(state: CatanState, seat: Int, ignoring: Int = -1): List<Int> {
val topo = state.topo
val sea = state.sea!!
return (0 until topo.EDGE_COUNT).filter { e ->
topo.edgeSea[e] &&
state.roads[e] == NO_PLAYER &&
(sea.ships[e] == NO_PLAYER || e == ignoring) &&
sea.pirate !in topo.EDGE_HEXES[e] &&
topo.EDGE_VERTICES[e].let { (a, b) ->
shipReaches(state, seat, a, ignoring) || shipReaches(state, seat, b, ignoring)
}
}
}
fun buildShipMoves(state: CatanState, seat: Int): List<BuildShip> =
if (fleetOf(state, seat) >= MAX_SHIPS) emptyList() else shipSites(state, seat).map { BuildShip(it) }
/** Whether the ship on [edge] sits at the open end of a route: one end touches nothing else of [seat]'s. */
private fun atOpenEnd(state: CatanState, seat: Int, edge: Int): Boolean {
val topo = state.topo
val sea = state.sea!!
return topo.EDGE_VERTICES[edge].toList().any { v ->
state.buildings[v] == null &&
topo.VERTEX_EDGES[v].none { it != edge && (state.roads[it] == seat || sea.ships[it] == seat) }
}
}
/** A ship may sail once a turn, never the turn it was built, only from the open end of a route. */
fun moveShipMoves(state: CatanState, seat: Int): List<MoveShip> {
val sea = state.sea!!
if (sea.shipMoved) return emptyList()
val moves = mutableListOf<MoveShip>()
for (e in sea.ships.indices) {
if (sea.ships[e] != seat || e in sea.builtThisTurn || !atOpenEnd(state, seat, e)) continue
shipSites(state, seat, ignoring = e).filter { it != e }.forEach { moves += MoveShip(e, it) }
}
return moves
}
// -----------------------------------------------------------------------
// Pirate
// -----------------------------------------------------------------------
/** Seats with a ship beside [hex] that hold at least one card: whom the pirate can rob there. */
private fun pirateVictims(state: CatanState, thief: Int, hex: Int): List<Int> {
val topo = state.topo
val sea = state.sea!!
return (0 until topo.EDGE_COUNT).filter { hex in topo.EDGE_HEXES[it] }
.map { sea.ships[it] }
.filter { it != NO_PLAYER && it != thief }
.distinct()
.filter { state.resourceCount(it) > 0 }
}
fun pirateMoves(state: CatanState, seat: Int): List<MovePirate> {
val topo = state.topo
val sea = state.sea!!
val moves = mutableListOf<MovePirate>()
for (h in 0 until topo.HEX_COUNT) {
if (topo.land[h] || h == sea.pirate) continue
val victims = pirateVictims(state, seat, h)
if (victims.isEmpty()) {
moves += MovePirate(topo.HEXES[h])
} else {
victims.forEach { moves += MovePirate(topo.HEXES[h], it) }
}
}
return moves
}
// -----------------------------------------------------------------------
// Gold
// -----------------------------------------------------------------------
/** Every way the seat at the front of the gold queue can choose its cards, supply permitting. */
fun goldMoves(state: CatanState): List<PickGold> {
val owed = state.sea!!.gold.firstOrNull() ?: return emptyList()
val supply = Resource.entries.associateWith { CatanRules.bankSupply(state, it) }
val take = minOf(owed.count, supply.values.sum())
val picks = mutableListOf<PickGold>()
fun walk(index: Int, left: Int, acc: MutableList<Resource>) {
if (left == 0) {
picks += PickGold(acc.toList())
return
}
if (index == Resource.entries.size) return
val resource = Resource.entries[index]
for (n in 0..minOf(left, supply.getValue(resource))) {
repeat(n) { acc += resource }
walk(index + 1, left - n, acc)
repeat(n) { acc.removeAt(acc.size - 1) }
}
}
walk(0, take, mutableListOf())
return picks
}
/** Drops queue entries the bank can no longer pay, and leaves the gold phase once nobody is owed. */
fun settleGold(state: CatanState): CatanState {
val sea = state.sea!!
val supply = Resource.entries.sumOf { CatanRules.bankSupply(state, it) }
val queue = if (supply > 0) sea.gold else emptyList()
return if (queue.isEmpty()) {
state.copy(sea = sea.copy(gold = emptyList()), phase = CatanPhase.PLAYING)
} else {
state.copy(sea = sea.copy(gold = queue), phase = CatanPhase.GOLD)
}
}
// -----------------------------------------------------------------------
// Islands and scoring
// -----------------------------------------------------------------------
/** The bonus points [seat] holds for first landings. */
fun bonusPoints(state: CatanState, seat: Int): Int =
(state.sea?.islandBonus?.count { it == seat } ?: 0) * ISLAND_BONUS
/** After a settlement on [vertex]: award the island bonus if it is the first on a new island. */
fun claimIsland(state: CatanState, seat: Int, vertex: Int): CatanState {
val sea = state.sea ?: return state
val island = state.topo.vertexIsland[vertex]
if (island < 0 || island == sea.home || sea.islandBonus[island] != NO_PLAYER) return state
val bonus = sea.islandBonus.toMutableList().also { it[island] = seat }
return state.copy(
sea = sea.copy(islandBonus = bonus),
log = state.log + "Seat $seat is the first to settle a new island: +$ISLAND_BONUS victory points.",
)
}
// -----------------------------------------------------------------------
// Longest route
// -----------------------------------------------------------------------
/**
* The seat's longest route over roads *and* ships. A route is a simple path of the seat's
* pieces that never runs through an opponent's town; it may change between road and ship
* only at a town of the seat's own.
*/
fun longestRouteFor(state: CatanState, seat: Int): Int {
val topo = state.topo
val sea = state.sea!!
val blocked = BooleanArray(topo.VERTEX_COUNT) { v ->
state.buildings[v]?.let { it.owner != seat } == true
}
fun owns(e: Int) = state.roads[e] == seat || sea.ships[e] == seat
fun kind(e: Int) = if (state.roads[e] == seat) 0 else 1
fun walk(v: Int, arrivedBy: Int, visited: BooleanArray): Int {
visited[v] = true
var best = 0
val ownTown = state.buildings[v]?.owner == seat
for (e in topo.VERTEX_EDGES[v]) {
if (!owns(e)) continue
val k = kind(e)
if (arrivedBy != -1 && k != arrivedBy && !ownTown) continue
val w = topo.otherEnd(e, v)
if (visited[w]) continue
best = maxOf(best, if (blocked[w]) 1 else 1 + walk(w, k, visited))
}
visited[v] = false
return best
}
var best = 0
for (start in 0 until topo.VERTEX_COUNT) {
if (topo.VERTEX_EDGES[start].none { owns(it) }) continue
best = maxOf(best, walk(start, -1, BooleanArray(topo.VERTEX_COUNT)))
}
return best
}
}
@@ -0,0 +1,39 @@
package org.prolibertate.games.game.meowdoku
import org.prolibertate.games.game.engine.GameAi
/**
* Plays the board for the one seat.
*
* Like peg solitaire's AI, nothing normally asks it to: the single seat belongs
* to the person holding the phone. It exists because an empty seat has to be
* playable by something, and because "the computer can finish this board" is a
* property worth testing — a generated puzzle that only a human could unpack is
* a broken promise about uniqueness.
*
* It reads the answer from the state. The rules hold it, the seat is alone with
* it, and a solver that deduced its way there would be the same length as the
* hint engine in the original game without making this seat any more honest.
*/
object MeowdokuAi : GameAi<MeowdokuState, MeowdokuMove> {
override fun chooseMove(
state: MeowdokuState,
seat: Int,
legal: List<MeowdokuMove>,
): MeowdokuMove {
require(legal.isNotEmpty()) { "No move available" }
val places = legal.filterIsInstance<PlaceCat>().associateBy { it.square }
// The first cat still missing. A cross on its square is no obstacle:
// crossed squares take attempts like anything else, and a correct one
// replaces the cross.
for (row in 0 until state.side) {
if (state.cats.none { it.row == row }) {
places[Square(row, state.catCols[row])]?.let { return it }
}
}
return legal.first()
}
}
@@ -0,0 +1,371 @@
package org.prolibertate.games.game.meowdoku
import kotlin.math.abs
/**
* Builds the puzzles.
*
* A Meowdoku puzzle is an N by N board, a colouring of it into N regions, and a
* hidden answer of N cats. The three are not chosen separately: the cats come
* first, then the regions grow out from them so every region owns exactly one
* cat, then the finished board is checked to make sure it has only that one
* answer. A puzzle with two answers is a puzzle with no answer, since the second
* one cannot be deduced from what is printed.
*
* The construction is seeded and reproducible: the same seed gives the same
* board, which is what lets a saved game come back unchanged and lets a test
* speak about "the" puzzle for a seed.
*/
object MeowdokuGenerator {
/** Smallest and largest boards worth dealing; anything bigger is a wall. */
val MIN_SIDE = 4
val MAX_SIDE = 10
/**
* The answer, as the column of the cat in each row.
*
* One cat per row and one per column is already built in, since the rows are
* enumerated once each and no column is taken twice.
*/
fun placeCats(side: Int, rng: SeededRng): IntArray {
val cols = IntArray(side) { -1 }
val placed = placeCatsFrom(0, side, cols, rng)
// Every board from four up has a placement, and the search is complete,
// so failing here would mean the generator itself is broken.
check(placed) { "No way to settle $side cats on a $side board" }
return cols
}
private fun placeCatsFrom(row: Int, side: Int, cols: IntArray, rng: SeededRng): Boolean {
if (row == side) return true
for (col in rng.shuffled(cols.indices.toList())) {
if (catFitsHere(row, col, cols)) {
cols[row] = col
if (placeCatsFrom(row + 1, side, cols, rng)) return true
cols[row] = -1
}
}
return false
}
/**
* Whether a cat at [row],[col] is at peace with the cats in the rows above.
*
* No shared column, and no shared corner: a cat grooms the squares touching
* its own on all eight sides, so a neighbour one step away in any direction
* is too close.
*/
fun catFitsHere(row: Int, col: Int, cols: IntArray): Boolean {
for (r in 0 until row) {
val c = cols[r]
if (c == col) return false
if (abs(r - row) <= 1 && abs(c - col) <= 1) return false
}
return true
}
/**
* The colours of the board, as a region id per square.
*
* Each region starts as the cat it contains and grows outwards one square at
* a time. The first [strips] regions are grown as straight bars along their
* cat's row or column, alternating which way they run: a board of nothing but
* blobs reads as texture, and the bars are what give the regions a shape a
* player can reason about. The rest take the shortest border square, which
* keeps them clumped rather than trailing off into the far corner.
*/
fun growRegions(side: Int, catCols: IntArray, rng: SeededRng, strips: Int): Array<IntArray> {
val regions = Array(side) { IntArray(side) { -1 } }
val dirs = listOf(0 to 1, 0 to -1, 1 to 0, -1 to 0)
// Region i owns the cat in row i, which is where its colour starts.
for (r in 0 until side) regions[r][catCols[r]] = r
val sizes = IntArray(side) { 1 }
var unassigned = side * side - side
// The straight bars are laid first, and the rest is grown around them.
// The straight bars are laid first, and the rest is grown around them.
// Bar i runs along its cat's row when i is even, down its column when odd.
val stripRegions = rng.shuffled((0 until side).toList()).take(strips.coerceIn(0, side - 1)).toSet()
for ((n, region) in stripRegions.withIndex()) {
val r0 = region
val c0 = catCols[r0]
val extra = 1 + rng.nextInt(3)
var added = 0
if (n % 2 == 0) {
var left = c0 - 1
var right = c0 + 1
while (added < extra) {
val goLeft = rng.nextBoolean()
when {
goLeft && left >= 0 && regions[r0][left] == -1 ->
regions[r0][left--] = region
right < side && regions[r0][right] == -1 ->
regions[r0][right++] = region
left >= 0 && regions[r0][left] == -1 ->
regions[r0][left--] = region
else -> break
}
sizes[region]++
unassigned--
added++
}
} else {
var up = r0 - 1
var down = r0 + 1
while (added < extra) {
val goUp = rng.nextBoolean()
when {
goUp && up >= 0 && regions[up][c0] == -1 ->
regions[up--][c0] = region
down < side && regions[down][c0] == -1 ->
regions[down++][c0] = region
up >= 0 && regions[up][c0] == -1 ->
regions[up--][c0] = region
else -> break
}
sizes[region]++
unassigned--
added++
}
}
}
// What is left goes to whichever neighbouring region is smallest, so no
// region dies young while another sprawls.
var guard = side * side * 20
while (unassigned > 0 && guard > 0) {
guard--
for (r in 0 until side) {
for (c in 0 until side) {
if (regions[r][c] != -1) continue
var best = -1
for ((dr, dc) in dirs) {
val nr = r + dr
val nc = c + dc
if (nr !in 0 until side || nc !in 0 until side) continue
val neighbour = regions[nr][nc]
if (neighbour == -1 || neighbour in stripRegions) continue
if (best == -1 || sizes[neighbour] < sizes[best]) best = neighbour
}
if (best == -1) {
for ((dr, dc) in dirs) {
val nr = r + dr
val nc = c + dc
if (nr !in 0 until side || nc !in 0 until side) continue
val neighbour = regions[nr][nc]
if (neighbour != -1 && (best == -1 || sizes[neighbour] < sizes[best])) {
best = neighbour
}
}
}
if (best != -1) {
regions[r][c] = best
sizes[best]++
unassigned--
}
}
}
}
for (r in 0 until side) {
for (c in 0 until side) {
if (regions[r][c] == -1) regions[r][c] = 0
}
}
return regions
}
/**
* How many answers this coloured board has, up to [limit].
*
* A depth-first search with the obvious propagation: once a row is given a
* cat, the squares that cat excludes — its column, the rows beside it, and
* everything in its colour — lose their candidates. A row whose candidate
* list runs out fails immediately and the search backtracks, which is what
* makes the count finish quickly on boards of this size.
*
* Nothing about the true cats is handed to the search; it sees the colours
* and the rules and no more.
*/
fun countSolutions(side: Int, regions: Array<IntArray>, limit: Int = 2): Int {
val candidates = Array(side) { r -> (0 until side).toMutableSet() }
val colsTaken = BooleanArray(side)
val coloursTaken = BooleanArray(side)
val count = IntArray(1)
solve(0, side, regions, candidates, colsTaken, coloursTaken, count, limit)
return count[0]
}
private fun solve(
row: Int,
side: Int,
regions: Array<IntArray>,
candidates: Array<MutableSet<Int>>,
colsTaken: BooleanArray,
coloursTaken: BooleanArray,
count: IntArray,
limit: Int,
) {
if (count[0] >= limit) return
if (row == side) {
count[0]++
return
}
for (col in candidates[row].toList()) {
val colour = regions[row][col]
if (colsTaken[col] || coloursTaken[colour]) continue
val undo = exclude(row, col, side, regions, candidates)
if (undo != null) {
colsTaken[col] = true
coloursTaken[colour] = true
solve(row + 1, side, regions, candidates, colsTaken, coloursTaken, count, limit)
colsTaken[col] = false
coloursTaken[colour] = false
undo()
}
if (count[0] >= limit) return
}
}
/**
* Crosses out what the cat at [row],[col] forbids, and returns the function
* that puts it back. Returns null if some row loses its last candidate,
* which means this placement is a dead end.
*/
private fun exclude(
row: Int,
col: Int,
side: Int,
regions: Array<IntArray>,
candidates: Array<MutableSet<Int>>,
): (() -> Unit)? {
val colour = regions[row][col]
val saved = Array(side) { r -> candidates[r].toMutableSet() }
var dead = false
for (r in 0 until side) {
if (r == row) continue
candidates[r].remove(col)
if (abs(r - row) == 1) {
candidates[r].remove(col - 1)
candidates[r].remove(col + 1)
}
candidates[r].removeAll { c -> regions[r][c] == colour }
if (candidates[r].isEmpty()) dead = true
}
if (dead) {
for (r in 0 until side) candidates[r] = saved[r]
return null
}
return {
for (r in 0 until side) candidates[r] = saved[r]
}
}
/**
* A complete puzzle: the colours, the answer, and a proof it is unique.
*
* Generation is rejection rather than construction — build a board, check it,
* throw it away if the check fails — and the attempts walk forward from
* [seed] so a seed that has a puzzle within a few tries gives the same one
* every time.
*/
fun generate(side: Int, seed: Long): MeowdokuPuzzle? {
val base = (seed xor (seed ushr 32)).toInt()
for (attempt in 0 until MAX_ATTEMPTS) {
val rng = SeededRng(base + attempt * 7919)
val catCols = placeCats(side, rng)
val regions = growRegions(side, catCols, SeededRng(base * 31 + attempt * 104729), strips = side / 2)
if (distinctColours(side, regions) != side) continue
if (countSolutions(side, regions, limit = 2) != 1) continue
return MeowdokuPuzzle(side, regions, catCols)
}
return null
}
private const val MAX_ATTEMPTS = 4000
/** How many different colours a board actually shows. */
private fun distinctColours(side: Int, regions: Array<IntArray>): Int =
regions.flatMap { it.toList() }.distinct().size
}
/**
* A board and its answer.
*
* The answer is stored rather than re-derived because the rules need it to
* referee: a tap either lands a cat or loses a heart, and that is judged against
* the solution, not against whether the player could have deduced it.
*/
data class MeowdokuPuzzle(
val side: Int,
/** Region id per square; ids run 0 until side. */
val regions: Array<IntArray>,
/** The column of the cat in each row. */
val catCols: IntArray,
) {
fun regionOf(row: Int, col: Int): Int = regions[row][col]
/** Every colour id, in board order, for a legend or a palette. */
val colours: List<Int> get() = (0 until side).toList()
fun solutionSquares(): List<Pair<Int, Int>> =
catCols.indices.map { it to catCols[it] }
override fun equals(other: Any?): Boolean = other is MeowdokuPuzzle &&
side == other.side &&
regions.contentDeepEquals(other.regions) &&
catCols.contentEquals(other.catCols)
override fun hashCode(): Int = 31 * side + regions.contentDeepHashCode()
}
/**
* A 32-bit linear congruential generator.
*
* Deliberately not [kotlin.random.Random]: that one is seeded per-JVM and can
* change between releases, and a puzzle generator that drifts silently corrupts
* saved games. This one is small, explicit, and the same everywhere.
*/
class SeededRng(seed: Int) {
private var state = if (seed == 0) DEFAULT_SEED else seed
/** The next 31 bits, as a non-negative int. */
fun nextInt(): Int {
state = state * MULTIPLIER + 1
return (state ushr 1) and 0x7FFF_FFFF
}
/** A value in 0 until [bound], taken by rejection so it stays uniform. */
fun nextInt(bound: Int): Int {
require(bound > 0)
while (true) {
val n = nextInt()
// 2^31 is a multiple of no useful number of squares, so a bare
// modulus biases the low values; the slack is thrown and redrawn.
if (n < Int.MAX_VALUE - (Int.MAX_VALUE % bound)) return n % bound
}
}
fun nextBoolean(): Boolean = nextInt(2) == 0
/** The same list every time for the same seed, in a different order. */
fun <T> shuffled(items: List<T>): List<T> {
val out = items.toMutableList()
for (i in out.lastIndex downTo 1) {
val j = nextInt(i + 1)
val tmp = out[i]
out[i] = out[j]
out[j] = tmp
}
return out
}
private companion object {
// The constants of the old VAX C library's `rand`, which are well tested
// and give a period of the full 31 bits.
const val MULTIPLIER = 1103515245
const val DEFAULT_SEED = 0x5DEECE66D.toInt()
}
}
@@ -0,0 +1,119 @@
package org.prolibertate.games.game.meowdoku
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
// Meowdoku hides N cats in an N by N board: exactly one cat in every row, every
// column and every colour, and no two cats sharing even a corner. A tap crosses
// a square over; a second tap on that square asks the board whether a cat
// lives there. A right answer lands the cat, a wrong one costs a heart and
// keeps the cross — so the crossing over is both the player's note and the
// first half of every guess, and the hearts are what stop it being a lottery.
/** One square of the board. */
@Serializable
data class Square(val row: Int, val col: Int)
@Serializable
data class MeowdokuOptions(
/** The board is [side] by [side], and the answer is [side] cats. */
val side: Int = 6,
/**
* How many wrong taps the board forgives. The original game deals three,
* and the hearts on a level are part of its difficulty rather than a
* courtesy.
*/
val hearts: Int = 3,
/** How many cats the board will show on demand. */
val hints: Int = 3,
) {
init {
require(side in MeowdokuGenerator.MIN_SIDE..MeowdokuGenerator.MAX_SIDE) {
"No board of $side squares a side"
}
require(hearts in 1..9) { "A board with no hearts is lost before it is dealt" }
require(hints in 0..9) { "Hints must read on the board, not scroll" }
}
}
/**
* The dealt board plus the marks on it.
*
* [cats] and [marks] are sets rather than a state per square because a square's
* story is exactly those two memberships: nothing is a cat and crossed, and the
* empty squares carry no information worth storing.
*/
@Serializable
data class MeowdokuState(
val options: MeowdokuOptions,
/** The region id of every square, row by row. Ids run 0 until [side]. */
val regions: List<List<Int>>,
/** The column of the cat in each row — the answer, kept so taps can be judged. */
val catCols: List<Int>,
/** Cats the player has found (or a hint has shown). */
val cats: Set<Square>,
/** Squares crossed over: wrong taps and pencil marks alike. */
val marks: Set<Square>,
val heartsLeft: Int,
val hintsLeft: Int,
/** Wrong taps so far, which is how a solved board says how it was solved. */
val wrongCats: Int,
/** The last square touched, and whether touching it cost a heart. */
val lastSquare: Square? = null,
val lastWrong: Boolean = false,
val log: List<String>,
) {
val side: Int get() = options.side
/** Cats found, out of the [side] the board hides. */
val catsFound: Int get() = cats.size
val solved: Boolean get() = catsFound == side
/** Out of hearts, with cats still missing. */
val lost: Boolean get() = heartsLeft == 0 && !solved
fun regionOf(square: Square): Int = regions[square.row][square.col]
fun isCatSquare(square: Square): Boolean = catCols[square.row] == square.col
}
/** What the player may do to a board. */
@Serializable
sealed interface MeowdokuMove
/**
* The second tap of a double tap: the cat attempt.
*
* One move rather than two, because that is what the player is doing: the board
* decides whether the tap found a cat or cost a heart. The square may carry the
* cross its own first tap just drew — that cross is part of the double tap,
* not a decision against it — and a correct attempt replaces it.
*/
@Serializable
@SerialName("cat")
data class PlaceCat(val square: Square) : MeowdokuMove
/**
* Draws or erases a cross on a square with no cat on it.
*
* This is the first tap of a tap pair and every cell of a drag stroke, so it is
* the note-taking half of the scheme: free, unrewarded, and the board cannot
* lose a heart over it. A cross drawn here can still be tapped through with a
* cat attempt — the note is the player's, and only they get to change their
* mind about it.
*/
@Serializable
@SerialName("pencil")
data class TogglePencil(val square: Square) : MeowdokuMove
/**
* Spends a hint to show the cat in the first row that lacks one.
*
* The original game sells hints; here they are simply counted, and the one
* choice that has to be made about them — a hint is placed by the board, not
* guessed at by the player — is the same either way.
*/
@Serializable
@SerialName("hint")
data object TakeHint : MeowdokuMove
@@ -0,0 +1,197 @@
package org.prolibertate.games.game.meowdoku
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.engine.GameRules
import org.prolibertate.games.game.engine.TableConfig
/**
* Meowdoku as a pure state machine. See RULES-meowdoku.md.
*
* The engine is a second single-seat game, alongside peg solitaire, and it
* inherits the same freedom: there is nobody to hide anything from, so
* [viewFor] hands out the whole board including the answer. The answer lives in
* the state rather than being re-derived because judging a tap against a
* deduction the player could have made is a different thing from judging it
* against what the board was dealt — and the deduction engine (crossing out
* rows, columns and colours) belongs to the player, not the referee.
*/
object MeowdokuRules : GameRules<MeowdokuState, MeowdokuMove> {
override val gameId: String = GameCatalog.MEOWDOKU
/** The one seat. */
const val SOLO_SEAT: Int = 0
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
override fun initialState(config: TableConfig): MeowdokuState {
val options = json.decodeFromString<MeowdokuOptions>(config.optionsJson)
require(config.seats.size == 1) {
"Meowdoku seats one, not ${config.seats.size}"
}
val puzzle = deal(options.side, config.seed)
return MeowdokuState(
options = options,
regions = puzzle.regions.map { it.toList() },
catCols = puzzle.catCols.toList(),
cats = emptySet(),
marks = emptySet(),
heartsLeft = options.hearts,
hintsLeft = options.hints,
wrongCats = 0,
log = listOf("A ${options.side} by ${options.side} board, ${options.side} cats to find."),
)
}
/**
* Deals until the board is good.
*
* A seed whose first attempts all fail to produce a unique board moves the
* seed along rather than giving up: the same table asked twice gets the
* same board both times, which is what a saved game and a unit test need.
*/
private fun deal(side: Int, seed: Long): MeowdokuPuzzle {
var trySeed = seed
repeat(DEAL_TRIES) {
MeowdokuGenerator.generate(side, trySeed)?.let { return it }
trySeed = trySeed * 6364136223846793005L + 1442695040888963407L
}
error("No unique ${side} x $side board within $DEAL_TRIES deals of seed $seed")
}
private const val DEAL_TRIES = 8
/**
* Every tap the board would take.
*
* The scheme is the original's: the first tap crosses a square over and
* opens a short window, and a second tap in that window is the cat attempt
* — so an attempt is offered on every square without a cat, crossed or
* not. The cross the attempt replaces is exactly the cross the first tap
* drew. Cats, once placed, are never tapped again: they are not a guess
* you take back.
*
* The list is written row by row, which is also the order the hint uses.
*/
override fun legalMoves(state: MeowdokuState, seat: Int): List<MeowdokuMove> {
if (seat != SOLO_SEAT || isFinished(state)) return emptyList()
val cats = ArrayList<MeowdokuMove>()
val pencils = ArrayList<MeowdokuMove>()
for (row in 0 until state.side) {
for (col in 0 until state.side) {
val square = Square(row, col)
if (square in state.cats) continue
cats += PlaceCat(square)
pencils += TogglePencil(square)
}
}
val hint = if (state.hintsLeft > 0) listOf(TakeHint) else emptyList()
return cats + pencils + hint
}
override fun applyMove(
state: MeowdokuState,
seat: Int,
move: MeowdokuMove,
): MeowdokuState {
require(seat == SOLO_SEAT) { "Meowdoku has one seat" }
require(move in legalMoves(state, seat)) { "$move is not a legal move" }
return when (move) {
is PlaceCat -> tap(state, move.square)
is TogglePencil -> pencil(state, move.square)
TakeHint -> hint(state)
}
}
/**
* The second tap of a double tap: found a cat, or lost a heart.
*
* A correct tap clears the cross the first tap left behind — the cat
* replaces it. A wrong one leaves the cross where it is and spends a
* heart, because a wrong square crossed twice is the same mistake twice,
* and the board remembers it either way.
*/
private fun tap(state: MeowdokuState, square: Square): MeowdokuState {
if (state.isCatSquare(square)) {
val next = state.copy(
cats = state.cats + square,
marks = state.marks - square,
lastSquare = square,
lastWrong = false,
)
val note = if (next.solved) {
"All ${next.side} cats, ${next.wrongCats} wrong taps."
} else {
"Found a cat at row ${square.row + 1}, column ${square.col + 1}."
}
return next.copy(log = next.log + note)
}
val hearts = state.heartsLeft - 1
val next = state.copy(
marks = state.marks + square,
heartsLeft = hearts,
wrongCats = state.wrongCats + 1,
lastSquare = square,
lastWrong = true,
)
return if (hearts == 0) {
next.copy(log = next.log + "Out of hearts, with ${next.side - next.catsFound} cats still hiding.")
} else {
next
}
}
private fun pencil(state: MeowdokuState, square: Square): MeowdokuState {
val marks = if (square in state.marks) state.marks - square else state.marks + square
return state.copy(marks = marks, lastSquare = square, lastWrong = false)
}
/**
* Shows the cat in the first row without one.
*
* A revealed cat lands like a found one — and erases a pencil cross on its
* square, because the point of a hint is that the player stops having to
* work out whether the board contradicts their own notes.
*/
private fun hint(state: MeowdokuState): MeowdokuState {
val row = (0 until state.side).first { r -> state.cats.none { it.row == r } }
val square = Square(row, state.catCols[row])
return state.copy(
cats = state.cats + square,
marks = state.marks - square,
hintsLeft = state.hintsLeft - 1,
lastSquare = square,
lastWrong = false,
log = state.log + "A hint shows the cat in row ${row + 1}.",
)
}
/** A board is finished when it is solved or when the hearts run out. */
override fun isFinished(state: MeowdokuState): Boolean = state.solved || state.lost
override fun currentSeat(state: MeowdokuState): Int? =
if (isFinished(state)) null else SOLO_SEAT
override fun summary(state: MeowdokuState): String = when {
state.solved -> "Solved with ${state.heartsLeft} hearts to spare"
state.lost -> "Lost with ${state.catsFound} of ${state.side} cats found"
else -> "${state.catsFound} of ${state.side} cats, ${state.heartsLeft} hearts left"
}
/**
* Nothing to strip. One person plays, and the answer in the state is there
* for the referee, not as a secret. Should a second seat ever race through
* the same board, this is where [MeowdokuState.catCols] gets hidden.
*/
override fun viewFor(state: MeowdokuState, seat: Int): MeowdokuState = state
override fun encodeState(state: MeowdokuState): String = json.encodeToString(state)
override fun decodeState(json: String): MeowdokuState = this.json.decodeFromString(json)
override fun encodeMove(move: MeowdokuMove): String = json.encodeToString(move)
override fun decodeMove(json: String): MeowdokuMove = this.json.decodeFromString(json)
}
@@ -0,0 +1,58 @@
package org.prolibertate.games.game.yahtzee
import kotlinx.serialization.Serializable
/** What one finished game says about the local player's card. */
@Serializable
data class YahtzeeRecord(
val total: Int,
val gotBonus: Boolean,
/** Yahtzees scored, counting the extra ones that earned the hundred. */
val yahtzees: Int,
/** Boxes written in as nothing. */
val zeros: Int,
val players: Int,
val won: Boolean,
)
/** Builds the record of [seat]'s card in a finished game. Ties for first count as a win. */
fun recordOf(state: YahtzeeState, seat: Int): YahtzeeRecord {
val card = state.cards[seat]
val mine = state.totalFor(seat)
val best = state.cards.indices.maxOf { state.totalFor(it) }
return YahtzeeRecord(
total = mine,
gotBonus = card.upperBonus > 0,
yahtzees = (if (card[YahtzeeCategory.YAHTZEE] == YAHTZEE_SCORE) 1 else 0) +
state.yahtzeeBonuses[seat] / YAHTZEE_BONUS,
zeros = card.boxes.count { it == 0 },
players = state.cards.size,
won = mine >= best,
)
}
/** A game set against the ones before it. [rank] is 1 for a new best. */
data class YahtzeeComparison(
val current: YahtzeeRecord,
val previousGames: Int,
val bestBefore: Int?,
val averageBefore: Double?,
val rank: Int,
val gamesRanked: Int,
val bonusRateBefore: Double?,
val yahtzeesBefore: Int,
val winRateBefore: Double?,
)
fun compareToHistory(previous: List<YahtzeeRecord>, current: YahtzeeRecord): YahtzeeComparison =
YahtzeeComparison(
current = current,
previousGames = previous.size,
bestBefore = previous.maxOfOrNull { it.total },
averageBefore = previous.takeIf { it.isNotEmpty() }?.map { it.total }?.average(),
rank = 1 + previous.count { it.total > current.total },
gamesRanked = previous.size + 1,
bonusRateBefore = previous.takeIf { it.isNotEmpty() }?.let { p -> p.count { it.gotBonus }.toDouble() / p.size },
yahtzeesBefore = previous.sumOf { it.yahtzees },
winRateBefore = previous.takeIf { it.isNotEmpty() }?.let { p -> p.count { it.won }.toDouble() / p.size },
)
@@ -0,0 +1,17 @@
package org.prolibertate.games.score
/** The short list of names offered when a column is being named. */
object RecentNames {
const val LIMIT = 10
/**
* [name] moved to the front of [names], trimmed, with any earlier copy
* (compared ignoring case) dropped and the list cut to [LIMIT]. A blank name
* leaves the list as it was.
*/
fun with(names: List<String>, name: String): List<String> {
val typed = name.trim()
if (typed.isEmpty()) return names
return (listOf(typed) + names.filterNot { it.equals(typed, ignoreCase = true) }).take(LIMIT)
}
}
@@ -29,6 +29,7 @@ class ScorekeeperRepository(private val context: Context) {
private object Keys {
val SHEET = stringPreferencesKey("sheet")
val RECENT_NAMES = stringPreferencesKey("recent_names")
}
// Unknown keys are ignored so a sheet written by a newer build can still be
@@ -45,6 +46,24 @@ class ScorekeeperRepository(private val context: Context) {
?: ScoreSheet()
}
/** The names last used on a sheet, most recent first, for the rename dialog. */
val recentNames: Flow<List<String>> = context.scorekeeperDataStore.data.map { prefs ->
prefs[Keys.RECENT_NAMES]
?.let { stored ->
runCatching { json.decodeFromString<List<String>>(stored) }.getOrNull()
}
?: emptyList()
}
suspend fun rememberName(name: String) {
context.scorekeeperDataStore.edit { prefs ->
val current = prefs[Keys.RECENT_NAMES]
?.let { runCatching { json.decodeFromString<List<String>>(it) }.getOrNull() }
.orEmpty()
prefs[Keys.RECENT_NAMES] = json.encodeToString(RecentNames.with(current, name))
}
}
suspend fun save(sheet: ScoreSheet) {
context.scorekeeperDataStore.edit { it[Keys.SHEET] = json.encodeToString(sheet) }
}
@@ -31,8 +31,18 @@ sealed interface SheetOp {
/** Sets a fresh sheet up for [count] players, scored by round or by tally. */
@Serializable
@SerialName("start")
data class StartSheet(val count: Int, val mode: ScoringMode = ScoringMode.ROUNDS) : SheetOp {
override fun applyTo(sheet: ScoreSheet): ScoreSheet = ScoreSheet.of(count, mode)
data class StartSheet(
val count: Int,
val mode: ScoringMode = ScoringMode.ROUNDS,
/** Names for the first seats, in order; a seat past the end stays unnamed. */
val names: List<String> = emptyList(),
) : SheetOp {
override fun applyTo(sheet: ScoreSheet): ScoreSheet {
val seated = ScoreSheet.of(count, mode)
return seated.players.foldIndexed(seated) { index, acc, player ->
names.getOrNull(index)?.let { acc.renamed(player.id, it) } ?: acc
}
}
}
/** Rubs everything out. */
@@ -24,6 +24,8 @@ data class Settings(
val soundEnabled: Boolean = true,
val animationSpeed: Float = 1.0f,
val checkForUpdatesOnLaunch: Boolean = true,
/** Multiplies the system font size across the whole app. */
val textScale: Float = 1.0f,
/**
* Exactly what the user has typed, which may be blank while they are
* mid-edit. Nothing substitutes a default into this value: doing so meant
@@ -46,6 +48,8 @@ data class Settings(
companion object {
const val MIN_SPEED = 0.5f
const val MAX_SPEED = 2.0f
const val MIN_TEXT_SCALE = 0.8f
const val MAX_TEXT_SCALE = 1.6f
const val DEFAULT_PLAYER_NAME = "Player"
}
}
@@ -56,6 +60,7 @@ class SettingsRepository(private val context: Context) {
val SOUND = booleanPreferencesKey("sound_enabled")
val SPEED = floatPreferencesKey("animation_speed")
val UPDATE_ON_LAUNCH = booleanPreferencesKey("check_updates_on_launch")
val TEXT_SCALE = floatPreferencesKey("text_scale")
val NAME = stringPreferencesKey("player_name")
// There is deliberately no update-channel key. The channel is a
// property of the installed APK, not a preference — an install that
@@ -69,6 +74,8 @@ class SettingsRepository(private val context: Context) {
soundEnabled = prefs[Keys.SOUND] ?: true,
animationSpeed = prefs[Keys.SPEED] ?: 1.0f,
checkForUpdatesOnLaunch = prefs[Keys.UPDATE_ON_LAUNCH] ?: true,
textScale = (prefs[Keys.TEXT_SCALE] ?: 1.0f)
.coerceIn(Settings.MIN_TEXT_SCALE, Settings.MAX_TEXT_SCALE),
playerName = prefs[Keys.NAME] ?: "",
)
}
@@ -83,6 +90,12 @@ class SettingsRepository(private val context: Context) {
}
}
suspend fun setTextScale(scale: Float) {
context.dataStore.edit {
it[Keys.TEXT_SCALE] = scale.coerceIn(Settings.MIN_TEXT_SCALE, Settings.MAX_TEXT_SCALE)
}
}
suspend fun setCheckForUpdatesOnLaunch(enabled: Boolean) {
context.dataStore.edit { it[Keys.UPDATE_ON_LAUNCH] = enabled }
}
@@ -0,0 +1,42 @@
package org.prolibertate.games.settings
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.prolibertate.games.game.yahtzee.YahtzeeComparison
import org.prolibertate.games.game.yahtzee.YahtzeeRecord
import org.prolibertate.games.game.yahtzee.compareToHistory
private val Context.yahtzeeStatsStore: DataStore<Preferences> by preferencesDataStore(
name = "yahtzee_stats",
)
/** The local player's finished Yahtzee games, newest last, kept to the last [KEEP]. */
class YahtzeeStatsRepository(private val context: Context) {
private val key = stringPreferencesKey("games")
private val json = Json { ignoreUnknownKeys = true }
private fun decode(stored: String?): List<YahtzeeRecord> =
stored?.let { runCatching { json.decodeFromString<List<YahtzeeRecord>>(it) }.getOrNull() }
.orEmpty()
/** Compares [record] with every earlier game, then adds it to the history. */
suspend fun finish(record: YahtzeeRecord): YahtzeeComparison {
val previous = decode(context.yahtzeeStatsStore.data.first()[key])
context.yahtzeeStatsStore.edit {
it[key] = json.encodeToString((previous + record).takeLast(KEEP))
}
return compareToHistory(previous, record)
}
private companion object {
const val KEEP = 500
}
}
@@ -20,6 +20,8 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -49,6 +51,7 @@ import org.prolibertate.games.net.ScorekeeperSession
import org.prolibertate.games.score.ScorekeeperRepository
import org.prolibertate.games.settings.Settings
import org.prolibertate.games.settings.SettingsRepository
import org.prolibertate.games.settings.YahtzeeStatsRepository
import org.prolibertate.games.ui.game.LocalCardSpeed
import org.prolibertate.games.update.UpdateChannel
import org.prolibertate.games.update.Updater
@@ -68,6 +71,7 @@ class AppEnv(
val peerId: String,
/** Games in progress, kept between sessions. */
val saves: SavedGameStore,
val yahtzeeStats: YahtzeeStatsRepository,
)
/**
@@ -139,7 +143,14 @@ fun AppRoot(env: AppEnv) {
// Card motion reads its speed from here rather than from a parameter.
// Every card screen animates, most of them several layers deep, and the
// alternative was passing one unchanging number down through all of it.
CompositionLocalProvider(LocalCardSpeed provides settings.animationSpeed) {
val density = LocalDensity.current
val scaledDensity = remember(density, settings.textScale) {
Density(density.density, density.fontScale * settings.textScale)
}
CompositionLocalProvider(
LocalCardSpeed provides settings.animationSpeed,
LocalDensity provides scaledDensity,
) {
AppContent(
env = env,
settings = settings,
@@ -50,6 +50,8 @@ import org.prolibertate.games.game.freecell.FreeCellOptions
import org.prolibertate.games.game.pyramid.PyramidOptions
import org.prolibertate.games.game.spider.SpiderOptions
import org.prolibertate.games.game.yahtzee.YahtzeeOptions
import org.prolibertate.games.game.meowdoku.MeowdokuGenerator
import org.prolibertate.games.game.meowdoku.MeowdokuOptions
import org.prolibertate.games.game.pegsolitaire.PegBoard
import org.prolibertate.games.game.pegsolitaire.PegGoal
import org.prolibertate.games.game.pegsolitaire.PegSolitaireOptions
@@ -109,6 +111,7 @@ fun GameSetupScreen(
var pirates by remember { mutableStateOf(PiratesOptions()) }
var hearts by remember { mutableStateOf(HeartsOptions()) }
var pegs by remember { mutableStateOf(PegSolitaireOptions()) }
var meowdoku by remember { mutableStateOf(MeowdokuOptions()) }
var yahtzee by remember { mutableStateOf(YahtzeeOptions()) }
var klondike by remember { mutableStateOf(KlondikeOptions()) }
var freecell by remember { mutableStateOf(FreeCellOptions()) }
@@ -140,6 +143,7 @@ fun GameSetupScreen(
GameCatalog.PIRATES -> setupJson.encodeToString(pirates)
GameCatalog.HEARTS -> setupJson.encodeToString(hearts)
GameCatalog.PEG_SOLITAIRE -> setupJson.encodeToString(pegs)
GameCatalog.MEOWDOKU -> setupJson.encodeToString(meowdoku)
GameCatalog.YAHTZEE -> setupJson.encodeToString(yahtzee)
GameCatalog.KLONDIKE -> setupJson.encodeToString(klondike)
GameCatalog.FREECELL -> setupJson.encodeToString(freecell)
@@ -205,6 +209,8 @@ fun GameSetupScreen(
GameCatalog.PEG_SOLITAIRE -> PegSolitaireOptionsEditor(pegs) { pegs = it }
GameCatalog.MEOWDOKU -> MeowdokuOptionsEditor(meowdoku) { meowdoku = it }
GameCatalog.YAHTZEE -> YahtzeeOptionsEditor(yahtzee) { yahtzee = it }
GameCatalog.KLONDIKE -> KlondikeOptionsEditor(klondike) { klondike = it }
@@ -359,12 +365,15 @@ internal fun catanPointsChoices(ruleSet: CatanRuleSet): List<Int> = when (ruleSe
CatanRuleSet.BASE -> listOf(5, 8, 10, 12)
// Cities & Knights is played to thirteen, which is also why it runs longer.
CatanRuleSet.CITIES_AND_KNIGHTS -> listOf(8, 10, 13, 15)
// Seafarers adds two points for each new island, so the target is higher.
CatanRuleSet.SEAFARERS -> listOf(8, 10, 12, 14)
}
/** The published target for a rule set. */
internal fun catanDefaultPoints(ruleSet: CatanRuleSet): Int = when (ruleSet) {
CatanRuleSet.BASE -> 10
CatanRuleSet.CITIES_AND_KNIGHTS -> 13
CatanRuleSet.SEAFARERS -> 12
}
/**
@@ -381,6 +390,7 @@ internal fun catanSwitchRules(options: CatanOptions, ruleSet: CatanRuleSet): Cat
private fun catanRuleSetName(ruleSet: CatanRuleSet): String = when (ruleSet) {
CatanRuleSet.BASE -> "Base game"
CatanRuleSet.CITIES_AND_KNIGHTS -> "Cities & Knights"
CatanRuleSet.SEAFARERS -> "Seafarers"
}
private fun catanRuleSetBlurb(ruleSet: CatanRuleSet): String = when (ruleSet) {
@@ -389,6 +399,9 @@ private fun catanRuleSetBlurb(ruleSet: CatanRuleSet): String = when (ruleSet) {
CatanRuleSet.CITIES_AND_KNIGHTS ->
"Cities make commodities, knights hold off the barbarians, and improvements earn progress " +
"cards and metropolises. No development cards; the robber sleeps until the first attack."
CatanRuleSet.SEAFARERS ->
"Build ships to reach new islands. Gold fields pay any resource, a pirate guards the sea, " +
"and the first settlement on each new island scores two extra points."
}
@Composable
@@ -1134,6 +1147,36 @@ private fun PegSolitaireOptionsEditor(
}
}
@Composable
private fun MeowdokuOptionsEditor(
options: MeowdokuOptions,
onChange: (MeowdokuOptions) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ChipRow(
label = "Board",
values = (MeowdokuGenerator.MIN_SIDE..MeowdokuGenerator.MAX_SIDE).toList(),
selected = options.side,
display = { "$it × $it" },
onSelect = { onChange(options.copy(side = it)) },
)
ChipRow(
label = "Hearts",
values = (1..5).toList(),
selected = options.hearts,
display = { if (it == 1) "One" else "$it" },
onSelect = { onChange(options.copy(hearts = it)) },
)
ChipRow(
label = "Hints",
values = (0..3).toList(),
selected = options.hints,
display = { if (it == 0) "None" else "$it" },
onSelect = { onChange(options.copy(hints = it)) },
)
}
}
@Composable
private fun YahtzeeOptionsEditor(options: YahtzeeOptions, onChange: (YahtzeeOptions) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -7,6 +7,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -87,6 +88,10 @@ import org.prolibertate.games.game.mastermind.MastermindAi
import org.prolibertate.games.game.mastermind.MastermindMove
import org.prolibertate.games.game.mastermind.MastermindRules
import org.prolibertate.games.game.mastermind.MastermindState
import org.prolibertate.games.game.meowdoku.MeowdokuAi
import org.prolibertate.games.game.meowdoku.MeowdokuMove
import org.prolibertate.games.game.meowdoku.MeowdokuRules
import org.prolibertate.games.game.meowdoku.MeowdokuState
import org.prolibertate.games.game.morris.MorrisAi
import org.prolibertate.games.game.morris.MorrisMove
import org.prolibertate.games.game.morris.MorrisRules
@@ -131,6 +136,7 @@ import org.prolibertate.games.ui.game.EuchreScreen
import org.prolibertate.games.ui.game.GolfScreen
import org.prolibertate.games.ui.game.KaiserScreen
import org.prolibertate.games.ui.game.MastermindScreen
import org.prolibertate.games.ui.game.MeowdokuScreen
import org.prolibertate.games.ui.game.MorrisScreen
import org.prolibertate.games.ui.game.PegSolitaireScreen
import org.prolibertate.games.ui.game.SpiderScreen
@@ -544,6 +550,34 @@ fun PlayScreen(
)
}
GameCatalog.MEOWDOKU -> {
// Like peg solitaire the controller is rebuilt rather than
// reset — a finished board has nothing left to do — but unlike
// it a new board is dealt every time: the same cats in the same
// colours twice would only repeat the last puzzle's answer.
// Moving the seed moves the deal, and each deal is still
// reproducible from its own seed.
var attempt by remember(route) { mutableLongStateOf(0L) }
val controller = remember(route, attempt) {
MatchController<MeowdokuState, MeowdokuMove>(
rules = MeowdokuRules,
// Never consulted while a person holds the only seat.
ai = MeowdokuAi,
config = route.config.copy(seed = route.config.seed + attempt),
scope = scope,
role = role,
localSeats = setOf(route.localSeat),
primarySeat = route.localSeat,
)
}
StartMatch(env, controller, route)
MeowdokuScreen(
controller = controller,
onNewPuzzle = { attempt++ },
onExit = onExit,
)
}
GameCatalog.YAHTZEE -> {
val controller = remember(route) {
MatchController<YahtzeeState, YahtzeeMove>(
@@ -560,7 +594,12 @@ fun PlayScreen(
)
}
StartMatch(env, controller, route)
YahtzeeScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
YahtzeeScreen(
controller = controller,
localSeat = route.localSeat,
stats = env.yahtzeeStats,
onExit = onExit,
)
}
GameCatalog.KLONDIKE -> {
@@ -36,6 +36,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -114,6 +115,8 @@ fun ScorekeeperScreen(
local = repository.sheet.first()
loaded = true
}
val recentNames by repository.recentNames.collectAsState(initial = emptyList())
val nameUsed: (String) -> Unit = { name -> scope.launch { repository.rememberName(name) } }
val shared by session.state.collectAsState()
val role = shared.role
@@ -220,7 +223,7 @@ fun ScorekeeperScreen(
role == Role.GUEST -> Column(modifier = modifier) {
ConnectionBanner(shared = shared, onSync = { session.sync() })
Scoring(modifier = Modifier.weight(1f), sheet = sheet, onOp = change)
Scoring(Modifier.weight(1f), sheet, change, recentNames, nameUsed)
}
joining && role == Role.NONE ->
@@ -228,11 +231,16 @@ fun ScorekeeperScreen(
!sheet.started -> SetupPane(
modifier = modifier,
onStart = { count, mode -> change(StartSheet(count, mode)) },
recentNames = recentNames,
onStart = { count, mode, names ->
change(StartSheet(count, mode, names))
// Last first, so the list reads in seating order.
names.reversed().forEach(nameUsed)
},
onJoin = { joining = true },
)
else -> Scoring(modifier = modifier, sheet = sheet, onOp = change)
else -> Scoring(modifier, sheet, change, recentNames, nameUsed)
}
}
@@ -289,9 +297,46 @@ fun ScorekeeperScreen(
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun SetupPane(modifier: Modifier, onStart: (Int, ScoringMode) -> Unit, onJoin: () -> Unit) {
private fun SetupPane(
modifier: Modifier,
recentNames: List<String>,
onStart: (Int, ScoringMode, List<String>) -> Unit,
onJoin: () -> Unit,
) {
var count by remember { mutableStateOf(ScoreSheet.DEFAULT_PLAYERS) }
var mode by remember { mutableStateOf(ScoringMode.ROUNDS) }
// -1 while the options are showing; otherwise the seat being named.
var naming by remember { mutableStateOf(-1) }
val names = remember { mutableStateListOf<String>() }
if (naming >= 0) {
NamePane(
modifier = modifier,
seat = naming,
count = count,
name = names.getOrElse(naming) { "" },
// Offered once only: two players on one sheet with the same name
// are indistinguishable.
suggestions = recentNames.filter { recent ->
names.withIndex().none { (seat, taken) ->
seat != naming && taken.trim().equals(recent.trim(), ignoreCase = true)
}
},
onName = { typed ->
while (names.size <= naming) names.add("")
names[naming] = typed
},
onBack = { naming -= 1 },
onNext = {
if (naming + 1 < count) {
naming += 1
} else {
onStart(count, mode, List(count) { names.getOrElse(it) { "" }.trim() })
}
},
)
return
}
Column(
modifier = modifier.verticalScroll(rememberScrollState()),
@@ -339,33 +384,98 @@ private fun SetupPane(modifier: Modifier, onStart: (Int, ScoringMode) -> Unit, o
}
}
Text(
text = "Names come next: tap a column head to name it. Players can be " +
"added, removed or dragged into a different order at any point " +
"without losing anybody's score.",
text = "You will be asked for each name next, or skip them and tap a " +
"column head later. Players can be added, removed or dragged into " +
"a different order at any point without losing anybody's score.",
style = MaterialTheme.typography.bodySmall,
)
PrimaryAction(text = "Start scoring") { onStart(count, mode) }
PrimaryAction(text = "Next: name the players") { naming = 0 }
OutlinedButton(onClick = onJoin, modifier = Modifier.fillMaxWidth()) {
Text("Join a shared sheet nearby")
}
}
}
/** One seat's name, asked for on its own; a blank leaves the seat as "Player N". */
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun NamePane(
modifier: Modifier,
seat: Int,
count: Int,
name: String,
suggestions: List<String>,
onName: (String) -> Unit,
onBack: () -> Unit,
onNext: () -> Unit,
) {
val focus = remember { FocusRequester() }
LaunchedEffect(seat) { focus.requestFocus() }
val last = seat + 1 >= count
Column(
modifier = modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("Player ${seat + 1} of $count", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = name,
onValueChange = onName,
placeholder = { Text("Player ${seat + 1}") },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = if (last) ImeAction.Done else ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { onNext() }, onDone = { onNext() }),
modifier = Modifier.fillMaxWidth().focusRequester(focus),
)
if (suggestions.isNotEmpty()) {
Text("Recent names", style = MaterialTheme.typography.labelMedium)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
suggestions.forEach { recent ->
FilterChip(
selected = recent.equals(name.trim(), ignoreCase = true),
onClick = { onName(recent) },
label = { Text(recent) },
)
}
}
}
PrimaryAction(text = if (last) "Start scoring" else "Next") { onNext() }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = onBack) { Text("Back") }
TextButton(onClick = { onName(""); onNext() }) { Text(if (last) "Skip and start" else "Skip") }
}
}
}
// ---------------------------------------------------------------------------
// The sheet: a column per player, a row per round, totals ruled off at the foot
// ---------------------------------------------------------------------------
@Composable
private fun Scoring(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) {
private fun Scoring(
modifier: Modifier,
sheet: ScoreSheet,
onOp: (SheetOp) -> Unit,
recentNames: List<String>,
onNameUsed: (String) -> Unit,
) {
if (sheet.mode == ScoringMode.TALLY) {
TallySheet(modifier = modifier, sheet = sheet, onOp = onOp)
TallySheet(modifier, sheet, onOp, recentNames, onNameUsed)
} else {
Sheet(modifier = modifier, sheet = sheet, onOp = onOp)
Sheet(modifier, sheet, onOp, recentNames, onNameUsed)
}
}
@Composable
private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) {
private fun Sheet(
modifier: Modifier,
sheet: ScoreSheet,
onOp: (SheetOp) -> Unit,
recentNames: List<String>,
onNameUsed: (String) -> Unit,
) {
// One scroll state shared by the head, the rows and the totals, so all three
// move together: a column and the name over it must never come apart. Every
// row is built the same way and is therefore exactly the same width, which
@@ -384,6 +494,9 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
var lastTyped by remember { mutableStateOf(-1) }
// The cell to put the cursor in when a round is opened by tapping it.
var focusPlayer by remember { mutableStateOf(-1) }
// Bumped when a round is finished, to put the cursor back in the first cell
// of the row that is now being filled in.
var nextRoundFocus by remember { mutableStateOf(0) }
val editing = if (correcting >= 0) correction else pending
@@ -513,7 +626,7 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
)
},
trailing = { EmptyCell(width = ADD_COLUMN, height = ROW_HEIGHT) },
) { _, player ->
) { index, player ->
if (correcting >= 0) {
// A round is being corrected above; what is typed here
// is kept, but only one row is edited at a time.
@@ -521,6 +634,8 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
} else {
PointsField(
text = pending[player.id].orEmpty(),
autoFocus = index == 0 && nextRoundFocus > 0,
focusRequest = nextRoundFocus,
onText = {
pending[player.id] = it
lastTyped = player.id
@@ -563,6 +678,7 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
onOp(AddRound(amountsAsPoints(pending)))
pending.clear()
}
nextRoundFocus += 1
lastTyped = -1
focusPlayer = -1
},
@@ -587,6 +703,8 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
RenameDialog(
sheet = sheet,
player = renamed,
recentNames = recentNames,
onNameUsed = onNameUsed,
onName = { onOp(RenamePlayer(renamed.id, it)) },
onRemove = {
onOp(RemovePlayer(renamed.id))
@@ -607,7 +725,13 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
* sheet, so tapping one in the list takes it back off.
*/
@Composable
private fun TallySheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) {
private fun TallySheet(
modifier: Modifier,
sheet: ScoreSheet,
onOp: (SheetOp) -> Unit,
recentNames: List<String>,
onNameUsed: (String) -> Unit,
) {
var renaming by remember { mutableStateOf(-1) }
Column(modifier = modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -676,6 +800,8 @@ private fun TallySheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) ->
RenameDialog(
sheet = sheet,
player = renamed,
recentNames = recentNames,
onNameUsed = onNameUsed,
onName = { onOp(RenamePlayer(renamed.id, it)) },
onRemove = {
onOp(RemovePlayer(renamed.id))
@@ -961,9 +1087,14 @@ private fun PointsCell(text: String, muted: Boolean = false, onClick: (() -> Uni
/** A cell being typed into. Blank means nothing entered; a typed 0 is kept and shown. */
@Composable
private fun PointsField(text: String, onText: (String) -> Unit, autoFocus: Boolean = false) {
private fun PointsField(
text: String,
onText: (String) -> Unit,
autoFocus: Boolean = false,
focusRequest: Int = 0,
) {
val focus = remember { FocusRequester() }
LaunchedEffect(autoFocus) { if (autoFocus) focus.requestFocus() }
LaunchedEffect(autoFocus, focusRequest) { if (autoFocus) focus.requestFocus() }
BasicTextField(
value = text,
onValueChange = { onText(sanitiseAmount(it)) },
@@ -1064,16 +1195,27 @@ private fun ActionBar(
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun RenameDialog(
sheet: ScoreSheet,
player: ScorePlayer,
recentNames: List<String>,
onNameUsed: (String) -> Unit,
onName: (String) -> Unit,
onRemove: () -> Unit,
onDismiss: () -> Unit,
) {
// Closing the dialog is what files the name away, so a name is remembered
// once rather than once per keystroke.
val finish = {
onNameUsed(player.name)
onDismiss()
}
val taken = sheet.players.filter { it.id != player.id }.map { it.name.trim().lowercase() }
val suggestions = recentNames.filter { it.trim().lowercase() !in taken }
AlertDialog(
onDismissRequest = onDismiss,
onDismissRequest = finish,
title = { Text("Name this column") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -1084,6 +1226,21 @@ private fun RenameDialog(
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
if (suggestions.isNotEmpty()) {
Text("Recent names", style = MaterialTheme.typography.labelMedium)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
suggestions.forEach { name ->
FilterChip(
selected = name.equals(player.name.trim(), ignoreCase = true),
onClick = { onName(name) },
label = { Text(name) },
)
}
}
}
TextButton(
onClick = onRemove,
// The last two columns stay: a sheet needs somebody on it.
@@ -1091,7 +1248,7 @@ private fun RenameDialog(
) { Text("Remove this player and their score") }
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } },
confirmButton = { TextButton(onClick = finish) { Text("Done") } },
)
}
@@ -24,6 +24,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
import org.prolibertate.games.settings.Settings
import org.prolibertate.games.update.Release
import org.prolibertate.games.update.UpdateChannel
@@ -83,6 +84,26 @@ fun SettingsScreen(
Divider()
Column {
Text("Text size", fontWeight = FontWeight.Bold)
Text(
text = "${(settings.textScale * 100).roundToInt()}% — " +
"scales text throughout the app, on top of your phone's setting",
style = MaterialTheme.typography.bodySmall,
)
Slider(
value = settings.textScale,
onValueChange = { scale ->
scope.launch { env.settingsRepository.setTextScale(scale) }
},
valueRange = Settings.MIN_TEXT_SCALE..Settings.MAX_TEXT_SCALE,
// 10% increments across 80%-160%.
steps = 7,
)
}
Divider()
Column {
Text("Your name", fontWeight = FontWeight.Bold)
Text(
@@ -0,0 +1,379 @@
package org.prolibertate.games.ui.game
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.TextUnit
import org.prolibertate.games.game.meowdoku.MeowdokuMove
import org.prolibertate.games.game.meowdoku.MeowdokuState
import org.prolibertate.games.game.meowdoku.PlaceCat
import org.prolibertate.games.game.meowdoku.Square
import org.prolibertate.games.game.meowdoku.TakeHint
import org.prolibertate.games.game.meowdoku.TogglePencil
import org.prolibertate.games.net.MatchController
import org.prolibertate.games.ui.LocalEndGameHandle
import org.prolibertate.games.ui.ScreenScaffold
import org.prolibertate.games.ui.theme.CardRed
import kotlin.math.abs
import kotlin.math.roundToInt
private const val TITLE = "Meowdoku"
/**
* How long the second tap of a double tap may take to arrive.
*
* 350 ms, the number the original game ships: long enough that a deliberate
* one-two still reads as one question, short enough that nobody pairs two
* taps they meant for two separate squares.
*/
private const val DOUBLE_TAP_WINDOW_MILLIS = 350L
/**
* The region colours, one per cat the board can hold.
*
* The board never needs two regions of one colour — a side-ten board has ten
* regions and the palette has ten — so a region's colour is just its id, and
* nothing has to be computed to keep neighbours apart. The palette reads as
* watercolour on purpose: cats are dark ink on top of it, and a square the
* player has crossed over is too.
*/
private val RegionColours = listOf(
Color(0xFFF2C6C2), // rose
Color(0xFFF7E0A6), // butter
Color(0xFFCDE8C5), // mint
Color(0xFFC5E3F0), // sky
Color(0xFFD8CDEB), // lilac
Color(0xFFEBC9A0), // apricot
Color(0xFFBFE3D0), // spruce
Color(0xFFF0D7E3), // blossom
Color(0xFFCFCFE8), // periwinkle
Color(0xFFE3E7C1), // lime
)
/**
* The puzzle board, tapped the way the original's is.
*
* One tap crosses a square over; a second tap on the same square within
* [DOUBLE_TAP_WINDOW_MILLIS] asks the board whether a cat lives there. Drag
* strokes paint or erase crosses along the line the finger takes, which is
* how a row gets crossed out in one gesture rather than eight taps. Cats are
* permanent: tapping one does nothing, and a stroke skips over it.
*/
@Composable
fun MeowdokuScreen(
controller: MatchController<MeowdokuState, MeowdokuMove>,
onNewPuzzle: () -> Unit,
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val legal by controller.legalMoves.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val current = state
if (current == null) {
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") }
abandoned?.let { LeftBoardDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) EndBoardDialog(onExit) { confirmingEnd = false }
}
return
}
val places = legal.filterIsInstance<PlaceCat>().map { it.square }.toSet()
val pencils = legal.filterIsInstance<TogglePencil>().map { it.square }.toSet()
val finished by controller.finished.collectAsState()
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
Column(
modifier = modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = when {
current.solved -> "Found every cat" +
if (current.wrongCats == 0) ", not one tap wasted." else ", with ${current.wrongCats} wrong taps."
current.lost -> "Out of hearts, with ${current.side - current.catsFound} cats still hiding."
else -> "${current.catsFound} of ${current.side} cats found. " +
"Tap to cross a square, tap it again for a cat."
},
fontWeight = FontWeight.Bold,
)
Text(
text = "Hearts " + "♥".repeat(current.heartsLeft) +
" · Hints " + current.hintsLeft,
style = MaterialTheme.typography.bodySmall,
)
Board(
state = current,
// Moves are refused rather than hidden: an attempt or a note
// the board would reject (a tap on a cat, mostly) has to die
// here, in the gesture, where the player can be doing nothing
// else the same instant.
onNote = { square -> if (square in pencils) controller.submit(TogglePencil(square)) },
onAttempt = { square -> if (square in places) controller.submit(PlaceCat(square)) },
)
if (TakeHint in legal) {
FilterChip(
selected = false,
onClick = { controller.submit(TakeHint) },
label = { Text("Hint") },
)
}
if (finished) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onNewPuzzle) { Text("New puzzle") }
TextButton(onClick = onExit) { Text("Leave") }
}
}
current.log.takeLast(2).forEach {
Text(it, style = MaterialTheme.typography.bodySmall)
}
}
abandoned?.let { LeftBoardDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) EndBoardDialog(onExit) { confirmingEnd = false }
}
}
/**
* The board as one gesture surface.
*
* Cells are drawn by weight inside a square frame, but input is not per-cell:
* a stroke is one continuous gesture, so the pointer works in board
* coordinates and maps positions to squares itself. The cell list renders
* underneath, and the live state is read through [rememberUpdatedState]
* because a gesture outlives the recompositions it lands in.
*/
@Composable
private fun Board(
state: MeowdokuState,
onNote: (Square) -> Unit,
onAttempt: (Square) -> Unit,
) {
val side = state.side
val current by rememberUpdatedState(state)
val note by rememberUpdatedState(onNote)
val attempt by rememberUpdatedState(onAttempt)
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
// One cell's edge, so a cat is always about half a square whatever
// board was dealt.
val cell = maxWidth / side
Box(
modifier = Modifier
.aspectRatio(1f)
.pointerInput(side) {
val cellPx = size.width.toFloat() / side
var lastTap: Square? = null
var lastTapAt = 0L
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
val board = current
val start = squareAt(down.position, cellPx, side)
?: return@awaitEachGesture
if (start in board.cats) return@awaitEachGesture
val now = System.currentTimeMillis()
if (start == lastTap && now - lastTapAt < DOUBLE_TAP_WINDOW_MILLIS) {
// The second tap of a pair: the cat attempt, and
// no note — the cross it lands on was drawn by
// this pair's first tap, and the board replaces it.
lastTap = null
attempt(start)
do {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id }
change?.consume()
} while (change != null && change.pressed)
return@awaitEachGesture
}
// The first tap: a note, and the stroke that may follow
// paints the same note everywhere it goes.
lastTap = start
lastTapAt = now
val erasing = start in board.marks
note(start)
val marks = board.marks.toMutableSet()
marks.flip(start)
var previous = start
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id }
if (change == null || !change.pressed) break
change.consume()
val here = squareAt(change.position, cellPx, side) ?: continue
if (here == previous) continue
for (square in path(previous, here)) {
if (square in board.cats) continue
// `marks` is the stroke's own mirror: the base
// board plus every note this stroke has
// already submitted. The state flow lags a
// frame behind submissions, and a stroke that
// crosses a square twice must leave it at the
// stroke's target, not flip it back.
//
// Erasing means "flip everything still
// crossed"; painting means "flip everything
// still blank" — which is the same test read
// two ways: flip while marked == erasing.
if ((square in marks) == erasing) {
note(square)
marks.flip(square)
}
}
previous = here
}
}
}
) {
Column(modifier = Modifier.aspectRatio(1f)) {
for (row in 0 until side) {
Row(modifier = Modifier.weight(1f)) {
for (col in 0 until side) {
CellView(
square = Square(row, col),
state = current,
glyphSize = (cell.value * 0.55f).sp,
modifier = Modifier.weight(1f),
)
}
}
}
}
}
}
}
/** Which square a pointer position is over, or null if it has left the board. */
private fun squareAt(position: Offset, cellPx: Float, side: Int): Square? {
val col = (position.x / cellPx).toInt()
val row = (position.y / cellPx).toInt()
if (row !in 0 until side || col !in 0 until side) return null
return Square(row, col)
}
/** Add the square, or drop it if it was already there — the stroke's own toggle. */
private fun MutableSet<Square>.flip(square: Square) {
if (!add(square)) remove(square)
}
/**
* The squares between two consecutive pointer reads, endpoints not repeated.
*
* A fast stroke publishes fewer positions than it crosses cells, so the line
* between two reads is walked in whole-cell steps. [path] returns at most one
* square per cell crossed, in order, which is what lets a stroke cross a row
* in one drag without missing the squares between two events.
*/
private fun path(from: Square, to: Square): List<Square> {
val dr = to.row - from.row
val dc = to.col - from.col
val steps = maxOf(abs(dr), abs(dc))
if (steps <= 1) return listOf(to)
return (1..steps).map { i ->
Square(
from.row + (dr * i.toFloat() / steps).roundToInt(),
from.col + (dc * i.toFloat() / steps).roundToInt(),
)
}
}
@Composable
private fun CellView(
square: Square,
state: MeowdokuState,
glyphSize: TextUnit,
modifier: Modifier,
) {
val isCat = square in state.cats
val isMark = square in state.marks
val justWrong = state.lastWrong && state.lastSquare == square
Box(
modifier = modifier
.aspectRatio(1f)
.border(1.dp, Color(0x33000000))
.background(RegionColours[state.regionOf(square) % RegionColours.size])
.then(
if (justWrong) Modifier.border(3.dp, CardRed) else Modifier
),
contentAlignment = Alignment.Center,
) {
when {
isCat -> Text("🐱", fontSize = glyphSize)
isMark -> Text(
"✕",
color = Color(0xAA1C1B1F),
fontWeight = FontWeight.Bold,
fontSize = glyphSize,
)
}
}
}
@Composable
private fun EndBoardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
AlertDialog(
onDismissRequest = onKeepPlaying,
title = { Text("End this game?") },
text = { Text("The board is left as it stands.") },
confirmButton = {
val handle = LocalEndGameHandle.current
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
},
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@Composable
private fun LeftBoardDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) {
AlertDialog(
onDismissRequest = onStay,
title = { Text("The game has ended") },
text = { Text(notice) },
confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } },
dismissButton = { TextButton(onClick = onStay) { Text("Look at the board") } },
)
}
@@ -6,7 +6,11 @@ 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.BoxWithConstraints
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -30,12 +34,17 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.yahtzee.DICE_COUNT
import org.prolibertate.games.game.yahtzee.RollDice
import org.prolibertate.games.game.yahtzee.ScoreIn
import org.prolibertate.games.game.yahtzee.YahtzeeCategory
import org.prolibertate.games.game.yahtzee.YahtzeeComparison
import org.prolibertate.games.game.yahtzee.recordOf
import org.prolibertate.games.settings.YahtzeeStatsRepository
import kotlin.math.roundToInt
import org.prolibertate.games.game.yahtzee.YahtzeeMove
import org.prolibertate.games.game.yahtzee.YahtzeeSection
import org.prolibertate.games.game.yahtzee.YahtzeeState
@@ -61,6 +70,7 @@ private const val TITLE = "Yahtzee"
fun YahtzeeScreen(
controller: MatchController<YahtzeeState, YahtzeeMove>,
localSeat: Int,
stats: YahtzeeStatsRepository,
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
@@ -69,6 +79,11 @@ fun YahtzeeScreen(
val kept = remember { mutableStateListOf<Int>() }
var confirmingEnd by remember { mutableStateOf(false) }
var confirmingZero by remember { mutableStateOf<YahtzeeCategory?>(null) }
var comparison by remember { mutableStateOf<YahtzeeComparison?>(null) }
// Only a game seen to finish is recorded: reopening a saved game that was
// already over must not count it a second time.
var sawInProgress by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
@@ -89,6 +104,15 @@ fun YahtzeeScreen(
// refused, and the Throw button would look broken again.
LaunchedEffect(current.turn, current.round) { kept.clear() }
LaunchedEffect(current.isOver) {
if (!current.isOver) {
sawInProgress = true
} else if (sawInProgress) {
sawInProgress = false
comparison = stats.finish(recordOf(current, localSeat))
}
}
val yours = current.turn == localSeat && !current.isOver
val canRoll = legal.any { it is RollDice }
val writable = legal.filterIsInstance<ScoreIn>().map { it.category }.toSet()
@@ -138,8 +162,14 @@ fun YahtzeeScreen(
localSeat = localSeat,
writable = if (yours) writable else emptySet(),
onWrite = {
controller.submit(ScoreIn(it))
kept.clear()
// A zero is a box spent for nothing, and one mis-tap on a
// dense card is all it takes, so ask first.
if (scoreOf(it, current.dice) == 0) {
confirmingZero = it
} else {
controller.submit(ScoreIn(it))
kept.clear()
}
},
)
@@ -150,9 +180,79 @@ fun YahtzeeScreen(
abandoned?.let { LeftCardDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) EndCardDialog(onExit) { confirmingEnd = false }
comparison?.let { StatsDialog(it, onExit) { comparison = null } }
confirmingZero?.let { category ->
ZeroScoreDialog(
category = category,
onConfirm = {
confirmingZero = null
controller.submit(ScoreIn(category))
kept.clear()
},
onCancel = { confirmingZero = null },
)
}
}
}
@Composable
private fun StatsDialog(c: YahtzeeComparison, onLeave: () -> Unit, onClose: () -> Unit) {
fun pct(v: Double?) = v?.let { "${(it * 100).roundToInt()}%" } ?: "—"
val headline = when {
c.previousGames == 0 -> "Your first recorded game."
c.rank == 1 -> "A new personal best!"
else -> "${ordinal(c.rank)} best of ${c.gamesRanked} games."
}
AlertDialog(
onDismissRequest = onClose,
title = { Text("Game over: ${c.current.total} points") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(headline, fontWeight = FontWeight.Bold)
StatLine("", "This game", "Before")
StatLine("Score", "${c.current.total}", c.averageBefore?.let { "avg ${it.roundToInt()}, best ${c.bestBefore}" } ?: "—")
StatLine("Upper bonus", if (c.current.gotBonus) "Yes" else "No", pct(c.bonusRateBefore))
StatLine("Yahtzees", "${c.current.yahtzees}", "${c.yahtzeesBefore} total")
StatLine("Zeroed boxes", "${c.current.zeros}", "")
if (c.current.players > 1) {
StatLine("Result", if (c.current.won) "Won" else "Lost", "won ${pct(c.winRateBefore)}")
}
Text(
"Games before this one: ${c.previousGames}",
style = MaterialTheme.typography.bodySmall,
)
}
},
confirmButton = { TextButton(onClick = onLeave) { Text("Leave the game") } },
dismissButton = { TextButton(onClick = onClose) { Text("Look at the card") } },
)
}
@Composable
private fun StatLine(label: String, now: String, before: String) {
Row(modifier = Modifier.fillMaxWidth()) {
Text(label, modifier = Modifier.weight(1.2f), fontWeight = FontWeight.Bold)
Text(now, modifier = Modifier.weight(1f))
Text(before, modifier = Modifier.weight(1.6f))
}
}
private fun ordinal(n: Int): String {
val suffix = if (n % 100 in 11..13) "th" else when (n % 10) { 1 -> "st"; 2 -> "nd"; 3 -> "rd"; else -> "th" }
return "$n$suffix"
}
@Composable
private fun ZeroScoreDialog(category: YahtzeeCategory, onConfirm: () -> Unit, onCancel: () -> Unit) {
AlertDialog(
onDismissRequest = onCancel,
title = { Text("Score zero?") },
text = { Text("These dice are worth 0 in ${category.label}. Write the zero there?") },
confirmButton = { TextButton(onClick = onConfirm) { Text("Score 0") } },
dismissButton = { TextButton(onClick = onCancel) { Text("Choose another") } },
)
}
@Composable
private fun DiceRow(
dice: List<Int>,
@@ -190,12 +290,29 @@ private fun Die(face: Int?, held: Boolean, enabled: Boolean, onClick: () -> Unit
.clickable(enabled = enabled) { onClick() },
contentAlignment = Alignment.Center,
) {
Text(
text = face?.toString() ?: "·",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = WallaceRed,
)
if (face != null) {
Canvas(modifier = Modifier.size(48.dp)) {
val radius = size.minDimension * 0.08f
pipPositions(face).forEach { (x, y) ->
drawCircle(WallaceRed, radius, Offset(size.width * x, size.height * y))
}
}
}
}
}
/** Pip centres as fractions of the die face, in the usual casino layout. */
private fun pipPositions(face: Int): List<Pair<Float, Float>> {
val lo = 0.28f
val mid = 0.5f
val hi = 0.72f
return when (face) {
1 -> listOf(mid to mid)
2 -> listOf(lo to lo, hi to hi)
3 -> listOf(lo to lo, mid to mid, hi to hi)
4 -> listOf(lo to lo, hi to lo, lo to hi, hi to hi)
5 -> listOf(lo to lo, hi to lo, mid to mid, lo to hi, hi to hi)
else -> listOf(lo to lo, hi to lo, lo to mid, hi to mid, lo to hi, hi to hi)
}
}
@@ -206,28 +323,61 @@ private fun ScoreCard(
writable: Set<YahtzeeCategory>,
onWrite: (YahtzeeCategory) -> Unit,
) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(modifier = Modifier.fillMaxWidth()) {
Text("", modifier = Modifier.weight(2f), style = MaterialTheme.typography.labelSmall)
state.cards.indices.forEach { seat ->
Text(
text = if (seat == localSeat) "You" else "S$seat",
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
)
}
}
YahtzeeSection.entries.forEach { section ->
YahtzeeCategory.entries.filter { it.section == section }.forEach { category ->
CardRow(state, localSeat, category, writable, onWrite)
}
if (section == YahtzeeSection.UPPER) {
// Upper boxes and bonus on the left, lower boxes and total on the right, so
// each half is wide enough for text that can actually be read. Each half
// has a floor on its width; if two of them do not fit (many players, large
// text) the pair scrolls sideways instead of squeezing.
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val needed = (CARD_LABEL_WIDTH + CARD_SEAT_WIDTH * state.cards.size).dp
val half = maxOf((maxWidth - CARD_GAP.dp) / 2, needed)
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(CARD_GAP.dp),
) {
Column(modifier = Modifier.width(half)) {
CardHeader(state, localSeat)
CardSection(YahtzeeSection.UPPER, state, localSeat, writable, onWrite)
TotalRow("Bonus at 63", state.cards.map { it.upperBonus })
}
Column(modifier = Modifier.width(half)) {
CardHeader(state, localSeat)
CardSection(YahtzeeSection.LOWER, state, localSeat, writable, onWrite)
Divider()
TotalRow("Total", state.cards.indices.map { state.totalFor(it) }, bold = true)
}
}
Divider()
TotalRow("Total", state.cards.indices.map { state.totalFor(it) }, bold = true)
}
}
private const val CARD_LABEL_WIDTH = 120
private const val CARD_SEAT_WIDTH = 56
private const val CARD_GAP = 12
@Composable
private fun CardHeader(state: YahtzeeState, localSeat: Int) {
Row(modifier = Modifier.fillMaxWidth()) {
Text("", modifier = Modifier.weight(2f))
state.cards.indices.forEach { seat ->
Text(
text = if (seat == localSeat) "You" else "S$seat",
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
)
}
}
}
@Composable
private fun CardSection(
section: YahtzeeSection,
state: YahtzeeState,
localSeat: Int,
writable: Set<YahtzeeCategory>,
onWrite: (YahtzeeCategory) -> Unit,
) {
YahtzeeCategory.entries.filter { it.section == section }.forEach { category ->
CardRow(state, localSeat, category, writable, onWrite)
}
}
@@ -244,12 +394,12 @@ private fun CardRow(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = open) { onWrite(category) }
.padding(vertical = 2.dp),
.padding(vertical = 5.dp),
) {
Text(
text = category.label,
modifier = Modifier.weight(2f),
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (open) FontWeight.Bold else FontWeight.Normal,
)
state.cards.forEachIndexed { seat, card ->
@@ -263,7 +413,7 @@ private fun CardRow(
else -> "—"
},
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyMedium,
color = if (written == null && open) WallaceRed else MaterialTheme.colorScheme.onSurface,
)
}
@@ -272,18 +422,18 @@ private fun CardRow(
@Composable
private fun TotalRow(label: String, values: List<Int>, bold: Boolean = false) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp)) {
Text(
text = label,
modifier = Modifier.weight(2f),
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
)
values.forEach {
Text(
text = "$it",
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
)
}
@@ -33,6 +33,7 @@ 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
import org.prolibertate.games.game.catan.Terrain
/**
* The island, drawn and tappable.
@@ -61,12 +62,18 @@ fun CatanBoardView(
rolledHexes: Set<Int> = emptySet(),
/** Cities & Knights: the knight whose menu is open or that is about to move. */
selectedKnight: Int? = null,
/** A placement lined up but not yet confirmed (or being dragged over): drawn as a solid ghost. */
stagedVertex: Int? = null,
stagedEdge: Int? = null,
/** The seat that will own the staged piece, for its colour. */
stagedSeat: Int? = null,
) {
val measurer = rememberTextMeasurer()
var size by remember { mutableStateOf(IntSize.Zero) }
val layout = remember(size) {
val topo = state.topo
val layout = remember(size, topo) {
if (size.width > 0 && size.height > 0) {
CatanGeometry.fit(size.width.toFloat(), size.height.toFloat())
CatanGeometry.fit(size.width.toFloat(), size.height.toFloat(), topo)
} else {
null
}
@@ -80,7 +87,7 @@ fun CatanBoardView(
Canvas(
modifier = modifier
.aspectRatio(CatanGeometry.ASPECT)
.aspectRatio(CatanGeometry.aspect(topo))
.onSizeChanged { size = it }
.semantics { contentDescription = "Katan island board" }
.pointerInput(layout, targets, style) {
@@ -92,8 +99,8 @@ fun CatanBoardView(
targets.tappableVertices.isNotEmpty() ->
board.nearestVertex(at, reach, targets.tappableVertices)?.let { vertexTap(it) }
targets.edges.isNotEmpty() ->
board.nearestEdge(at, reach, targets.edges)?.let { edgeTap(it) }
targets.edges.isNotEmpty() || targets.ships.isNotEmpty() ->
board.nearestEdge(at, reach, targets.edges + targets.ships)?.let { edgeTap(it) }
targets.hexes.isNotEmpty() ->
// A hex is big: accept a tap anywhere near its middle.
@@ -108,9 +115,12 @@ fun CatanBoardView(
drawHexes(state, board, style, measurer)
drawRolled(rolledHexes, board, style)
drawRobber(state, board, style)
drawPirate(state, board, style)
drawMerchant(state, board, style, measurer)
drawRoads(state, board, style)
drawShips(state, board, style)
drawEdgeTargets(targets, board, style)
drawShipTargets(targets, board, style)
drawBuildings(state, board, style)
drawWalls(state, board, style)
drawMetropolises(state, board, style)
@@ -118,6 +128,7 @@ fun CatanBoardView(
drawKnightTargets(targets, board, style)
drawVertexTargets(targets, selectedVertex ?: selectedKnight, board, style)
drawHexTargets(targets, board, style)
drawStaged(stagedVertex, stagedEdge, stagedSeat, board, style)
}
}
@@ -131,10 +142,15 @@ private fun DrawScope.drawHexes(
style: CatanStyle,
measurer: TextMeasurer,
) {
for (hex in 0 until CatanBoard.HEX_COUNT) {
for (hex in 0 until state.topo.HEX_COUNT) {
val centre = board.hexCenter(hex)
val corners = board.hexCorners(hex).map { insetToward(it, centre, style.hexInset) }
val path = polygon(corners)
if (state.terrains[hex] == Terrain.SEA) {
// Open water: only a faint outline, so ships have a grid to sail on.
drawPath(path, color = style.hexEdge.copy(alpha = 0.12f), style = Stroke(width = board.radius * 0.02f))
continue
}
drawPath(path, color = style.terrain.getValue(state.terrains[hex]))
drawPath(path, color = style.hexEdge, style = Stroke(width = board.radius * 0.03f))
@@ -359,7 +375,7 @@ private fun DrawScope.drawResourceIcon(resource: Resource, at: Pt, s: Float, fil
// ---------------------------------------------------------------------------
private fun DrawScope.drawRoads(state: CatanState, board: BoardLayout, style: CatanStyle) {
for (edge in 0 until CatanBoard.EDGE_COUNT) {
for (edge in 0 until state.topo.EDGE_COUNT) {
val owner = state.roads[edge]
if (owner == NO_PLAYER) continue
val (a, b) = board.edgeEnds(edge)
@@ -385,7 +401,7 @@ private fun DrawScope.drawRoads(state: CatanState, board: BoardLayout, style: Ca
}
private fun DrawScope.drawBuildings(state: CatanState, board: BoardLayout, style: CatanStyle) {
for (vertex in 0 until CatanBoard.VERTEX_COUNT) {
for (vertex in 0 until state.topo.VERTEX_COUNT) {
val building = state.buildings[vertex] ?: continue
val at = board.vertex(vertex)
val fill = style.seats[building.owner % style.seats.size]
@@ -642,3 +658,86 @@ private fun DrawScope.drawCentered(
topLeft = Offset(at.x - layout.size.width / 2f, at.y - layout.size.height / 2f),
)
}
/** The ghost of a placement waiting for ✓: a solid piece in the player's colour with a bright ring. */
private fun DrawScope.drawStaged(vertex: Int?, edge: Int?, seat: Int?, board: BoardLayout, style: CatanStyle) {
val colour = seat?.let { style.seats.getOrNull(it) } ?: style.target
if (edge != null) {
val (a, b) = board.edgeEnds(edge)
val from = lerp(a, b, 0.1f)
val to = lerp(a, b, 0.9f)
drawLine(style.targetSelected, from.offset(), to.offset(), board.radius * style.roadWidth * 2.1f, StrokeCap.Round)
drawLine(colour, from.offset(), to.offset(), board.radius * style.roadWidth * 1.3f, StrokeCap.Round)
}
if (vertex != null) {
val at = board.vertex(vertex).offset()
val radius = board.radius * style.targetRadius
drawCircle(colour, radius * 1.3f, at)
drawCircle(style.targetSelected, radius * 1.9f, at, style = Stroke(width = board.radius * 0.1f))
}
}
// ---------------------------------------------------------------------------
// Seafarers: ships, the pirate
// ---------------------------------------------------------------------------
/** A ship on each sea edge that has one: a hull along the edge with a sail amidships, in the owner's colour. */
private fun DrawScope.drawShips(state: CatanState, board: BoardLayout, style: CatanStyle) {
val sea = state.sea ?: return
for (edge in sea.ships.indices) {
val owner = sea.ships[edge]
if (owner == NO_PLAYER) continue
val (a, b) = board.edgeEnds(edge)
val from = lerp(a, b, 0.2f)
val to = lerp(a, b, 0.8f)
val width = board.radius * style.roadWidth * 1.15f
val colour = style.seats[owner % style.seats.size]
drawLine(style.pieceOutline, from.offset(), to.offset(), width * 1.5f, StrokeCap.Round)
drawLine(colour, from.offset(), to.offset(), width, StrokeCap.Round)
// The sail: a small triangle standing off the hull, on the screen's upper side.
val mid = lerp(a, b, 0.5f)
val s = board.radius * 0.17f
val sail = Path().apply {
moveTo(mid.x - s * 0.1f, mid.y - s * 1.9f)
lineTo(mid.x + s * 1.1f, mid.y - s * 0.2f)
lineTo(mid.x - s * 0.1f, mid.y - s * 0.2f)
close()
}
drawPath(sail, style.shipSail)
drawPath(sail, style.pieceOutline, style = Stroke(width = board.radius * 0.03f))
}
}
/** A ring round each of the player's ships that can sail, so they read as tappable. */
private fun DrawScope.drawShipTargets(targets: BoardTargets, board: BoardLayout, style: CatanStyle) {
for (edge in targets.ships) {
val mid = board.edgeMidpoint(edge).offset()
drawCircle(style.target, board.radius * 0.38f, mid, style = Stroke(width = board.radius * 0.07f))
}
}
/** The pirate: a black pawn with a flag, drawn beside the middle of its sea hex like the robber. */
private fun DrawScope.drawPirate(state: CatanState, board: BoardLayout, style: CatanStyle) {
val sea = state.sea ?: return
val hex = board.hexCenter(sea.pirate)
val x = hex.x
val y = hex.y + board.radius * 0.1f
val s = board.radius * style.robberSize
val hull = Path().apply {
moveTo(x - s * 1.0f, y)
lineTo(x + s * 1.0f, y)
lineTo(x + s * 0.6f, y + s * 0.7f)
lineTo(x - s * 0.6f, y + s * 0.7f)
close()
}
drawPath(hull, style.pirate)
drawLine(style.pirate, Offset(x, y), Offset(x, y - s * 1.5f), strokeWidth = s * 0.12f)
val flag = Path().apply {
moveTo(x, y - s * 1.5f)
lineTo(x + s * 0.9f, y - s * 1.15f)
lineTo(x, y - s * 0.8f)
close()
}
drawPath(flag, style.pirate)
drawPath(hull, Color.White.copy(alpha = 0.7f), style = Stroke(width = s * 0.07f))
}
@@ -138,6 +138,55 @@ fun CatanDiscardDialog(
)
}
// ---------------------------------------------------------------------------
// Gold (Seafarers)
// ---------------------------------------------------------------------------
/**
* A gold field paid out: choose the resource cards it gives. Not dismissable: the game
* is waiting. The engine's list says which resources the bank can still pay.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanGoldDialog(
legal: List<CatanMove>,
onSubmit: (org.prolibertate.games.game.catan.PickGold) -> Unit,
) {
val count = CatanInteraction.goldCount(legal)
var picked by remember { mutableStateOf(emptyMap<Resource, Int>()) }
val selection = picked.asCards()
val move = CatanInteraction.goldMove(legal, selection)
// A resource is on offer if some legal pick names it.
val offered = legal.filterIsInstance<org.prolibertate.games.game.catan.PickGold>()
.flatMap { it.cards }.toSet()
AlertDialog(
onDismissRequest = {},
title = { Text(CatanText.GOLD_TITLE) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Gold! Choose $count resource cards (${selection.size} of $count chosen).")
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Resource.entries.filter { it in offered }.forEach { resource ->
ResourceCounter(
resource = resource,
count = picked[resource] ?: 0,
max = if (selection.size < count) count else (picked[resource] ?: 0),
onChange = { n -> picked = picked + (resource to n.coerceIn(0, count)) },
)
}
}
}
},
confirmButton = {
Button(onClick = { move?.let(onSubmit) }, enabled = move != null) { Text(CatanText.GOLD_TAKE) }
},
)
}
// ---------------------------------------------------------------------------
// The robber
// ---------------------------------------------------------------------------
@@ -1,6 +1,7 @@
package org.prolibertate.games.ui.game.catan
import org.prolibertate.games.game.catan.CatanBoard
import org.prolibertate.games.game.catan.Topology
import kotlin.math.sqrt
/** A point in board pixels. Plain floats, so this file needs no Compose types. */
@@ -24,29 +25,35 @@ class BoardLayout(
val height: Float,
/** Circumradius of one hex, in pixels. */
val radius: Float,
/** The board this layout draws: the standard island unless the game is Seafarers. */
val topo: Topology = CatanBoard.base,
) {
private val centreX = width / 2f
private val centreY = height / 2f
private val unitX = radius * SQRT3 / 2f
private val unitY = radius / 2f
/** The lattice point drawn at the middle of the box: the middle of the frame. */
private val midX = (topo.lattice[0] + topo.lattice[1]) / 2f
private val midY = (topo.lattice[2] + topo.lattice[3]) / 2f
fun point(latticeX: Int, latticeY: Int): Pt =
Pt(centreX + latticeX * unitX, centreY - latticeY * unitY)
Pt(centreX + (latticeX - midX) * unitX, centreY - (latticeY - midY) * unitY)
fun hexCenter(hex: Int): Pt {
val (lx, ly) = CatanBoard.hexCenter(hex)
val (lx, ly) = topo.hexCenter(hex)
return point(lx, ly)
}
fun vertex(vertex: Int): Pt {
val (lx, ly) = CatanBoard.VERTEX_POS[vertex]
val (lx, ly) = topo.VERTEX_POS[vertex]
return point(lx, ly)
}
fun hexCorners(hex: Int): List<Pt> = CatanBoard.HEX_CORNERS[hex].map { vertex(it) }
fun hexCorners(hex: Int): List<Pt> = topo.HEX_CORNERS[hex].map { vertex(it) }
fun edgeEnds(edge: Int): Pair<Pt, Pt> {
val (a, b) = CatanBoard.EDGE_VERTICES[edge]
val (a, b) = topo.EDGE_VERTICES[edge]
return vertex(a) to vertex(b)
}
@@ -62,8 +69,15 @@ class BoardLayout(
*/
fun portAnchor(edge: Int): Pt {
val mid = edgeMidpoint(edge)
val dx = mid.x - centreX
val dy = mid.y - centreY
// Straight out from the middle of the island on the standard board; on a map of
// islands, straight out from the land hex the harbour sits on.
val from = if (topo.EDGE_HEXES[edge].size == 1) {
Pt(centreX, centreY)
} else {
topo.EDGE_HEXES[edge].firstOrNull { topo.land[it] }?.let { hexCenter(it) } ?: Pt(centreX, centreY)
}
val dx = mid.x - from.x
val dy = mid.y - from.y
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)
}
@@ -97,23 +111,29 @@ class BoardLayout(
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
/** A Seafarers frame already ends in a ring of sea, so it needs almost none. */
private const val FRAME_MARGIN = 0.3f
private fun margin(topo: Topology) = if (topo.land.all { it }) MARGIN else FRAME_MARGIN
private fun widthInRadii(topo: Topology) =
(topo.lattice[1] - topo.lattice[0]) * SQRT3 / 2f + 2 * margin(topo)
private fun heightInRadii(topo: Topology) =
(topo.lattice[3] - topo.lattice[2]) / 2f + 2 * margin(topo)
/** Width ÷ height of the board including its margin, for sizing its box. */
val ASPECT: Float = boardWidthInRadii / boardHeightInRadii
val ASPECT: Float = widthInRadii(CatanBoard.base) / heightInRadii(CatanBoard.base)
fun aspect(topo: Topology): Float = widthInRadii(topo) / heightInRadii(topo)
/** 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)
fun fit(width: Float, height: Float, topo: Topology = CatanBoard.base): BoardLayout {
val radius = minOf(width / widthInRadii(topo), height / heightInRadii(topo))
return BoardLayout(width, height, radius, topo)
}
}
@@ -0,0 +1,460 @@
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.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Surface
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
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.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/*
* The heads-up display pieces of the landscape Katan screen: the controls the
* Catan Universe match screen uses (see docs/RE-catan-universe.md), redrawn in
* 2D. Stateless, like everything in CatanPanels.kt: values in, lambdas out.
*/
/** A round HUD button: a glyph or short word, optionally with a one-word caption. */
@Composable
fun HudRoundButton(
glyph: String,
modifier: Modifier = Modifier,
caption: String? = null,
enabled: Boolean = true,
primary: Boolean = false,
tint: Color = Color.Unspecified,
diameter: Dp = 56.dp,
onClick: () -> Unit,
) {
val container = when {
tint != Color.Unspecified -> tint
primary -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.secondaryContainer
}
val ink = if (primary || tint != Color.Unspecified) Color.White else MaterialTheme.colorScheme.onSecondaryContainer
Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(diameter)
.clip(CircleShape)
.background(container.copy(alpha = if (enabled) 1f else 0.35f))
.border(2.dp, Color(0x55000000), CircleShape)
.then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier),
contentAlignment = Alignment.Center,
) {
Text(glyph, color = ink.copy(alpha = if (enabled) 1f else 0.6f), fontSize = 18.sp, fontWeight = FontWeight.Bold)
}
// Always laid out, so the button column never changes height.
Text(caption ?: " ", fontSize = 10.sp, maxLines = 1)
}
}
/**
* The left-hand build column: one icon per piece. **Drag one onto the board** and
* release over a lit spot to stage it; or tap to arm the board and tap a spot. The
* column reports pointer positions in window coordinates so the screen can find the
* spot under the finger whatever the board's zoom and pan.
*/
@Composable
fun HudBuildColumn(
kinds: List<BuildKind>,
canBuild: Set<BuildKind>,
armed: BuildKind?,
modifier: Modifier = Modifier,
onTap: (BuildKind) -> Unit,
onDragStart: (BuildKind, Offset) -> Unit,
onDrag: (Offset) -> Unit,
onDragEnd: () -> Unit,
) {
Column(modifier, verticalArrangement = Arrangement.spacedBy(3.dp), horizontalAlignment = Alignment.CenterHorizontally) {
kinds.forEach { kind ->
BuildChip(kind, enabled = kind in canBuild, armed = armed == kind, onTap, onDragStart, onDrag, onDragEnd)
}
}
}
@Composable
private fun BuildChip(
kind: BuildKind,
enabled: Boolean,
armed: Boolean,
onTap: (BuildKind) -> Unit,
onDragStart: (BuildKind, Offset) -> Unit,
onDrag: (Offset) -> Unit,
onDragEnd: () -> Unit,
) {
var coords by remember { mutableStateOf<LayoutCoordinates?>(null) }
val start by rememberUpdatedState(onDragStart)
val move by rememberUpdatedState(onDrag)
val end by rememberUpdatedState(onDragEnd)
val tap by rememberUpdatedState(onTap)
val shape = RoundedCornerShape(12.dp)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(46.dp)
.clip(shape)
.background(
when {
armed -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = if (enabled) 1f else 0.35f)
},
)
.border(2.dp, Color(0x55000000), shape)
.onGloballyPositioned { coords = it }
.pointerInput(enabled, kind) {
if (!enabled) return@pointerInput
detectDragGestures(
onDragStart = { local ->
coords?.localToWindow(local)?.let { start(kind, it) }
},
onDrag = { change, _ ->
change.consume()
coords?.localToWindow(change.position)?.let { move(it) }
},
onDragEnd = { end() },
onDragCancel = { end() },
)
}
.then(if (enabled) Modifier.clickable { tap(kind) } else Modifier),
contentAlignment = Alignment.Center,
) {
Text(
CatanHudGlyphs.of(kind),
fontSize = 22.sp,
color = if (armed) Color.White else MaterialTheme.colorScheme.onSecondaryContainer,
)
}
Text(CatanHudGlyphs.label(kind), fontSize = 10.sp, maxLines = 1)
}
}
/** Glyphs and captions of the build column. Kept apart so the art can be swapped for drawables. */
object CatanHudGlyphs {
fun of(kind: BuildKind): String = when (kind) {
BuildKind.ROAD -> "━"
BuildKind.SETTLEMENT -> "⌂"
BuildKind.CITY -> "▲"
BuildKind.WALL -> "▦"
BuildKind.KNIGHT -> "♞"
BuildKind.SHIP -> "⛵"
}
fun label(kind: BuildKind): String = when (kind) {
BuildKind.ROAD -> CatanText.ROAD
BuildKind.SETTLEMENT -> CatanText.SETTLEMENT
BuildKind.CITY -> CatanText.CITY
BuildKind.WALL -> CatanText.WALL
BuildKind.KNIGHT -> CatanText.KNIGHT
BuildKind.SHIP -> CatanText.SHIP
}
}
/** ✓ beside ✗: confirm or cancel a staged placement. Always laid out; enabled only while something is staged. */
@Composable
fun HudConfirmButtons(
staged: Boolean,
modifier: Modifier = Modifier,
onConfirm: () -> Unit,
onCancel: () -> Unit,
) {
Row(modifier, horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.Bottom) {
HudRoundButton("✓", caption = CatanText.CONFIRM_BUILD, enabled = staged, tint = Color(0xFF2E7D32), diameter = 48.dp, onClick = onConfirm)
HudRoundButton("✕", caption = CatanText.CANCEL_BUILD, enabled = staged, tint = Color(0xFFC62828), diameter = 48.dp, onClick = onCancel)
}
}
/**
* Pan with one finger, pinch to zoom, like the Universe board camera. The content
* is drawn through a graphics layer, so taps inside it arrive already in the
* content's own coordinates and the board's hit-testing needs no changes.
*/
@Composable
fun ZoomPanBox(
modifier: Modifier = Modifier,
minScale: Float = 1f,
maxScale: Float = 3f,
content: @Composable () -> Unit,
) {
var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
Box(
modifier = modifier
.clip(RoundedCornerShape(0.dp))
.pointerInput(Unit) {
detectTransformGestures { centroid, pan, zoom, _ ->
val newScale = (scale * zoom).coerceIn(minScale, maxScale)
val centre = Offset(size.width / 2f, size.height / 2f)
// The content point under the fingers stays under them while zooming; then pan.
val world = (centroid - centre - offset) / scale
val next = centroid - centre - world * newScale + pan
val maxX = size.width * (newScale - 1f) / 2f
val maxY = size.height * (newScale - 1f) / 2f
offset = Offset(next.x.coerceIn(-maxX, maxX), next.y.coerceIn(-maxY, maxY))
scale = newScale
}
},
) {
Box(
Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = scale
scaleY = scale
translationX = offset.x
translationY = offset.y
},
contentAlignment = Alignment.Center,
) { content() }
}
}
/** A small caption pill, for the drag hint and the staged-move text. */
@Composable
fun HudPill(text: String, modifier: Modifier = Modifier) {
Text(
text,
color = Color.White,
fontSize = 12.sp,
maxLines = 1,
modifier = modifier
.clip(RoundedCornerShape(50))
.background(Color(0xCC000000))
.padding(horizontal = 12.dp, vertical = 4.dp),
)
}
/**
* The landscape screen, laid out like the Catan Universe match view:
*
* ```
* ┌ player strip ───────────────────────────────────────────────┐
* │ prompt + dice │
* │ [build] the island (pinch / pan) [✓] [✕] │
* │ [column] │
* │ [log] ┌ your cards ┐ [buy][play] [⇄][●]│
* └─────────────┴────────────┴───────────────────────────────────┘
* ```
*
* Everything here is wiring around the existing panels; the island is handed in as [board].
*/
@Composable
fun CatanHud(
state: org.prolibertate.games.game.catan.CatanState,
seats: CatanSeats,
style: CatanStyle,
actions: CatanActions,
prompt: String,
notice: String?,
legal: List<org.prolibertate.games.game.catan.CatanMove>,
localSeat: Int,
targets: BoardTargets,
pointsOf: (Int) -> Int,
staged: org.prolibertate.games.game.catan.CatanMove?,
dragKind: BuildKind?,
dragAt: Offset?,
showLog: Boolean,
boardCoords: LayoutCoordinates?,
building: BuildKind?,
onBoardCoords: (LayoutCoordinates) -> Unit,
onToggleLog: () -> Unit,
onArm: (BuildKind) -> Unit,
onDragStart: (BuildKind, Offset) -> Unit,
onDrag: (Offset) -> Unit,
onDragEnd: () -> Unit,
onConfirm: () -> Unit,
onCancelStaged: () -> Unit,
onRoll: () -> Unit,
onEndTurn: () -> Unit,
onEndFreeRoads: () -> Unit,
onEndSmith: () -> Unit,
onRepickSetup: () -> Unit,
onBuyCard: () -> Unit,
onPlayCard: () -> Unit,
onTrade: () -> Unit,
onImprove: () -> Unit,
onProgress: () -> Unit,
board: @Composable (Modifier) -> Unit,
) {
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(2.dp)) {
HudPlayerStrip(state, seats, pointsOf, style)
Box(Modifier.weight(1f).fillMaxWidth()) {
// The island fills the whole area; controls float over its sea margin.
ZoomPanBox(Modifier.fillMaxSize().padding(start = 64.dp, end = 64.dp, top = 30.dp, bottom = 44.dp)) {
board(Modifier.onGloballyPositioned(onBoardCoords))
}
// What is going on, and the dice. Two lines, always.
Row(
Modifier.align(Alignment.TopCenter).padding(horizontal = 64.dp)
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.8f))
.padding(horizontal = 8.dp, vertical = 1.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = notice ?: prompt,
style = MaterialTheme.typography.bodySmall,
color = if (notice != null) MaterialTheme.colorScheme.error else Color.Unspecified,
minLines = 2,
maxLines = 2,
modifier = Modifier.weight(1f),
)
CatanDice(state.lastDice, ck = state.isCk, event = state.ck?.eventDie)
}
// Left: pieces to drag onto the island.
HudBuildColumn(
kinds = actions.buildKinds,
canBuild = actions.canBuild,
armed = building,
modifier = Modifier.align(Alignment.CenterStart).padding(start = 6.dp),
onTap = onArm,
onDragStart = onDragStart,
onDrag = onDrag,
onDragEnd = onDragEnd,
)
// Right, middle: the card actions.
Column(
Modifier.align(Alignment.CenterEnd).padding(end = 6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (actions.ck) {
HudRoundButton("⬆", caption = CatanText.IMPROVE, enabled = actions.canImprove, diameter = 44.dp, onClick = onImprove)
HudRoundButton("🂠", caption = CatanText.CARDS_SHORT, enabled = actions.canPlayProgress, diameter = 44.dp, onClick = onProgress)
} else {
HudRoundButton("+", caption = CatanText.BUY_SHORT, enabled = actions.canBuyCard, diameter = 44.dp, onClick = onBuyCard)
HudRoundButton("🂠", caption = CatanText.CARDS_SHORT, enabled = actions.canPlayCard, diameter = 44.dp, onClick = onPlayCard)
}
}
// Bottom centre: your cards.
Box(
Modifier.align(Alignment.BottomCenter).padding(bottom = 4.dp, start = 80.dp, end = 270.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.85f))
.horizontalScroll(rememberScrollState())
.padding(6.dp),
) { CatanHand(state, localSeat) }
// Bottom left: the log.
Column(Modifier.align(Alignment.BottomStart).padding(start = 6.dp, bottom = 4.dp)) {
if (showLog) {
Surface(
modifier = Modifier.padding(bottom = 6.dp),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f),
tonalElevation = 4.dp,
) {
CatanLog(state, Modifier.padding(8.dp).height(150.dp).width(260.dp))
}
}
HudRoundButton("☰", caption = CatanText.LOG_BUTTON, diameter = 40.dp, onClick = onToggleLog)
}
// Bottom right: place / cancel a lined-up piece, trade, and the turn button.
Row(
Modifier.align(Alignment.BottomEnd).padding(end = 6.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.Bottom,
) {
HudConfirmButtons(staged = staged != null, onConfirm = onConfirm, onCancel = onCancelStaged)
HudRoundButton("⇄", caption = CatanText.TRADE, enabled = actions.canTrade, diameter = 48.dp, onClick = onTrade)
PrimaryTurnButton(actions, onRoll, onEndTurn, onEndFreeRoads, onEndSmith, onRepickSetup)
}
if (dragKind != null && dragAt == null) HudPill(CatanText.DRAG_HINT, Modifier.align(Alignment.TopCenter))
}
}
}
/** The one big button: roll, end the turn, finish roads, or step back from a setup choice. */
@Composable
private fun PrimaryTurnButton(
actions: CatanActions,
onRoll: () -> Unit,
onEndTurn: () -> Unit,
onEndFreeRoads: () -> Unit,
onEndSmith: () -> Unit,
onRepickSetup: () -> Unit,
) {
val (glyph, caption, enabled, click) = when {
actions.canRoll -> Quad("🎲", CatanText.ROLL_SHORT, true, onRoll)
actions.canEndFreeRoads -> Quad("✔", CatanText.DONE_SHORT, true, onEndFreeRoads)
actions.canEndSmith -> Quad("✔", CatanText.DONE_SHORT, true, onEndSmith)
actions.canRepickSetup -> Quad("↺", CatanText.CANCEL, true, onRepickSetup)
else -> Quad("➜", CatanText.END_SHORT, actions.canEndTurn, onEndTurn)
}
HudRoundButton(glyph, caption = caption, enabled = enabled, primary = true, diameter = 72.dp, onClick = click)
}
private data class Quad(val glyph: String, val caption: String, val enabled: Boolean, val click: () -> Unit)
/** Everyone's colour, name, points and card count in one thin row across the top. */
@Composable
private fun HudPlayerStrip(
state: org.prolibertate.games.game.catan.CatanState,
seats: CatanSeats,
pointsOf: (Int) -> Int,
style: CatanStyle,
) {
Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp, vertical = 2.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
(0 until state.playerCount).forEach { seat ->
val onTurn = seat == state.turn
val shape = RoundedCornerShape(10.dp)
Row(
Modifier.weight(1f)
.clip(shape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.border(if (onTurn) 2.dp else 1.dp, if (onTurn) MaterialTheme.colorScheme.primary else Color(0x33000000), shape)
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Box(
Modifier.size(14.dp).clip(CircleShape).background(style.seats[seat]).border(1.dp, style.pieceOutline, CircleShape),
)
Text(seats.label(seat), fontSize = 12.sp, fontWeight = FontWeight.Bold, maxLines = 1, modifier = Modifier.weight(1f))
Text("${pointsOf(seat)} VP · ${state.resourceCount(seat)}🂠", fontSize = 12.sp, maxLines = 1)
}
}
}
}
@@ -5,6 +5,10 @@ import org.prolibertate.games.game.catan.AcceptTrade
import org.prolibertate.games.game.catan.ActivateKnight
import org.prolibertate.games.game.catan.BuildCity
import org.prolibertate.games.game.catan.BuildRoad
import org.prolibertate.games.game.catan.BuildShip
import org.prolibertate.games.game.catan.MovePirate
import org.prolibertate.games.game.catan.MoveShip
import org.prolibertate.games.game.catan.Topology
import org.prolibertate.games.game.catan.BuildSettlement
import org.prolibertate.games.game.catan.BuildWall
import org.prolibertate.games.game.catan.BuyDevCard
@@ -35,6 +39,7 @@ import org.prolibertate.games.game.catan.MoveKnight
import org.prolibertate.games.game.catan.MoveRobber
import org.prolibertate.games.game.catan.OfferGoods
import org.prolibertate.games.game.catan.OfferTrade
import org.prolibertate.games.game.catan.PickGold
import org.prolibertate.games.game.catan.PillageCity
import org.prolibertate.games.game.catan.PlaceKnight
import org.prolibertate.games.game.catan.PlaceMetropolis
@@ -59,10 +64,16 @@ import org.prolibertate.games.game.catan.Track
* only in Cities & Knights; the base game's build menu lists the first three.
*/
enum class BuildKind {
ROAD, SETTLEMENT, CITY, WALL, KNIGHT;
ROAD, SETTLEMENT, CITY, WALL, KNIGHT,
/** Seafarers: a ship, laid on a sea edge. */
SHIP;
companion object {
val BASE: List<BuildKind> = listOf(ROAD, SETTLEMENT, CITY)
/** Seafarers adds the ship, listed after the road it sails beside. */
val SEAFARERS: List<BuildKind> = listOf(ROAD, SHIP, SETTLEMENT, CITY)
}
}
@@ -76,11 +87,14 @@ data class BoardTargets(
val edges: Set<Int> = emptySet(),
val hexes: Set<Int> = emptySet(),
val knights: Set<Int> = emptySet(),
/** Seafarers: the player's ships that can sail this turn, tapped to pick one up. */
val ships: Set<Int> = emptySet(),
/** The player's other knights: tappable for their menu, but with nothing to do, so not ringed. */
val idleKnights: Set<Int> = emptySet(),
) {
val isEmpty: Boolean
get() = vertices.isEmpty() && edges.isEmpty() && hexes.isEmpty() && knights.isEmpty() && idleKnights.isEmpty()
get() = vertices.isEmpty() && edges.isEmpty() && hexes.isEmpty() && knights.isEmpty() &&
idleKnights.isEmpty() && ships.isEmpty()
/** Every crossing a tap can land on. */
val tappableVertices: Set<Int> get() = vertices + knights + idleKnights
@@ -109,8 +123,11 @@ sealed interface 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
/** The robber's (or the pirate's) hex has more than one possible victim: ask which. */
data class PickRobberVictim(val hex: HexCoord, val victims: List<Int>, val pirate: Boolean = false) : TapOutcome
/** Seafarers: one of the player's ships was tapped; its destination is tapped next. */
data class PickShip(val edge: Int) : TapOutcome
/** One of the player's own knights was tapped: open its menu. */
data class PickKnight(val vertex: Int) : TapOutcome
@@ -142,6 +159,43 @@ data class DevCardInHand(val card: DevCard, val plays: List<CatanMove>) {
*/
object CatanInteraction {
// --- Staged placements and drag-to-build ------------------------------------
/** A place on the board a dragged piece is over: one crossing or one edge. */
data class Spot(val vertex: Int? = null, val edge: Int? = null)
/**
* The spot a finger at [at] (board pixels) is nearest to, among [targets]. [reach] is
* generous (fingers cover what they point at), and edges are only offered when no
* crossing is lit, matching what a tap would do.
*/
fun snap(layout: BoardLayout, targets: BoardTargets, at: Pt, reach: Float): Spot? = when {
targets.tappableVertices.isNotEmpty() ->
layout.nearestVertex(at, reach, targets.tappableVertices)?.let { Spot(vertex = it) }
targets.edges.isNotEmpty() -> layout.nearestEdge(at, reach, targets.edges)?.let { Spot(edge = it) }
else -> null
}
/** The move a build tap or drop would make, if it is a placement that waits for ✓. */
fun stageable(outcome: TapOutcome, building: BuildKind?): CatanMove? {
if (building == null || outcome !is TapOutcome.Submit) return null
return outcome.move.takeIf {
it is BuildRoad || it is BuildShip || it is BuildSettlement || it is BuildCity || it is BuildWall || it is HireKnight
}
}
/** Where a staged move sits on the board, for drawing its ghost. */
fun spotOf(move: CatanMove?): Spot = when (move) {
is BuildRoad -> Spot(edge = move.edge)
is BuildShip -> Spot(edge = move.edge)
is BuildSettlement -> Spot(vertex = move.vertex)
is BuildCity -> Spot(vertex = move.vertex)
is BuildWall -> Spot(vertex = move.vertex)
is HireKnight -> Spot(vertex = move.vertex)
else -> Spot()
}
// --- Board ---------------------------------------------------------------
fun targets(
@@ -153,6 +207,8 @@ object CatanInteraction {
armed: ArmedCard? = null,
/** The local seat, so its sleeping knights can be tapped for their menu too. */
localSeat: Int? = null,
/** Seafarers: the ship that has been picked up and is waiting for a destination. */
movingShip: Int? = null,
): BoardTargets {
val setup = legal.filterIsInstance<SetupPlace>()
if (setup.isNotEmpty()) {
@@ -164,8 +220,11 @@ object CatanInteraction {
}
val robber = legal.filterIsInstance<MoveRobber>()
if (robber.isNotEmpty()) {
return BoardTargets(hexes = robber.mapNotNull { CatanBoard.hexIndex[it.hex] }.toSet())
val pirate = legal.filterIsInstance<MovePirate>()
if (robber.isNotEmpty() || pirate.isNotEmpty()) {
return BoardTargets(
hexes = (robber.map { it.hex } + pirate.map { it.hex }).mapNotNull { state.topo.hexIndex[it] }.toSet(),
)
}
// Cities & Knights: answers to a queued chore are taps on the board.
@@ -173,7 +232,14 @@ object CatanInteraction {
if (chore.isNotEmpty()) return BoardTargets(vertices = chore)
val roads = legal.filterIsInstance<BuildRoad>().map { it.edge }.toSet()
if (state.phase == CatanPhase.FREE_ROADS) return BoardTargets(edges = roads)
val ships = legal.filterIsInstance<BuildShip>().map { it.edge }.toSet()
if (state.phase == CatanPhase.FREE_ROADS) return BoardTargets(edges = roads + ships)
if (movingShip != null) {
return BoardTargets(
edges = legal.filterIsInstance<MoveShip>().filter { it.from == movingShip }.map { it.to }.toSet(),
)
}
if (armed != null) return armedTargets(armed)
@@ -185,6 +251,7 @@ object CatanInteraction {
return when (building) {
BuildKind.ROAD -> BoardTargets(edges = roads)
BuildKind.SHIP -> BoardTargets(edges = ships)
BuildKind.SETTLEMENT ->
BoardTargets(vertices = legal.filterIsInstance<BuildSettlement>().map { it.vertex }.toSet())
@@ -204,7 +271,11 @@ object CatanInteraction {
} else {
emptySet()
}
BoardTargets(knights = ready, idleKnights = mine - ready)
BoardTargets(
knights = ready,
idleKnights = mine - ready,
ships = legal.filterIsInstance<MoveShip>().map { it.from }.toSet(),
)
}
}
}
@@ -314,6 +385,15 @@ object CatanInteraction {
// --- Cities & Knights: goods ----------------------------------------------
/** The gold pick that names exactly [selection], if the engine allows it. */
fun goldMove(legal: List<CatanMove>, selection: List<Resource>): PickGold? {
val want = selection.groupingBy { it }.eachCount()
return legal.filterIsInstance<PickGold>().firstOrNull { it.cards.groupingBy { c -> c }.eachCount() == want }
}
/** How many cards the gold pick on offer must name. */
fun goldCount(legal: List<CatanMove>): Int = legal.filterIsInstance<PickGold>().firstOrNull()?.cards?.size ?: 0
/** Bank trades on offer in Cities & Knights: what can be given, and for what. */
fun goodsBankTrades(legal: List<CatanMove>): Map<Good, List<Good>> =
legal.filterIsInstance<GoodsBankTrade>()
@@ -396,6 +476,8 @@ object CatanInteraction {
building: BuildKind?,
setupVertex: Int?,
armed: ArmedCard? = null,
/** Seafarers: the ship picked up and waiting for a destination. */
movingShip: Int? = null,
): TapOutcome {
if (legal.any { it is SetupPlace }) {
if (setupVertex == null) return TapOutcome.Ignored
@@ -406,21 +488,51 @@ object CatanInteraction {
val play = armed.plays.firstOrNull { it.edge == edge } ?: return TapOutcome.Ignored
return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored
}
// Free roads need no build mode; a paid road does.
val canBuild = building == BuildKind.ROAD || legal.any { it is EndFreeRoads }
if (movingShip != null) {
val move = MoveShip(movingShip, edge)
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
}
// Free roads (and free ships) need no build mode; a paid one does.
val free = legal.any { it is EndFreeRoads }
if (building == BuildKind.SHIP || (free && BuildShip(edge) in legal)) {
val move = BuildShip(edge)
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
}
if (building == null && !free) {
// Nothing armed: tapping one of your own ships that can sail picks it up.
return if (legal.any { it is MoveShip && it.from == edge }) TapOutcome.PickShip(edge) else TapOutcome.Ignored
}
val canBuild = building == BuildKind.ROAD || free
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>, armed: ArmedCard? = null): TapOutcome {
fun onHexTap(
hex: Int,
legal: List<CatanMove>,
armed: ArmedCard? = null,
/** The board the hex index belongs to (the standard island unless the game is Seafarers). */
topo: Topology = CatanBoard.base,
): TapOutcome {
if (armed != null) return onArmedHexTap(hex, legal, armed)
val coord = CatanBoard.HEXES.getOrNull(hex) ?: return TapOutcome.Ignored
val coord = topo.HEXES.getOrNull(hex) ?: return TapOutcome.Ignored
val moves = legal.filterIsInstance<MoveRobber>().filter { it.hex == coord }
val pirates = legal.filterIsInstance<MovePirate>().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 })
moves.isNotEmpty() -> if (moves.size == 1) {
TapOutcome.Submit(moves.first())
} else {
TapOutcome.PickRobberVictim(coord, moves.mapNotNull { it.victim })
}
pirates.isNotEmpty() -> if (pirates.size == 1) {
TapOutcome.Submit(pirates.first())
} else {
TapOutcome.PickRobberVictim(coord, pirates.mapNotNull { it.victim }, pirate = true)
}
else -> TapOutcome.Ignored
}
}
@@ -458,7 +570,7 @@ object CatanInteraction {
}
val total = state.lastDice.sum()
if (total == 7) return emptySet()
return (0 until CatanBoard.HEX_COUNT)
return (0 until state.topo.HEX_COUNT)
.filter { state.numbers[it] == total && it != state.robber }
.toSet()
}
@@ -471,6 +583,7 @@ object CatanInteraction {
fun canBuild(legal: List<CatanMove>): Set<BuildKind> = buildSet {
if (legal.any { it is BuildRoad }) add(BuildKind.ROAD)
if (legal.any { it is BuildShip }) add(BuildKind.SHIP)
if (legal.any { it is BuildSettlement }) add(BuildKind.SETTLEMENT)
if (legal.any { it is BuildCity }) add(BuildKind.CITY)
if (legal.any { it is BuildWall }) add(BuildKind.WALL)
@@ -597,6 +597,7 @@ private fun buildLabel(kind: BuildKind): String = when (kind) {
BuildKind.CITY -> CatanText.CITY
BuildKind.WALL -> CatanText.WALL
BuildKind.KNIGHT -> CatanText.KNIGHT
BuildKind.SHIP -> CatanText.SHIP
}
private fun buildCost(kind: BuildKind): String = when (kind) {
@@ -605,6 +606,7 @@ private fun buildCost(kind: BuildKind): String = when (kind) {
BuildKind.CITY -> CatanText.CITY_COST
BuildKind.WALL -> CatanText.WALL_COST
BuildKind.KNIGHT -> CatanText.KNIGHT_COST
BuildKind.SHIP -> CatanText.SHIP_COST_TEXT
}
@Composable
@@ -9,6 +9,15 @@ 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.layout.padding
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.background
import androidx.compose.material3.Surface
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.unit.IntSize
import org.prolibertate.games.game.catan.BuildRoad
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
@@ -22,6 +31,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -47,6 +57,7 @@ import org.prolibertate.games.game.catan.EndTurn
import org.prolibertate.games.game.catan.GiveChore
import org.prolibertate.games.game.catan.GiveGoods
import org.prolibertate.games.game.catan.MoveKnight
import org.prolibertate.games.game.catan.MoveShip
import org.prolibertate.games.game.catan.MoveRobber
import org.prolibertate.games.game.catan.PickDeckChore
import org.prolibertate.games.game.catan.RollDice
@@ -102,8 +113,22 @@ fun CatanScreen(
var knightMenu by remember { mutableStateOf<Int?>(null) }
var movingKnight by remember { mutableStateOf<Int?>(null) }
var armed by remember { mutableStateOf<ArmedCard?>(null) }
// Seafarers: the ship that has been picked up and is waiting for somewhere to sail.
var movingShip by remember { mutableStateOf<Int?>(null) }
var showImprove by remember { mutableStateOf(false) }
var showProgress by remember { mutableStateOf(false) }
// The landscape HUD: a placement waiting for its ✓, the piece being dragged from the
// build column (and where the finger is, in window pixels), the log popup, and the board.
var staged by remember { mutableStateOf<CatanMove?>(null) }
var dragKind by remember { mutableStateOf<BuildKind?>(null) }
var dragAt by remember { mutableStateOf<Offset?>(null) }
var showLog by remember { mutableStateOf(false) }
var boardCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
val config = LocalConfiguration.current
// Landscape gets the heads-up display; portrait keeps the stacked layout.
val wide = config.screenWidthDp > config.screenHeightDp * 1.1f
val staging = wide
val dark = isSystemInDarkTheme()
val style = remember(dark) { catanStyle(dark) }
@@ -114,10 +139,12 @@ fun CatanScreen(
// settlement that is no longer pending).
LaunchedEffect(legal) {
if (building != null && building !in CatanInteraction.canBuild(legal)) building = null
if (staged != null && staged !in legal) staged = null
if (setupVertex != null && legal.none { it is SetupPlace && it.vertex == setupVertex }) {
setupVertex = null
}
if (movingKnight != null && legal.none { it is MoveKnight && it.from == movingKnight }) movingKnight = null
if (movingShip != null && legal.none { it is MoveShip && it.from == movingShip }) movingShip = null
armed?.let { card -> if (card.plays.none { it in legal }) armed = null }
if (legal.isEmpty()) {
showCards = false
@@ -128,6 +155,7 @@ fun CatanScreen(
knightMenu = null
movingKnight = null
armed = null
staged = null
}
}
@@ -142,7 +170,11 @@ fun CatanScreen(
showProgress = false
knightMenu = null
movingKnight = null
movingShip = null
armed = null
staged = null
dragKind = null
dragAt = null
}
val endGameAction: @Composable () -> Unit = {
@@ -159,13 +191,17 @@ fun CatanScreen(
return
}
val targets = CatanInteraction.targets(current, legal, building, setupVertex, movingKnight, armed, localSeat)
val targets = CatanInteraction.targets(current, legal, building, setupVertex, movingKnight, armed, localSeat, movingShip)
val ownKnights = current.ck?.knights?.filter { it.owner == localSeat }?.map { it.vertex }?.toSet().orEmpty()
val devHand = CatanInteraction.devHand(current, localSeat, legal)
val progressHand = CatanInteraction.progressHand(current, localSeat, legal)
val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty()
val actions = CatanActions(
buildKinds = if (current.isCk) BuildKind.entries else BuildKind.BASE,
buildKinds = when {
current.isCk -> BuildKind.entries.filter { it != BuildKind.SHIP }
current.isSea -> BuildKind.SEAFARERS
else -> BuildKind.BASE
},
ck = current.isCk,
canImprove = CatanInteraction.improvable(legal).isNotEmpty(),
canPlayProgress = progressHand.any { it.playable },
@@ -189,16 +225,22 @@ fun CatanScreen(
building = building,
movingKnight = movingKnight != null,
armed = armed?.kind,
movingShip = movingShip != null,
)
val handle: (TapOutcome) -> Unit = { outcome ->
when (outcome) {
val wait = if (staging) CatanInteraction.stageable(outcome, building) else null
when {
wait != null -> staged = wait
else -> when (outcome) {
TapOutcome.Ignored -> Unit
is TapOutcome.Submit -> submit(outcome.move)
is TapOutcome.PickSetupVertex -> setupVertex = outcome.vertex
is TapOutcome.PickRobberVictim -> victimChoice = outcome
is TapOutcome.PickKnight -> knightMenu = outcome.vertex
is TapOutcome.PickShip -> movingShip = outcome.edge
is TapOutcome.PickFirstHex -> armed = armed?.copy(firstHex = outcome.hex)
}
}
}
@@ -210,10 +252,14 @@ fun CatanScreen(
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 hoverSpot = if (dragKind != null) dragSpot(boardCoords, dragAt, targets) else null
val stagedSpot = CatanInteraction.spotOf(staged)
val ghostVertex = hoverSpot?.vertex ?: stagedSpot.vertex
val ghostEdge = hoverSpot?.edge ?: stagedSpot.edge
val board: @Composable (Modifier) -> Unit = { boardModifier ->
CatanBoardView(
state = current,
@@ -227,12 +273,15 @@ fun CatanScreen(
),
)
},
onEdgeTap = { handle(CatanInteraction.onEdgeTap(it, legal, building, setupVertex, armed)) },
onHexTap = { handle(CatanInteraction.onHexTap(it, legal, armed)) },
onEdgeTap = { handle(CatanInteraction.onEdgeTap(it, legal, building, setupVertex, armed, movingShip)) },
onHexTap = { handle(CatanInteraction.onHexTap(it, legal, armed, current.topo)) },
modifier = boardModifier,
style = style,
rolledHexes = rolledHexes,
selectedKnight = movingKnight ?: knightMenu,
stagedVertex = ghostVertex,
stagedEdge = ghostEdge,
stagedSeat = localSeat,
)
}
val players: @Composable (Boolean) -> Unit = { grid ->
@@ -265,21 +314,61 @@ fun CatanScreen(
}
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)
if (current.isCk) CatanBarbarians(current, style = style)
promptRow()
handAndActions()
CatanLog(current, Modifier.fillMaxWidth().height(120.dp))
}
}
CatanHud(
state = current,
seats = seats,
style = style,
actions = actions,
prompt = prompt,
notice = notice,
legal = legal,
localSeat = localSeat,
targets = targets,
pointsOf = pointsOf,
staged = staged,
dragKind = dragKind,
dragAt = dragAt,
showLog = showLog,
boardCoords = boardCoords,
building = building,
onBoardCoords = { boardCoords = it },
onToggleLog = { showLog = !showLog },
onArm = { kind -> building = if (building == kind) null else kind; staged = null },
onDragStart = { kind, at ->
building = kind
staged = null
dragKind = kind
dragAt = at
},
onDrag = { dragAt = it },
onDragEnd = {
val spot = dragSpot(boardCoords, dragAt, targets)
dragKind = null
dragAt = null
val outcome = when {
spot?.vertex != null -> CatanInteraction.onVertexTap(
spot.vertex, legal, building, setupVertex,
ownKnights = if (legal.isNotEmpty()) ownKnights else emptySet(),
)
spot?.edge != null -> CatanInteraction.onEdgeTap(spot.edge, legal, building, setupVertex, armed, movingShip)
else -> TapOutcome.Ignored
}
handle(outcome)
},
onConfirm = { staged?.let(submit) },
onCancelStaged = { staged = null; building = null },
onRoll = { submit(RollDice()) },
onEndTurn = { legal.firstOrNull { m -> m is EndTurn }?.let(submit) },
onEndFreeRoads = { legal.firstOrNull { m -> m is EndFreeRoads }?.let(submit) },
onEndSmith = { legal.firstOrNull { m -> m is EndSmith }?.let(submit) },
onRepickSetup = { setupVertex = null },
onBuyCard = { legal.firstOrNull { m -> m is BuyDevCard }?.let(submit) },
onPlayCard = { showCards = true },
onTrade = { showTrade = true },
onImprove = { showImprove = true },
onProgress = { showProgress = true },
board = { m -> board(m) },
)
} else {
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
players(false)
@@ -291,6 +380,39 @@ fun CatanScreen(
handAndActions()
}
}
// The trade board slides up over the bottom of the island (base game; Cities &
// Knights trades goods and still uses its dialogs).
val offerToMe = current.pendingTrade?.takeIf {
current.phase == CatanPhase.TRADE_PENDING && it.asking == localSeat && !current.isCk
}
val boardsAllowed = abandoned == null && !confirmingEnd && current.phase != CatanPhase.GAME_OVER
if (boardsAllowed && offerToMe != null) {
CatanTradeReplyBoard(
state = current,
seat = localSeat,
offer = offerToMe,
seats = seats,
canAccept = CatanInteraction.tradeResponses(legal).first,
onAccept = { submit(AcceptTrade) },
onDecline = { submit(DeclineTrade) },
onCounter = submit,
modifier = Modifier.align(Alignment.BottomCenter),
style = style,
)
} else if (boardsAllowed && showTrade && !current.isCk) {
CatanTradeBoard(
state = current,
seat = localSeat,
seats = seats,
legal = legal,
onBank = submit,
onOffer = submit,
onClose = { showTrade = false },
modifier = Modifier.align(Alignment.BottomCenter),
style = style,
)
}
}
CatanDialogHost(
@@ -432,7 +554,6 @@ private fun CatanDialogHost(
onCancelCards: () -> Unit,
onCancelTrade: () -> Unit,
) {
val offer = state.pendingTrade
val goodsOffer = state.ck?.goodsOffer
when {
abandoned != null -> CatanAbandonedDialog(abandoned, onExit, onStay)
@@ -450,6 +571,9 @@ private fun CatanDialogHost(
onSubmit = onSubmit,
)
state.phase == CatanPhase.GOLD && CatanRules.currentSeat(state) == localSeat && legal.isNotEmpty() ->
CatanGoldDialog(legal, onSubmit)
state.phase == CatanPhase.CHORE && CatanRules.currentSeat(state) == localSeat ->
ChoreDialog(state, localSeat, legal, onSubmit)
@@ -473,18 +597,6 @@ private fun CatanDialogHost(
onNoDeal = { onSubmit(DismissCounters) },
)
state.phase == CatanPhase.TRADE_PENDING && offer != null && offer.asking == localSeat ->
CatanTradeResponseDialog(
state = state,
seat = localSeat,
offer = offer,
seats = seats,
canAccept = CatanInteraction.tradeResponses(legal).first,
onAccept = { onSubmit(AcceptTrade) },
onDecline = { onSubmit(DeclineTrade) },
onCounter = onSubmit,
)
state.phase == CatanPhase.TRADE_COUNTERS && state.turn == localSeat && legal.isNotEmpty() ->
CatanCounterChoiceDialog(
counters = CatanInteraction.counterChoices(state, legal),
@@ -497,7 +609,15 @@ private fun CatanDialogHost(
CatanVictimDialog(
victims = victimChoice.victims,
seats = seats,
onPick = { victim -> onSubmit(MoveRobber(victimChoice.hex, victim)) },
onPick = { victim ->
onSubmit(
if (victimChoice.pirate) {
org.prolibertate.games.game.catan.MovePirate(victimChoice.hex, victim)
} else {
MoveRobber(victimChoice.hex, victim)
},
)
},
onCancel = onCancelVictim,
)
@@ -541,17 +661,6 @@ private fun CatanDialogHost(
onOffer = onSubmit,
onCancel = onCancelTrade,
)
showTrade ->
CatanTradeDialog(
state = state,
seat = localSeat,
seats = seats,
legal = legal,
onBank = onSubmit,
onOffer = onSubmit,
onCancel = onCancelTrade,
)
}
}
@@ -612,3 +721,11 @@ private fun ChoreDialog(
else -> Unit
}
}
/** The board spot under a dragged finger ([at], in window pixels), or null off the board. */
private fun dragSpot(coords: LayoutCoordinates?, at: Offset?, targets: BoardTargets): CatanInteraction.Spot? {
if (coords == null || at == null || !coords.isAttached) return null
val local = coords.windowToLocal(at)
val layout = CatanGeometry.fit(coords.size.width.toFloat(), coords.size.height.toFloat())
return CatanInteraction.snap(layout, targets, Pt(local.x, local.y), layout.radius * 1.2f)
}
@@ -81,6 +81,9 @@ data class CatanStyle(
val citySize: Float = 0.42f,
val tokenRadius: Float = 0.36f,
val robberSize: Float = 0.34f,
/** Seafarers: the pirate's flag, and the hull and sail of a ship. */
val pirate: Color = Color(0xFF000000),
val shipSail: Color = Color(0xFFFFFFFF),
val portRadius: Float = 0.46f,
val targetRadius: Float = 0.20f,
/** How close a tap must land to a vertex / edge / hex centre to count. */
@@ -118,6 +121,9 @@ private val LightStyle = CatanStyle(
Terrain.HILLS to Color(0xFFB4532F),
Terrain.MOUNTAINS to Color(0xFF7C8794),
Terrain.DESERT to Color(0xFFE6D3A0),
// Seafarers. Sea hexes are drawn as a faint wash over the water; gold is a rich yellow.
Terrain.SEA to Color(0xFF3A7CA5),
Terrain.GOLD to Color(0xFFF2B01E),
),
hexEdge = Color(0x55000000),
token = Color(0xFFF7EFD9),
@@ -72,6 +72,8 @@ object CatanText {
Terrain.HILLS -> "Hills"
Terrain.MOUNTAINS -> "Mountains"
Terrain.DESERT -> "Desert"
Terrain.SEA -> "Sea"
Terrain.GOLD -> "Gold field"
}
fun devCard(type: DevCardType): String = when (type) {
@@ -108,6 +110,11 @@ object CatanText {
// --- Buttons and headings --------------------------------------------------
const val SEA_ROBBER = "Move the robber (tap a land hex) or the pirate (tap a sea hex)."
const val SHIP = "Ship"
const val SHIP_COST_TEXT = "1 lumber + 1 wool"
const val GOLD_TITLE = "Gold field"
const val GOLD_TAKE = "Take"
const val ROLL = "Roll dice"
const val END_TURN = "End turn"
const val ROAD = "Road"
@@ -139,6 +146,28 @@ object CatanText {
const val YOU_GIVE = "You give"
const val YOU_GET = "You get"
const val SEND_OFFER = "Send offer"
// --- Trade board and HUD -----------------------------------------------------
const val TAB_PLAYER = "Player"
const val TAB_BANK = "Bank"
const val YOU_RECEIVE_ROW = "You get"
const val YOU_GIVE_ROW = "You give"
const val TRADE_HINT_PLAYER = "Tap cards to build an offer. Tap a player to address it to them alone."
const val TRADE_HINT_BANK = "Tap what you give, then what you want."
const val TRADE_HINT_BANK_EMPTY = "You do not hold enough of any one resource to trade with the bank."
const val TRADE_HINT_COUNTER = "Change the terms, then send them back."
const val HIDE = "Hide"
const val DENY = "Deny"
const val CONFIRM_BUILD = "Place"
const val CANCEL_BUILD = "Cancel"
const val DRAG_HINT = "Drag a piece onto the board"
const val LOG_BUTTON = "Log"
const val CARDS_SHORT = "Cards"
const val BUY_SHORT = "Buy"
const val ROLL_SHORT = "Roll"
const val END_SHORT = "End"
const val DONE_SHORT = "Done"
const val LOG_TITLE = "Table log"
const val NO_LOG = "Nothing has happened yet."
const val END_GAME = "End game"
const val LOOK_AT_BOARD = "Look at the board"
@@ -161,6 +190,8 @@ object CatanText {
movingKnight: Boolean = false,
/** Cities & Knights: a progress card is waiting for a tap on the board. */
armed: ProgressType? = null,
/** Seafarers: a ship has been picked to sail, and its destination is awaited. */
movingShip: Boolean = false,
): String {
if (state.isCk) return ckPrompt(state, seats, setupVertexChosen, building, movingKnight, armed)
val me = seats.me
@@ -198,10 +229,23 @@ object CatanText {
}
CatanPhase.ROBBER ->
if (mine) "Move the robber. Tap a hex." else "$turn is moving the robber…"
if (mine) {
if (state.isSea) SEA_ROBBER else "Move the robber. Tap a hex."
} else {
"$turn is moving the robber…"
}
CatanPhase.GOLD ->
if (mine) {
"Gold! Choose ${state.sea?.gold?.firstOrNull()?.count ?: 1} resource cards."
} else {
"${seats.label(onClock ?: state.turn)} is choosing from the gold…"
}
CatanPhase.PLAYING -> when {
!mine -> "$turn is playing…"
movingShip -> "Tap the sea edge to sail the ship to."
building == BuildKind.SHIP -> "Tap a sea edge beside your fleet or a harbour town to launch a ship."
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."
@@ -364,6 +408,8 @@ object CatanText {
val ck = state.ck!!
return when (state.phase) {
CatanPhase.GOLD -> ""
CatanPhase.GAME_OVER -> {
val winner = state.winner
if (winner == null) "The game is over." else "${seats.label(winner)} won the game."
@@ -0,0 +1,415 @@
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.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.prolibertate.games.game.catan.CatanMove
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.CounterTrade
import org.prolibertate.games.game.catan.MaritimeTrade
import org.prolibertate.games.game.catan.OfferTrade
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.TradeOffer
/*
* The trade board: the 2D version of Catan Universe's trade panel (see
* docs/RE-catan-universe.md). One panel slides up over the bottom of the board:
*
* header [Player | Bank] hint rival buttons
* top row the cards you GET, tinted with the other side's colour
* arrows
* bottom row the cards you GIVE, tinted with your colour
* right ▾ hide ✕ deny ✓ accept
*
* Like every Katan panel it decides nothing: what may be offered is asked of
* [CatanInteraction], and what a bank trade costs comes from the engine's legal list.
*/
private enum class TradeMode { PLAYER, BANK }
private fun Map<Resource, Int>.cards(): List<Resource> =
Resource.entries.flatMap { r -> List(this[r] ?: 0) { r } }
/** The panel for starting a trade: with the bank, or an offer to one rival or everyone. */
@Composable
fun CatanTradeBoard(
state: CatanState,
seat: Int,
seats: CatanSeats,
legal: List<CatanMove>,
onBank: (MaritimeTrade) -> Unit,
onOffer: (OfferTrade) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
style: CatanStyle = LocalCatanStyle.current,
) {
var mode by remember { mutableStateOf(TradeMode.BANK) }
var to by remember { mutableStateOf<Int?>(null) }
var give by remember { mutableStateOf(emptyMap<Resource, Int>()) }
var get by remember { mutableStateOf(emptyMap<Resource, Int>()) }
var bankGive by remember { mutableStateOf<Resource?>(null) }
var bankGet by remember { mutableStateOf<Resource?>(null) }
val bank = CatanInteraction.bankTrades(legal)
val giveCards = give.cards()
val getCards = get.cards()
val canSend = CatanInteraction.offerAllowed(state, seat, to, giveCards, getCards)
val bankMove = bankGive?.let { g -> bankGet?.takeIf { it in bank[g].orEmpty() }?.let { MaritimeTrade(g, it) } }
val rivals = (0 until state.playerCount).filter { it != seat }
val headerRight: @Composable () -> Unit = {
if (mode == TradeMode.PLAYER) {
RivalButtons(rivals, to, style, seats) { to = if (to == it) null else it }
}
}
TradeBoardFrame(
modifier = modifier,
topTint = if (mode == TradeMode.PLAYER && to != null) style.seats[to!!] else Color.Gray,
bottomTint = style.seats[seat],
header = {
TradeTab(CatanText.TAB_BANK, mode == TradeMode.BANK) { mode = TradeMode.BANK }
TradeTab(CatanText.TAB_PLAYER, mode == TradeMode.PLAYER) { mode = TradeMode.PLAYER }
},
headline = when {
mode == TradeMode.PLAYER -> CatanText.TRADE_HINT_PLAYER
bank.isEmpty() -> CatanText.TRADE_HINT_BANK_EMPTY
else -> CatanText.TRADE_HINT_BANK
},
headerRight = headerRight,
topLabel = CatanText.YOU_RECEIVE_ROW,
bottomLabel = CatanText.YOU_GIVE_ROW,
topRow = {
if (mode == TradeMode.PLAYER) {
TradeCardRow(
counts = get,
limit = { 9 },
onChange = { get = it },
)
} else {
val choices = bankGive?.let { bank[it] }.orEmpty().toSet()
TradeCardRow(
counts = bankGet?.let { mapOf(it to 1) } ?: emptyMap(),
limit = { if (it in choices) 1 else 0 },
enabledOf = { it in choices },
onChange = {},
pick = { bankGet = if (bankGet == it) null else it },
)
}
},
bottomRow = {
if (mode == TradeMode.PLAYER) {
TradeCardRow(
counts = give,
limit = { state.resources[seat][it] ?: 0 },
onChange = { give = it },
)
} else {
TradeCardRow(
counts = bank.keys.associateWith { CatanInteraction.bankRatio(state, seat, it) },
limit = { 0 },
enabledOf = { it in bank },
selected = bankGive,
caption = { r -> if (r in bank) "${CatanInteraction.bankRatio(state, seat, r)} : 1" else " " },
onChange = {},
pick = { r ->
bankGive = if (bankGive == r) null else r
bankGet = null
},
)
}
},
acceptEnabled = if (mode == TradeMode.PLAYER) canSend else bankMove != null,
onAccept = {
if (mode == TradeMode.PLAYER) {
onOffer(OfferTrade(to, giveCards, getCards))
} else {
bankMove?.let(onBank)
}
},
denyEnabled = true,
onDeny = {
give = emptyMap()
get = emptyMap()
bankGive = null
bankGet = null
to = null
},
onHide = onClose,
)
}
/**
* The panel for an offer made to the local player: the same layout, read-only. ✓ accepts,
* ✕ declines, and *Counter…* turns the rows editable, pre-filled with the mirror of the offer.
*/
@Composable
fun CatanTradeReplyBoard(
state: CatanState,
seat: Int,
offer: TradeOffer,
seats: CatanSeats,
canAccept: Boolean,
onAccept: () -> Unit,
onDecline: () -> Unit,
onCounter: (CounterTrade) -> Unit,
modifier: Modifier = Modifier,
style: CatanStyle = LocalCatanStyle.current,
) {
var countering by remember(offer) { mutableStateOf(false) }
var give by remember(offer) { mutableStateOf(offer.get.groupingBy { it }.eachCount()) }
var get by remember(offer) { mutableStateOf(offer.give.groupingBy { it }.eachCount()) }
val counter = CounterTrade(give.cards(), get.cards())
TradeBoardFrame(
modifier = modifier,
topTint = style.seats[offer.by],
bottomTint = style.seats[seat],
header = {
TradeTab(CatanText.COUNTER, countering) { countering = !countering }
},
headline = if (countering) {
CatanText.TRADE_HINT_COUNTER
} else {
CatanText.tradeSummary(
by = seats.label(offer.by),
to = if (offer.to == null) "the table" else "you",
give = offer.give,
get = offer.get,
)
},
headerRight = {},
topLabel = CatanText.YOU_RECEIVE_ROW,
bottomLabel = CatanText.YOU_GIVE_ROW,
topRow = {
if (countering) {
TradeCardRow(get, limit = { state.resources[offer.by][it] ?: 0 }, onChange = { get = it })
} else {
TradeCardRow(offer.give.groupingBy { it }.eachCount(), limit = { 0 }, onChange = {})
}
},
bottomRow = {
if (countering) {
TradeCardRow(give, limit = { state.resources[seat][it] ?: 0 }, onChange = { give = it })
} else {
TradeCardRow(offer.get.groupingBy { it }.eachCount(), limit = { 0 }, onChange = {})
}
},
acceptEnabled = if (countering) {
CatanInteraction.counterAllowed(state, seat, counter.give, counter.get)
} else {
canAccept
},
onAccept = { if (countering) onCounter(counter) else onAccept() },
denyEnabled = true,
onDeny = onDecline,
onHide = null,
)
}
// ---------------------------------------------------------------------------
// Pieces
// ---------------------------------------------------------------------------
@Composable
private fun TradeBoardFrame(
topTint: Color,
bottomTint: Color,
header: @Composable () -> Unit,
headline: String,
headerRight: @Composable () -> Unit,
topLabel: String,
bottomLabel: String,
topRow: @Composable () -> Unit,
bottomRow: @Composable () -> Unit,
acceptEnabled: Boolean,
onAccept: () -> Unit,
denyEnabled: Boolean,
onDeny: () -> Unit,
onHide: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.97f),
tonalElevation = 6.dp,
shadowElevation = 12.dp,
) {
Row(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
header()
Text(
headline,
style = MaterialTheme.typography.bodySmall,
// Two lines, always: the panel keeps its height whatever is said.
minLines = 2,
maxLines = 2,
modifier = Modifier.weight(1f),
)
headerRight()
}
HorizontalDivider()
TintedRow(topTint, topLabel, topRow)
Text("⇣ ⇣ ⇣", modifier = Modifier.align(Alignment.CenterHorizontally), fontSize = 14.sp)
TintedRow(bottomTint, bottomLabel, bottomRow)
}
Column(
modifier = Modifier.padding(start = 10.dp).width(72.dp),
verticalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Always three slots, so the column does not shift when there is nothing to hide.
if (onHide != null) {
HudRoundButton("▾", caption = CatanText.HIDE, diameter = 48.dp, onClick = onHide)
} else {
Box(Modifier.size(48.dp))
}
HudRoundButton("✕", caption = CatanText.DENY, enabled = denyEnabled, tint = Color(0xFFC62828), diameter = 52.dp, onClick = onDeny)
HudRoundButton("✓", caption = CatanText.ACCEPT, enabled = acceptEnabled, tint = Color(0xFF2E7D32), diameter = 60.dp, onClick = onAccept)
}
}
}
}
@Composable
private fun TintedRow(tint: Color, label: String, content: @Composable () -> Unit) {
val shape = RoundedCornerShape(12.dp)
Column(
Modifier
.fillMaxWidth()
.clip(shape)
.background(tint.copy(alpha = 0.18f))
.border(1.dp, tint.copy(alpha = 0.5f), shape)
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Text(label, fontSize = 10.sp, fontWeight = FontWeight.Bold)
Box(Modifier.horizontalScroll(rememberScrollState())) { content() }
}
}
/**
* Five resource cards in a row. Tapping a card adds one (up to [limit]); the "−" under it
* takes one back. A bank row passes [pick] instead, and a tap selects the card.
*/
@Composable
private fun TradeCardRow(
counts: Map<Resource, Int>,
limit: (Resource) -> Int,
onChange: (Map<Resource, Int>) -> Unit,
enabledOf: (Resource) -> Boolean = { true },
selected: Resource? = null,
caption: ((Resource) -> String)? = null,
pick: ((Resource) -> Unit)? = null,
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Resource.entries.forEach { resource ->
val n = counts[resource] ?: 0
Column(horizontalAlignment = Alignment.CenterHorizontally) {
ResourceTile(
resource = resource,
count = n,
selected = selected == resource,
enabled = enabledOf(resource) && (pick != null || n > 0 || limit(resource) > 0),
onClick = {
if (pick != null) {
pick(resource)
} else if (n < limit(resource)) {
onChange(counts + (resource to n + 1))
}
},
)
if (caption != null) {
Text(caption(resource), fontSize = 11.sp, fontWeight = FontWeight.Bold)
} else {
// Always laid out so the row never changes height.
Text(
"−",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.alpha(if (n > 0 && pick == null && limit(resource) >= 0) 1f else 0f)
.clickable(enabled = n > 0 && pick == null) {
onChange(counts + (resource to n - 1))
}
.padding(horizontal = 14.dp),
)
}
}
}
}
}
@Composable
private fun TradeTab(label: String, selected: Boolean, onClick: () -> Unit) {
val shape = RoundedCornerShape(50)
Text(
label,
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.clip(shape)
.background(if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 8.dp),
)
}
/** One round button per rival: tap to address the offer to them alone, tap again for everyone. */
@Composable
private fun RivalButtons(
rivals: List<Int>,
chosen: Int?,
style: CatanStyle,
seats: CatanSeats,
onTap: (Int) -> Unit,
) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
rivals.forEach { rival ->
val picked = chosen == rival
Box(
Modifier
.size(40.dp)
.clip(CircleShape)
.background(style.seats[rival])
.border(if (picked) 4.dp else 1.dp, if (picked) Color.White else Color(0x55000000), CircleShape)
.clickable { onTap(rival) },
contentAlignment = Alignment.Center,
) {
Text(seats.label(rival).take(1).uppercase(), color = Color.White, fontWeight = FontWeight.Bold, fontSize = 14.sp)
}
}
}
}
@@ -106,30 +106,33 @@ class CatalogTest {
}
@Test
fun `every reference game in the catalogue can be played`() {
// Every game the app ships is playable. The one exception is Katan,
// listed so the slot exists and marked *Coming soon* until its engine
// and screen land — `available = false` is what keeps an unfinished
// game out of a production build while leaving it visible on a dev one.
assertEquals(
listOf(GameCatalog.CATAN),
GameCatalog.all.filterNot { it.available }.map { it.id },
)
// It is also the only difference the two channels see in a menu.
val hiddenFromProduction = GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = true) -
GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = false)
assertEquals(listOf(GameCatalog.byId(GameCatalog.CATAN)!!), hiddenFromProduction)
fun `every game in the catalogue can be played`() {
// `available` is the release gate. Nothing is held back at the moment,
// so a production menu lists every game; a game parked behind the gate
// belongs in `devPreview`, and must say so rather than being quietly
// unavailable everywhere.
GameCatalog.all.filterNot { it.available }.forEach { game ->
assertTrue("${game.id} is unavailable but not a dev preview", game.devPreview)
}
GameMenu.entries.forEach { menu ->
val held = GameCatalog.inMenu(menu, includeComingSoon = true) -
GameCatalog.inMenu(menu, includeComingSoon = false).toSet()
held.forEach { assertTrue("${it.id} hidden from production without a preview flag", it.devPreview) }
}
}
@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 ->
GameCatalog.all.filter { it.devPreview }.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))
}
// The two games that were once previews now open everywhere.
listOf(GameCatalog.CATAN, GameCatalog.MEOWDOKU).forEach { id ->
val game = GameCatalog.byId(id)!!
assertTrue("$id should 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))
@@ -174,6 +177,8 @@ class CatalogTest {
GameCatalog.MASTERMIND,
GameCatalog.BACKGAMMON,
GameCatalog.PIRATES,
GameCatalog.CATAN,
GameCatalog.MEOWDOKU,
),
GameCatalog.playable.map { it.id }.toSet(),
)
@@ -0,0 +1,325 @@
package org.prolibertate.games.game.catan
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
class CatanSeafarersTest {
private val json = Json { encodeDefaults = true }
private fun options(players: Int = 3) =
CatanOptions(playerCount = players, pointsToWin = 12, ruleSet = CatanRuleSet.SEAFARERS)
private fun config(options: CatanOptions, seed: Long) = TableConfig(
gameId = GameCatalog.CATAN,
seats = (0 until options.playerCount).map {
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
},
optionsJson = json.encodeToString(options),
seed = seed,
)
private fun start(players: Int = 3, seed: Long = 7L): CatanState =
CatanRules.initialState(config(options(players), seed))
/** A mid-game table on seat 0's turn with plenty of cards. */
private fun playing(seed: Long = 7L, adjust: CatanState.() -> CatanState = { this }): CatanState =
start(seed = seed).copy(
phase = CatanPhase.PLAYING,
turn = 0,
turnNumber = 1,
resources = List(3) { Resource.entries.associateWith { 5 } },
).adjust()
private fun CatanState.settle(vertex: Int, seat: Int, type: BuildingType = BuildingType.SETTLEMENT) =
copy(buildings = buildings.toMutableList().also { it[vertex] = CatanBuilding(type, seat) })
private fun CatanState.withShip(edge: Int, seat: Int) =
copy(sea = sea!!.let { it.copy(ships = it.ships.toMutableList().also { l -> l[edge] = seat }) })
private fun CatanState.withRoad(edge: Int, seat: Int) =
copy(roads = roads.toMutableList().also { it[edge] = seat })
/** A home-island vertex that touches open water, with a sea edge leaving it. */
private fun CatanState.coastVertex(): Int {
val topo = topo
return (0 until topo.VERTEX_COUNT).first { v ->
topo.vertexIsland[v] == sea!!.home &&
topo.VERTEX_EDGES[v].any { topo.edgeSea[it] && !topo.edgeLand[it] }
}
}
// --- The map -------------------------------------------------------------
@Test
fun `the new shores map is a big home island and three small ones`() {
val topo = SeaBoards.topology(SeaScenario.NEW_SHORES)
assertEquals(4, topo.islandCount)
val sizes = (0 until topo.islandCount).map { i -> topo.islandOf.count { it == i } }.sorted()
assertEquals(listOf(2, 3, 3, 21), sizes)
// Every coast is closed: nothing on land touches the edge of the frame.
for (e in 0 until topo.EDGE_COUNT) {
if (topo.EDGE_HEXES[e].size == 1) assertFalse("land on the frame edge at $e", topo.land[topo.EDGE_HEXES[e][0]])
}
}
@Test
fun `a new game has gold, a desert, numbers on every other land hex, a pirate at sea and nine harbours`() {
repeat(5) { i ->
val s = start(seed = 100L + i)
val topo = s.topo
assertEquals(2, s.terrains.count { it == Terrain.GOLD })
assertEquals(1, s.terrains.count { it == Terrain.DESERT })
for (h in 0 until topo.HEX_COUNT) {
val land = s.terrains[h] != Terrain.SEA
assertEquals("hex $h", topo.land[h], land)
assertEquals("hex $h", land && s.terrains[h] != Terrain.DESERT, s.numbers[h] != 0)
}
assertEquals(Terrain.SEA, s.terrains[s.sea!!.pirate])
assertTrue(s.terrains[s.robber] == Terrain.DESERT)
assertEquals(9, s.ports.size)
// The red numbers are never neighbours.
val reds = (0 until topo.HEX_COUNT).filter { s.numbers[it] == 6 || s.numbers[it] == 8 }
for (a in reds) for (b in reds) if (a < b) assertFalse(topo.hexesAdjacent(a, b))
}
}
@Test
fun `setup places both settlements on the home island, with roads rather than ships`() {
val s = start()
val moves = CatanRules.legalMoves(s, 0).filterIsInstance<SetupPlace>()
assertTrue(moves.isNotEmpty())
assertTrue(moves.all { s.topo.vertexIsland[it.vertex] == s.sea!!.home })
assertTrue(moves.all { s.topo.edgeLand[it.edge] })
}
@Test
fun `the state survives its wire format`() {
val s = playing().let { it.settle(it.coastVertex(), 0) }
assertEquals(s, CatanRules.decodeState(CatanRules.encodeState(s)))
}
// --- Ships ---------------------------------------------------------------
@Test
fun `a ship launches from a harbour town onto a sea edge and costs a lumber and a wool`() {
val s = playing().let { it.settle(it.coastVertex(), 0) }
val ships = CatanRules.legalMoves(s, 0).filterIsInstance<BuildShip>()
assertTrue(ships.isNotEmpty())
assertTrue(ships.all { s.topo.edgeSea[it.edge] })
val after = CatanRules.applyMove(s, 0, ships.first())
assertEquals(0, after.sea!!.ships.indexOfFirst { it == 0 }.let { if (it == ships.first().edge) 0 else 1 })
assertEquals(4, after.resources[0].getValue(Resource.LUMBER))
assertEquals(4, after.resources[0].getValue(Resource.WOOL))
assertEquals(5, after.resources[0].getValue(Resource.BRICK))
}
@Test
fun `no ship without the cards, and none sails out of nothing`() {
val bare = playing().let { it.settle(it.coastVertex(), 0) }
.let { it.copy(resources = it.resources.toMutableList().also { r -> r[0] = Resource.entries.associateWith { 0 } }) }
assertTrue(CatanRules.legalMoves(bare, 0).none { it is BuildShip })
// Nobody has a town or a fleet: there is nowhere to start from.
val nothing = playing()
assertTrue(CatanRules.legalMoves(nothing, 0).none { it is BuildShip })
}
@Test
fun `the pirate keeps ships off the edges round its hex`() {
val s = playing().let { it.settle(it.coastVertex(), 0) }
val ship = CatanRules.legalMoves(s, 0).filterIsInstance<BuildShip>().first()
val seaHex = s.topo.EDGE_HEXES[ship.edge].first { !s.topo.land[it] }
val blocked = s.copy(sea = s.sea!!.copy(pirate = seaHex))
assertTrue(CatanRules.legalMoves(blocked, 0).filterIsInstance<BuildShip>().none { seaHex in s.topo.EDGE_HEXES[it.edge] })
}
@Test
fun `a ship cannot sail the turn it is built, but can the next, from the open end only`() {
val s0 = playing().let { it.settle(it.coastVertex(), 0) }
val first = CatanRules.legalMoves(s0, 0).filterIsInstance<BuildShip>().first()
val s1 = CatanRules.applyMove(s0, 0, first)
assertTrue(CatanRules.legalMoves(s1, 0).none { it is MoveShip })
// Next turn (after the others pass), the lone ship is at an open end and can sail.
val s2 = s1.copy(sea = s1.sea!!.copy(builtThisTurn = emptyList(), shipMoved = false))
val sails = CatanRules.legalMoves(s2, 0).filterIsInstance<MoveShip>()
assertTrue(sails.isNotEmpty())
assertTrue(sails.all { it.from == first.edge && it.to != first.edge })
// Sailing uses the turn's one move.
val s3 = CatanRules.applyMove(s2, 0, sails.first())
assertTrue(s3.sea!!.shipMoved)
assertEquals(NO_PLAYER, s3.sea!!.ships[first.edge])
assertEquals(0, s3.sea!!.ships[sails.first().to])
assertTrue(CatanRules.legalMoves(s3, 0).none { it is MoveShip })
}
@Test
fun `a ship in the middle of a route is stuck`() {
// Town - ship A - ship B: A has the town at one end and B at the other, so only B is at an open end.
val s0 = playing().let { it.settle(it.coastVertex(), 0) }
val a = CatanRules.legalMoves(s0, 0).filterIsInstance<BuildShip>().first().edge
var s = s0.withShip(a, 0)
val far = s.topo.EDGE_VERTICES[a].toList().first { s.buildings[it] == null }
val b = s.topo.VERTEX_EDGES[far].first { it != a && s.topo.edgeSea[it] && s.roads[it] == NO_PLAYER }
s = s.withShip(b, 0)
val movers = CatanRules.legalMoves(s, 0).filterIsInstance<MoveShip>().map { it.from }.toSet()
assertEquals(setOf(b), movers)
}
@Test
fun `a settlement can rise at the end of a ship route, and the first on a new island scores the bonus`() {
val base = playing()
val topo = base.topo
val sea = base.sea!!
// A vertex on a small island, and a ship chain reaching it is too long to build by hand,
// so give the player the ship on the edge beside that vertex.
val vertex = (0 until topo.VERTEX_COUNT).first { v ->
val island = topo.vertexIsland[v]
island != -1 && island != sea.home &&
topo.VERTEX_EDGES[v].any { topo.edgeSea[it] }
}
val edge = topo.VERTEX_EDGES[vertex].first { topo.edgeSea[it] }
var s = base.withShip(edge, 0)
assertTrue(CatanRules.legalMoves(s, 0).any { it == BuildSettlement(vertex) })
val before = CatanRules.publicVictoryPoints(s)[0]
s = CatanRules.applyMove(s, 0, BuildSettlement(vertex))
assertEquals(before + 1 + SeaRules.ISLAND_BONUS, CatanRules.publicVictoryPoints(s)[0])
assertEquals(0, s.sea!!.islandBonus[topo.vertexIsland[vertex]])
// A second settlement on that island scores no second bonus.
val again = SeaRules.claimIsland(s, 1, vertex)
assertEquals(0, again.sea!!.islandBonus[topo.vertexIsland[vertex]])
}
@Test
fun `a road and a ship join a route only at a town of your own`() {
val base = playing()
val topo = base.topo
val v = base.coastVertex()
val road = topo.VERTEX_EDGES[v].first { topo.edgeLand[it] }
val ship = topo.VERTEX_EDGES[v].first { topo.edgeSea[it] && !topo.edgeLand[it] }
// With a town at the junction, road + ship is one route of two.
val withTown = base.settle(v, 0).withRoad(road, 0).withShip(ship, 0)
assertEquals(2, SeaRules.longestRouteFor(withTown, 0))
// Without it, they are two separate pieces.
val noTown = base.withRoad(road, 0).withShip(ship, 0)
assertEquals(1, SeaRules.longestRouteFor(noTown, 0))
}
// --- Pirate --------------------------------------------------------------
@Test
fun `on a seven the roller may move the robber to land or the pirate to sea, and the pirate robs a ship's owner`() {
val s0 = playing().let { it.settle(it.coastVertex(), 1) }
val ship = SeaRules.buildShipMoves(s0, 1).first().edge
val s = s0.withShip(ship, 1).copy(phase = CatanPhase.ROBBER)
val moves = CatanRules.legalMoves(s, 0)
assertTrue(moves.any { it is MoveRobber })
assertTrue(moves.any { it is MovePirate })
// Robber moves are on land only.
assertTrue(moves.filterIsInstance<MoveRobber>().all { s.topo.land[s.topo.hexIndex.getValue(it.hex)] })
assertTrue(moves.filterIsInstance<MovePirate>().all { !s.topo.land[s.topo.hexIndex.getValue(it.hex)] })
val robbery = moves.filterIsInstance<MovePirate>().first { it.victim == 1 }
val after = CatanRules.applyMove(s, 0, robbery)
assertEquals(CatanPhase.PLAYING, after.phase)
assertEquals(s.topo.hexIndex.getValue(robbery.hex), after.sea!!.pirate)
assertEquals(s.resourceCount(0) + 1, after.resourceCount(0))
assertEquals(s.resourceCount(1) - 1, after.resourceCount(1))
}
// --- Gold ----------------------------------------------------------------
@Test
fun `gold pays a resource of the owner's choice`() {
val base = playing()
val topo = base.topo
val goldHex = base.terrains.indexOf(Terrain.GOLD)
val number = base.numbers[goldHex]
val v = topo.HEX_CORNERS[goldHex].first { topo.vertexLand[it] }
// Seat 0's town beside the gold field; nothing else of theirs produces on that number.
val s = base.settle(v, 0).copy(
robber = (0 until topo.HEX_COUNT).first { topo.land[it] && it != goldHex && v !in topo.HEX_CORNERS[it] },
resources = List(3) { Resource.entries.associateWith { 0 } },
phase = CatanPhase.AWAIT_ROLL,
)
val dice = if (number <= 7) listOf(number - 1, 1) else listOf(6, number - 6)
val rolled = CatanRules.applyMove(s, 0, RollDice(forced = dice))
assertEquals(CatanPhase.GOLD, rolled.phase)
assertEquals(0, CatanRules.currentSeat(rolled))
val picks = CatanRules.legalMoves(rolled, 0)
assertEquals(Resource.entries.size, picks.size)
val after = CatanRules.applyMove(rolled, 0, PickGold(listOf(Resource.ORE)))
assertEquals(CatanPhase.PLAYING, after.phase)
assertEquals(1, after.resources[0].getValue(Resource.ORE))
}
// --- Whole games ---------------------------------------------------------
private data class Report(val seed: Long, val moves: Int, val ships: Int, val islandsTaken: Int, val points: List<Int>)
private fun playOut(options: CatanOptions, seed: Long, cap: Int = 20_000): Pair<CatanState, Report> {
val ai = CatanAi()
var state = CatanRules.initialState(config(options, seed))
var moves = 0
while (!CatanRules.isFinished(state)) {
assertTrue("seed $seed ran past $cap moves; log tail: ${state.log.takeLast(8)}", moves++ < cap)
val seat = CatanRules.currentSeat(state)
?: error("seed $seed: nobody on the clock in ${state.phase}; log tail: ${state.log.takeLast(8)}")
val legal = CatanRules.legalMoves(state, seat)
assertTrue("seed $seed: seat $seat has no legal move in ${state.phase}", legal.isNotEmpty())
val move = ai.chooseMove(state, seat, legal)
assertTrue(
"seed $seed: seat $seat chose the illegal move $move in ${state.phase}; log tail: ${state.log.takeLast(5)}",
CatanRules.isLegalMove(state, seat, move),
)
val next = CatanRules.applyMove(state, seat, move)
// The wire format must carry everything, every step.
if (moves % 25 == 0) assertEquals(next, CatanRules.decodeState(CatanRules.encodeState(next)))
state = next
}
val sea = state.sea!!
return state to Report(
seed, moves,
ships = sea.ships.count { it != NO_PLAYER },
islandsTaken = sea.islandBonus.count { it != NO_PLAYER },
points = CatanRules.publicVictoryPoints(state),
)
}
@Test
fun `engines finish Seafarers games at three and four players`() {
val reports = mutableListOf<Report>()
for ((players, seeds) in listOf(3 to listOf(1L, 2L, 3L, 4L), 4 to listOf(5L, 6L, 7L))) {
for (seed in seeds) {
val (state, report) = playOut(options(players), seed)
assertEquals("seed $seed", CatanPhase.GAME_OVER, state.phase)
val winner = state.winner
assertTrue("seed $seed finished without a winner", winner != null)
assertTrue(
"seed $seed: the winner fell short of the target",
CatanRules.victoryPoints(state, winner!!) >= state.options.pointsToWin,
)
reports += report
}
}
println("Seafarers self-play: " + reports.joinToString("; ") {
"seed ${it.seed}: ${it.moves} moves, ${it.ships} ships, ${it.islandsTaken} islands, points ${it.points}"
})
// The engines must actually go to sea in at least some of the games.
assertNotEquals("no engine ever built a ship", 0, reports.sumOf { it.ships })
}
}
@@ -0,0 +1,343 @@
package org.prolibertate.games.game.meowdoku
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
/**
* The puzzle, the generator, and the referee.
*
* The generator is where a wrong board would hurt most — a second answer makes
* the deduction unfair and the hearts a lottery — so the generation tests run
* over seeds rather than one lucky one.
*/
class MeowdokuRulesTest {
private val json = Json { encodeDefaults = true }
private fun soloConfig(options: MeowdokuOptions, seed: Long = 7L) = TableConfig(
gameId = "meowdoku",
seats = listOf(PlayerSlot(seat = 0, name = "P0", kind = PlayerKind.HUMAN_LOCAL, team = 0)),
optionsJson = json.encodeToString(options),
seed = seed,
)
private fun dealt(side: Int, seed: Long = 7L) =
MeowdokuRules.initialState(soloConfig(MeowdokuOptions(side = side), seed))
/**
* A puzzle that must exist, named so the assertions below read as facts
* rather than as unwrapping.
*/
private fun <T> required(value: T?, message: String): T {
assertNotNull(message, value)
@Suppress("UNCHECKED_CAST")
return value as T
}
// ---- generator ---------------------------------------------------------
@Test
fun `every dealt board has exactly one answer`() {
for (side in 4..8) {
for (seed in 1L..6L) {
val puzzle = required(
MeowdokuGenerator.generate(side, seed),
"no puzzle for side $side seed $seed",
)
assertEquals(
"the $side board dealt from seed $seed has more than one answer",
1,
MeowdokuGenerator.countSolutions(side, puzzle.regions),
)
}
}
}
@Test
fun `the same seed deals the same board`() {
val a = MeowdokuGenerator.generate(6, 12345L)
val b = MeowdokuGenerator.generate(6, 12345L)
assertEquals(a, b)
}
@Test
fun `the answer satisfies the cats' own rules`() {
for (seed in 0L..20L) {
val puzzle = required(MeowdokuGenerator.generate(7, seed), "no puzzle for seed $seed")
val cats = puzzle.solutionSquares()
assertEquals(7, cats.size)
assertEquals(7, cats.map { it.first }.distinct().size)
assertEquals(7, cats.map { it.second }.distinct().size)
for (i in cats.indices) {
for (j in i + 1 until cats.size) {
val (r1, c1) = cats[i]
val (r2, c2) = cats[j]
val touches = kotlin.math.abs(r1 - r2) <= 1 && kotlin.math.abs(c1 - c2) <= 1
assertFalse("cats $cats touch", touches)
assertFalse("two cats share a colour", puzzle.regionOf(r1, c1) == puzzle.regionOf(r2, c2))
}
}
}
}
@Test
fun `every colour owns exactly one cat and is contiguous`() {
val puzzle = required(MeowdokuGenerator.generate(6, 99L), "no puzzle for seed 99")
val cells = (0 until 6).flatMap { r -> (0 until 6).map { c -> r to c } }
val owned = (0 until 6).map { region ->
cells.filter { (r, c) -> puzzle.regionOf(r, c) == region }
}
owned.forEach { cellsOfRegion ->
assertTrue("an empty colour", cellsOfRegion.isNotEmpty())
val cats = cellsOfRegion.count { (r, c) -> puzzle.catCols[r] == c }
assertEquals("a colour with ${cats}cats", 1, cats)
// Contiguous: a breadth-first walk from the first cell reaches all.
val rest = cellsOfRegion.toMutableSet()
val queue = ArrayDeque(listOf(rest.first()))
val reached = mutableSetOf(queue.first())
while (queue.isNotEmpty()) {
val (r, c) = queue.removeFirst()
for ((dr, dc) in listOf(0 to 1, 0 to -1, 1 to 0, -1 to 0)) {
val next = (r + dr) to (c + dc)
if (next in rest && reached.add(next)) queue.add(next)
}
}
assertEquals("a colour in two pieces", rest.size, reached.size)
}
}
@Test
fun `the cats can always be settled`() {
// A four board is the smallest deal, and its answer is the point where
// no-touch and one-per-line first stop contradicting each other.
val rng = SeededRng(42)
val cols = MeowdokuGenerator.placeCats(4, rng)
assertEquals(4, cols.size)
// The placement the generator returns is internally consistent.
for (row in 0 until 4) {
for (earlier in 0 until row) {
assertFalse(MeowdokuGenerator.catFitsHere(row, cols[earlier], cols))
}
}
}
// ---- the referee -------------------------------------------------------
@Test
fun `the board opens empty and seated for one`() {
val state = dealt(6)
assertTrue(state.cats.isEmpty())
assertTrue(state.marks.isEmpty())
assertEquals(6, state.side)
assertEquals(3, state.heartsLeft)
assertEquals(0, state.catsFound)
assertEquals(0, MeowdokuRules.currentSeat(state))
assertFalse(MeowdokuRules.isFinished(state))
assertTrue(MeowdokuRules.legalMoves(state, 1).isEmpty())
}
@Test
fun `a correct tap lands a cat and a wrong tap costs a heart`() {
val state = dealt(6)
val square = Square(0, state.catCols[0])
val found = MeowdokuRules.applyMove(state, 0, PlaceCat(square))
assertTrue(square in found.cats)
assertEquals(3, found.heartsLeft)
val wrongRow = (0 until state.side).first { it != square.row }
val wrong = Square(wrongRow, if (state.catCols[wrongRow] == 0) 1 else 0)
val missed = MeowdokuRules.applyMove(found, 0, PlaceCat(wrong))
assertTrue(wrong in missed.marks)
assertFalse(wrong in missed.cats)
assertEquals(2, missed.heartsLeft)
assertEquals(1, missed.wrongCats)
}
@Test
fun `a board is solved by finding every cat`() {
var state = dealt(5)
for (row in 0 until 5) {
state = MeowdokuRules.applyMove(state, 0, PlaceCat(Square(row, state.catCols[row])))
}
assertTrue(state.solved)
assertTrue(MeowdokuRules.isFinished(state))
assertNull(MeowdokuRules.currentSeat(state))
assertTrue(MeowdokuRules.legalMoves(state, 0).isEmpty())
assertTrue(MeowdokuRules.summary(state).startsWith("Solved"))
}
@Test
fun `running out of hearts loses the board`() {
val state = dealt(8, seed = 3L)
var lost = state
var row = 0
while (lost.heartsLeft > 0) {
// A wrong square: not the answer's column, in a row with no cat.
val wrongCol = if (state.catCols[row] == 0) 1 else 0
val before = lost.catsFound
lost = MeowdokuRules.applyMove(lost, 0, PlaceCat(Square(row, wrongCol)))
assertEquals(before, lost.catsFound)
row = (row + 1) % state.side
}
assertTrue(lost.lost)
assertTrue(MeowdokuRules.isFinished(lost))
assertEquals(0, lost.catsFound)
assertTrue(MeowdokuRules.summary(lost).startsWith("Lost"))
}
@Test
fun `a crossed square still takes a cat attempt`() {
// The double tap is one gesture pair: the first tap crosses the square
// and the second asks it. The cross the pair drew cannot veto the
// question that follows it.
val state = dealt(6)
val truth = Square(0, state.catCols[0])
val crossed = MeowdokuRules.applyMove(state, 0, TogglePencil(truth))
assertTrue(truth in crossed.marks)
assertTrue(
"the attempt is offered over its own cross",
PlaceCat(truth) in MeowdokuRules.legalMoves(crossed, 0),
)
val placed = MeowdokuRules.applyMove(crossed, 0, PlaceCat(truth))
assertTrue(truth in placed.cats)
assertFalse("the cat replaces the cross", truth in placed.marks)
assertEquals(3, placed.heartsLeft)
assertEquals(0, placed.wrongCats)
}
@Test
fun `a wrong pair keeps its cross and spends a heart`() {
val state = dealt(6)
val wrongCol = if (state.catCols[1] == 0) 1 else 0
val wrong = Square(1, wrongCol)
val crossed = MeowdokuRules.applyMove(state, 0, TogglePencil(wrong))
val judged = MeowdokuRules.applyMove(crossed, 0, PlaceCat(wrong))
assertTrue(wrong in judged.marks)
assertFalse(wrong in judged.cats)
assertEquals(2, judged.heartsLeft)
assertEquals(1, judged.wrongCats)
}
@Test
fun `pencil marks are free notes`() {
val state = dealt(6)
val square = Square(2, if (state.catCols[2] == 5) 4 else 5)
val marked = MeowdokuRules.applyMove(state, 0, TogglePencil(square))
assertTrue(square in marked.marks)
assertEquals(3, marked.heartsLeft)
assertEquals(0, marked.wrongCats)
val erased = MeowdokuRules.applyMove(marked, 0, TogglePencil(square))
assertTrue(erased.marks.isEmpty())
}
@Test
fun `a cat square is never a pencil square`() {
val state = dealt(6)
val cat = Square(0, state.catCols[0])
val found = MeowdokuRules.applyMove(state, 0, PlaceCat(cat))
assertFalse(
"legal moves offer pencil on a cat",
MeowdokuRules.legalMoves(found, 0).any {
it is TogglePencil && it.square == cat
},
)
}
@Test
fun `a hint shows a missing cat and costs one token`() {
val state = dealt(6)
val hinted = MeowdokuRules.applyMove(state, 0, TakeHint)
assertEquals(1, hinted.catsFound)
assertEquals(2, hinted.hintsLeft)
// Out of hints the move stops being offered.
var drained = MeowdokuRules.applyMove(
MeowdokuRules.applyMove(hinted, 0, TakeHint),
0,
TakeHint,
)
assertEquals(0, drained.hintsLeft)
// A note on a square that is not a cat, so the pencil move is legal.
val note = Square(0, if (state.catCols[0] == 0) 1 else 0)
drained = MeowdokuRules.applyMove(drained, 0, TogglePencil(note))
assertFalse(TakeHint in MeowdokuRules.legalMoves(drained, 0))
}
@Test
fun `a hint over a wrong pencil reveals the cat anyway`() {
val state = dealt(6)
val truth = Square(0, state.catCols[0])
val pencilled = MeowdokuRules.applyMove(state, 0, TogglePencil(truth))
val revealed = MeowdokuRules.applyMove(pencilled, 0, TakeHint)
assertTrue(truth in revealed.cats)
assertFalse(truth in revealed.marks)
}
@Test
fun `the computer can finish any board it is handed`() {
var state = dealt(7)
var guard = 0
while (!MeowdokuRules.isFinished(state) && guard++ < 100) {
val legal = MeowdokuRules.legalMoves(state, 0)
assertFalse("stuck with cats still missing", legal.isEmpty())
state = MeowdokuRules.applyMove(state, 0, MeowdokuAi.chooseMove(state, 0, legal))
}
assertTrue("the AI did not solve the board", state.solved)
}
// ---- state across the wire and the save slot ---------------------------
@Test
fun `state and moves survive their json`() {
val state = dealt(6)
val decoded = MeowdokuRules.decodeState(MeowdokuRules.encodeState(state))
assertEquals(state, decoded)
val move: MeowdokuMove = PlaceCat(Square(1, 1))
assertEquals(move, MeowdokuRules.decodeMove(MeowdokuRules.encodeMove(move)))
assertEquals(TakeHint, MeowdokuRules.decodeMove(MeowdokuRules.encodeMove(TakeHint)))
}
@Test
fun `nothing is hidden from the only seat`() {
val state = dealt(6)
assertEquals(state, MeowdokuRules.viewFor(state, 0))
}
@Test
fun `a table with two seats is refused`() {
val config = TableConfig(
gameId = "meowdoku",
seats = listOf(
PlayerSlot(0, "P0", PlayerKind.HUMAN_LOCAL, 0),
PlayerSlot(1, "P1", PlayerKind.AI, 1),
),
optionsJson = json.encodeToString(MeowdokuOptions()),
seed = 1L,
)
assertThrows(IllegalArgumentException::class.java) {
MeowdokuRules.initialState(config)
}
}
@Test
fun `the options guard their own board`() {
assertThrows(IllegalArgumentException::class.java) { MeowdokuOptions(side = 3) }
assertThrows(IllegalArgumentException::class.java) { MeowdokuOptions(side = 11) }
assertThrows(IllegalArgumentException::class.java) { MeowdokuOptions(hearts = 0) }
}
}
@@ -0,0 +1,30 @@
package org.prolibertate.games.game.yahtzee
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class YahtzeeStatsTest {
private fun game(total: Int, bonus: Boolean = false, yahtzees: Int = 0, won: Boolean = true) =
YahtzeeRecord(total, bonus, yahtzees, zeros = 0, players = 2, won = won)
@Test
fun firstGameHasNothingToCompareTo() {
val c = compareToHistory(emptyList(), game(200))
assertEquals(1, c.rank)
assertEquals(0, c.previousGames)
assertNull(c.bestBefore)
assertNull(c.averageBefore)
}
@Test
fun ranksAgainstPreviousGames() {
val history = listOf(game(150, bonus = true, yahtzees = 1), game(250), game(200, won = false))
val c = compareToHistory(history, game(220))
assertEquals(2, c.rank)
assertEquals(4, c.gamesRanked)
assertEquals(250, c.bestBefore)
assertEquals(200.0, c.averageBefore!!, 0.001)
assertEquals(1, c.yahtzeesBefore)
}
}
@@ -145,6 +145,11 @@ class ScorekeeperSessionTest {
await("$name to be asking") { host.state.requests.any { it.id == deviceId } }
host.session.allow(deviceId)
await("$name to be let in") { guest.state.value.connected }
// The sheet follows the connection over the socket, so `connected` can
// flip while it is still in flight. Callers read the guest's sheet at
// once (its players, its rounds), and on a slow runner that was an empty
// one: wait until the guest is looking at the host's.
await("$name to receive the sheet") { guest.state.value.sheet == host.state.sheet }
return guest
}
@@ -0,0 +1,30 @@
package org.prolibertate.games.score
import org.junit.Test
import org.junit.Assert.assertEquals
class RecentNamesTest {
@Test
fun newestComesFirst() {
assertEquals(listOf("Bo", "Al"), RecentNames.with(listOf("Al"), "Bo"))
}
@Test
fun repeatMovesToFrontIgnoringCase() {
assertEquals(listOf("al", "Bo"), RecentNames.with(listOf("Bo", "Al"), " al "))
}
@Test
fun blankChangesNothing() {
assertEquals(listOf("Al"), RecentNames.with(listOf("Al"), " "))
}
@Test
fun keepsOnlyTen() {
val names = (1..10).map { "n$it" }
val result = RecentNames.with(names, "new")
assertEquals(10, result.size)
assertEquals("new", result.first())
assertEquals("n9", result.last())
}
}
@@ -199,6 +199,14 @@ class ScoreSheetTest {
assertEquals(ScoringMode.ROUNDS, StartSheet(3).applyTo(ScoreSheet()).mode)
}
@Test
fun startingWithNamesSeatsThemInOrderAndLeavesTheRestUnnamed() {
val sheet = StartSheet(3, names = listOf("Ann", "")).applyTo(ScoreSheet())
assertEquals(listOf("Ann", "", ""), sheet.players.map { it.name })
assertEquals("Ann", sheet.displayName(sheet.players[0]))
assertEquals("Player 2", sheet.displayName(sheet.players[1]))
}
@Test
fun `tally entries add up per player and can be taken back`() {
val sheet = ScoreSheet.of(2, ScoringMode.TALLY)
@@ -0,0 +1,56 @@
package org.prolibertate.games.ui.game.catan
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
import org.prolibertate.games.game.catan.BuildRoad
import org.prolibertate.games.game.catan.BuildSettlement
import org.prolibertate.games.game.catan.EndTurn
/** The pure half of the landscape HUD: drag-to-build snapping and the staged ✓ / ✗ placement. */
class CatanHudTest {
@Test
fun `a build tap is staged, not sent, but only while a build mode is armed`() {
val road = BuildRoad(0)
val outcome = TapOutcome.Submit(road)
assertEquals(road, CatanInteraction.stageable(outcome, BuildKind.ROAD))
assertNull(CatanInteraction.stageable(outcome, null))
// Ending the turn is never a placement.
assertNull(CatanInteraction.stageable(TapOutcome.Submit(EndTurn), BuildKind.ROAD))
assertNull(CatanInteraction.stageable(TapOutcome.Ignored, BuildKind.ROAD))
}
@Test
fun `the ghost of a staged move sits on its crossing or its edge`() {
assertEquals(CatanInteraction.Spot(vertex = 4), CatanInteraction.spotOf(BuildSettlement(4)))
assertEquals(CatanInteraction.Spot(edge = 9), CatanInteraction.spotOf(BuildRoad(9)))
assertEquals(CatanInteraction.Spot(), CatanInteraction.spotOf(null))
}
@Test
fun `a dragged finger snaps to the nearest lit crossing within reach, and off the board to nothing`() {
val layout = CatanGeometry.fit(800f, 600f)
val lit = setOf(3, 10, 21)
val targets = BoardTargets(vertices = lit)
val target = layout.vertex(10)
// A little off the crossing, well inside reach.
val near = Pt(target.x + layout.radius * 0.3f, target.y - layout.radius * 0.2f)
assertEquals(CatanInteraction.Spot(vertex = 10), CatanInteraction.snap(layout, targets, near, layout.radius * 1.2f))
// Far from every lit crossing.
assertNull(CatanInteraction.snap(layout, targets, Pt(-500f, -500f), layout.radius * 1.2f))
}
@Test
fun `with edges lit and no crossings, the drop snaps to an edge`() {
val layout = CatanGeometry.fit(800f, 600f)
val targets = BoardTargets(edges = setOf(5, 6))
val mid = layout.edgeMidpoint(6)
assertNotNull(CatanInteraction.snap(layout, targets, mid, layout.radius))
assertEquals(CatanInteraction.Spot(edge = 6), CatanInteraction.snap(layout, targets, mid, layout.radius))
}
}
@@ -0,0 +1,149 @@
package org.prolibertate.games.ui.game.catan
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.catan.BuildShip
import org.prolibertate.games.game.catan.BuildingType
import org.prolibertate.games.game.catan.CatanAi
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.CatanRuleSet
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.MovePirate
import org.prolibertate.games.game.catan.MoveRobber
import org.prolibertate.games.game.catan.MoveShip
import org.prolibertate.games.game.catan.PickGold
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.Terrain
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
/** What the screen offers on a Seafarers board: ships, the pirate, and gold. */
class CatanSeafarersInteractionTest {
private fun fresh(): CatanState {
val options = CatanOptions(playerCount = 3, pointsToWin = 12, ruleSet = CatanRuleSet.SEAFARERS)
return CatanRules.initialState(
TableConfig(
gameId = GameCatalog.CATAN,
seats = (0 until 3).map { PlayerSlot(it, "P$it", PlayerKind.AI, it) },
optionsJson = Json { encodeDefaults = true }.encodeToString(options),
seed = 7L,
),
)
}
/** Seat 0, mid-game, a harbour town on the coast of the home island and plenty of cards. */
private fun harbour(): CatanState {
val s = fresh().copy(
phase = CatanPhase.PLAYING,
turn = 0,
turnNumber = 1,
resources = List(3) { Resource.entries.associateWith { 5 } },
)
val topo = s.topo
val v = (0 until topo.VERTEX_COUNT).first { vertex ->
topo.vertexIsland[vertex] == s.sea!!.home &&
topo.VERTEX_EDGES[vertex].any { topo.edgeSea[it] && !topo.edgeLand[it] }
}
return s.copy(buildings = s.buildings.toMutableList().also { it[v] = CatanBuilding(BuildingType.SETTLEMENT, 0) })
}
@Test
fun `the ship is on the build menu, and arming it lights the sea edges a ship can take`() {
val s = harbour()
val legal = CatanRules.legalMoves(s, 0)
assertTrue(BuildKind.SHIP in CatanInteraction.canBuild(legal))
val targets = CatanInteraction.targets(s, legal, BuildKind.SHIP, null)
assertEquals(legal.filterIsInstance<BuildShip>().map { it.edge }.toSet(), targets.edges)
val edge = targets.edges.first { !s.topo.edgeLand[it] }
assertEquals(
TapOutcome.Submit(BuildShip(edge)),
CatanInteraction.onEdgeTap(edge, legal, BuildKind.SHIP, null),
)
// The same tap with the road armed means nothing: a road cannot lie between two sea hexes.
assertEquals(TapOutcome.Ignored, CatanInteraction.onEdgeTap(edge, legal, BuildKind.ROAD, null))
// A build tap waits for ✓ like any other piece.
assertEquals(BuildShip(edge), CatanInteraction.stageable(TapOutcome.Submit(BuildShip(edge)), BuildKind.SHIP))
}
@Test
fun `tapping a ship that can sail picks it up, and the next tap on a lit sea edge sails it`() {
val s0 = harbour()
val built = CatanRules.legalMoves(s0, 0).filterIsInstance<BuildShip>().first()
val s = CatanRules.applyMove(s0, 0, built)
.let { it.copy(sea = it.sea!!.copy(builtThisTurn = emptyList())) }
val legal = CatanRules.legalMoves(s, 0)
val idle = CatanInteraction.targets(s, legal, null, null)
assertEquals(setOf(built.edge), idle.ships)
assertEquals(TapOutcome.PickShip(built.edge), CatanInteraction.onEdgeTap(built.edge, legal, null, null))
val sailing = CatanInteraction.targets(s, legal, null, null, movingShip = built.edge)
val to = sailing.edges.first()
assertEquals(
TapOutcome.Submit(MoveShip(built.edge, to)),
CatanInteraction.onEdgeTap(to, legal, null, null, movingShip = built.edge),
)
}
@Test
fun `after a seven a land hex moves the robber and a sea hex moves the pirate`() {
val s = harbour().copy(phase = CatanPhase.ROBBER)
val legal = CatanRules.legalMoves(s, 0)
val targets = CatanInteraction.targets(s, legal, null, null)
val land = targets.hexes.first { s.terrains[it] != Terrain.SEA }
val sea = targets.hexes.first { s.terrains[it] == Terrain.SEA }
val robber = CatanInteraction.onHexTap(land, legal, topo = s.topo)
assertTrue(robber is TapOutcome.Submit && robber.move is MoveRobber)
val pirate = CatanInteraction.onHexTap(sea, legal, topo = s.topo)
assertTrue(pirate is TapOutcome.Submit && pirate.move is MovePirate)
}
@Test
fun `a gold pick is found by the cards it names`() {
val picks = listOf(PickGold(listOf(Resource.ORE)), PickGold(listOf(Resource.WOOL)))
assertEquals(PickGold(listOf(Resource.WOOL)), CatanInteraction.goldMove(picks, listOf(Resource.WOOL)))
assertEquals(null, CatanInteraction.goldMove(picks, listOf(Resource.WOOL, Resource.WOOL)))
assertEquals(1, CatanInteraction.goldCount(picks))
}
@Test
fun `the engines' own choices are always something the screen can make`() {
// Every legal move in a played-out game is reachable: a tap, a button or a dialog choice.
val ai = CatanAi()
var s = fresh()
var n = 0
while (!CatanRules.isFinished(s) && n++ < 600) {
val seat = CatanRules.currentSeat(s) ?: break
val legal = CatanRules.legalMoves(s, seat)
val ships = legal.filterIsInstance<BuildShip>()
if (s.phase == CatanPhase.PLAYING && ships.isNotEmpty()) {
val armed = CatanInteraction.targets(s, legal, BuildKind.SHIP, null)
ships.forEach { ship ->
assertTrue("ship ${ship.edge} not lit", ship.edge in armed.edges)
assertEquals(TapOutcome.Submit(ship), CatanInteraction.onEdgeTap(ship.edge, legal, BuildKind.SHIP, null))
}
}
legal.filterIsInstance<MoveShip>().forEach { sail ->
val lit = CatanInteraction.targets(s, legal, null, null, movingShip = sail.from)
assertTrue(sail.to in lit.edges)
assertEquals(
TapOutcome.Submit(sail),
CatanInteraction.onEdgeTap(sail.to, legal, null, null, movingShip = sail.from),
)
}
s = CatanRules.applyMove(s, seat, ai.chooseMove(s, seat, legal))
}
}
}
+73
View File
@@ -0,0 +1,73 @@
# Catan Universe 2.7.5 — what the reverse engineering showed
Source: `Catan+Universe_2.7.5_APKPure.xapk` (Unity, IL2CPP). Method: the Unity scenes
(`level3` is the match scene) were parsed with UnityPy for the uGUI hierarchy (names,
anchors, sizes, which objects start hidden), and `global-metadata.dat` was searched for
class, method and game-action names. Code bodies were not decompiled; behaviour below
that is not visible in a hierarchy is marked *inferred*.
The game is **landscape-only**; the canvas is laid out against a 1920×1080 reference.
## Match HUD (`InGameUI_mobile`)
| Region | Contents |
| --- | --- |
| Top | `PlayerFrames`: one frame per player (avatar, colour, VP, card count, timer), a "shuffle stats" button at the right |
| Bottom bar | `ResourceContainer`, centred (anchors 25–75 % of the width, 90 px tall): one entry per resource (Lumber, Brick, Wool, Grain, Ore; Cloth, Coins, Paper for C&K; Fish, Feather, Coca for other expansions, hidden otherwise) |
| Bottom right | `BtnTurn` (140 px, roll / end turn), `BtnTrade` immediately to its left (counter-offer and "blocked" badge variants), `BtnAddDevCard`, a chat/log `BubblePanel` |
| Left edge | `Drag&DropBuild`: a column of piece icons. **The player drags a piece from it onto the board** (`Drag&DropBuildRemove` is the drop target that cancels) |
| Mid right | `ConfirmButtonGroup`: a ✓ (`CatanConfirmBuildButton`) over a ✗ (`CatanCancelBuildButton`). A drop only *previews*; the ✓ commits |
| Board | `CandidateFactory` makes highlight markers for every legal spot (corner, edge, face); `QuickBuildTrigger{Corner,Edge,Face}` are hit zones for a one-tap/hold build; a candidate ticker shows the one-line instruction |
| Centre | Played-card panel, progress-card chooser, discard panel (`CardPanel` slides up over the bottom bar with a header) |
Gestures (`CatanGestureHandler`, `BaseGesture`, `TapHoldHandler`): one finger pans the
board, two-finger pinch zooms (`GetPinchZoomMagnitude`, `AutomaticZoom`), tap selects a
candidate, hold opens quick-build. Camera is a fixed-pitch table view.
## Trade board (`TradeBoard_mobile`)
A full-width panel that slides up over the bottom of the screen (`Animator`, collapsible):
```
┌────────────────────────────────────────────────────────────┬──────┐
│ [Player] [Bank] headline / description ☐ Block trade │ P1 P2│ header
├────────────────────────────────────────────────────────────┤ P3 P4│ (135 px)
│ OPPONENT row — cards you get (tinted by the opponent's │ │
│ colour) │ Hide │
│ ⇣⇣⇣ arrows (or the bank ratio chips 4:1 3:1 2:1) │ Deny │
│ PLAYER row — cards you give (tinted by your colour) │Accept│
└────────────────────────────────────────────────────────────┴──────┘
```
* **Header.** A *Player / Bank* toggle (extra toggles for the Inka 3:2 / 2:1 rules). A
**Block trade** checkbox refuses all incoming offers. One round button per rival
(`Player1…5`, with a selected border) picks whom the offer is addressed to; none
selected means everyone.
* **Content.** Two rows of resource cards separated by arrows. The top row is what you
*want*, the bottom row what you *give*. Tapping a card in a row adds one; tapping a
selected card removes one (*inferred from `AddFieldToTradePayment`/`ApplyCancelTradeResource`*).
In Bank mode a ratio row (`CardRatios`) shows the best rate per resource (harbours).
* **Right column (180 px).** Hide/collapse, **Deny** (✗), **Accept** (✓). When you
are the one receiving an offer, ✓ accepts and the chip-icon variant `Icon_CounterOffer`
turns the ✓ into a *counter-offer* send. A circular turn timer wraps the ✓.
* **Incoming offers** (`IncomingTradeContainer`) show in the same panel; the offerer's
colour tints the opponent row. Counter-offers land back in the proposer's panel with
an `AcceptCounterOfferButtonImage`.
## Seafarers game actions (from the metadata)
`CatanSeafarer_*_GameAction` names show what the mode adds to the base game:
* **Ships:** `BuildShip`, `MoveShip` (only the ship at the open end of a route, not on
the turn it was built), `BuildFreeRoad`; roads and ships share `AvailableCandidatesRoadAndShip`.
* **Pirate** alongside the robber: `RobberPirat_Move`, `RobberPirat_SelectPlayer`;
ships cannot be built or moved past a pirate-blocked edge.
* **Gold fields:** `ChooseGoldfieldResource` (a hex that pays any resource of choice).
* **Fog islands:** `Check_FogField`, `DistributeFogField` (hidden hex revealed when a
road/ship reaches it, the player takes the resource), `Shuffle_Islands`, `Shuffle_TileValueList`.
* **Treasure islands / Oasis / Great Canal** are Catan Universe scenario extras and are
**not** part of the physical Seafarers rules; they are out of scope here.
* **Setup:** `StartPhase_BuildSettlement`, `StartPhase_BuildRoadShip`, `PlaceValueChip`
(number tokens placed per scenario), `Shuffle_Harbors`.
* **Island bonus:** special victory points for the first settlement on a new island
(`AllIslandIds`, `CatanIslandState`).
+2 -1
View File
@@ -24,9 +24,10 @@ Naming: the catalogue id, lowercase, e.g. `crazy8s` → `RULES-crazy8s.md`.
| Golf | [RULES-golf.md](RULES-golf.md) |
| Hearts | — (TODO) |
| Kaiser | [RULES-kaiser.md](RULES-kaiser.md) |
| Katan (catalogue id `catan`) | [RULES-catan.md](RULES-catan.md) — and its expansion rule set, [RULES-catan-ck.md](RULES-catan-ck.md) |
| Katan (catalogue id `catan`) | [RULES-catan.md](RULES-catan.md) — and its expansion rule sets, [RULES-catan-ck.md](RULES-catan-ck.md) and [RULES-catan-seafarers.md](RULES-catan-seafarers.md) |
| Klondike | — (TODO) |
| Mastermind | [RULES-mastermind.md](RULES-mastermind.md) |
| Meowdoku | [RULES-meowdoku.md](RULES-meowdoku.md) |
| Morris | [RULES-morris.md](RULES-morris.md) |
| Peg solitaire | — (TODO) |
| Pirates | [RULES-pirates.md](RULES-pirates.md) |
+71
View File
@@ -0,0 +1,71 @@
# Katan: Seafarers
The Seafarers rule set is chosen at setup next to *Base game* and *Cities & Knights*. It is the base
game ([RULES-catan.md](RULES-catan.md)) with a sea, ships, a pirate, gold fields and islands to
settle. Code: `game/catan/CatanSeaRules.kt` (the additions), `CatanSeaBoard.kt` (the map),
`CatanBoard.kt` (`Topology`, the geometry shared by every map), state behind `CatanState.sea`
(null in the other rule sets). It cannot be combined with Cities & Knights.
Where the published rules leave room, or where this game differs, it says so below.
## The map
One map, *Heading for New Shores*: a large **home island** (21 hexes, one desert) and three small
islands (3, 3 and 2 hexes) across the water, inside a ring of sea. Which land hexes are gold, which
are the desert and where the sea lies is fixed; the **resources on the other hexes, the number tokens
and the nine harbours are shuffled** for each game. The red numbers (6, 8) are never on neighbouring
hexes. Players take three or four seats; the default target is **12 points** (menu: 8, 10, 12, 14).
The geometry is the same lattice as the base island, generalised (`Topology`): a hex is land or sea,
a crossing can hold a town if any hex touching it is land, an edge can hold a road if any hex touching
it is land and a ship if any hex touching it is sea (so a coastal edge takes either, never both).
## What changes
**Setup.** Both starting settlements (and their roads) go on the home island. A starting piece is a
road, never a ship. The second settlement collects from the hexes round it as usual (gold pays nothing
at setup).
**Ships.** A ship costs 1 lumber and 1 wool and lies on a sea edge.
* It must join your fleet or a town of yours: one end of the edge touches your town, or another of your ships.
An opponent's town is a wall.
* Roads and ships connect **only at a town of your own**: a road cannot start from a ship's end, nor a
ship from a road's, unless a settlement or city of yours stands on that crossing. A settlement may be
built at the end of a ship route exactly as at the end of a road route.
* No more than 15 ships. Road Building may lay two roads, two ships or one of each.
* **Sailing.** Once per turn you may move one ship, never one built or moved this turn, and only a ship at the
**open end** of a route (one end touches nothing else of yours). It goes to any edge it could be built on
with it lifted off.
**The pirate.** Starts in the open sea. On a seven (and with a Knight card) the player moves either the
robber, to any land hex, or the pirate, to any sea hex. The robber works as before. The pirate **blocks the six
edges round its hex**: no ship may be built or sail onto them (ships already there stay). It robs one card
at random from a player with a ship on an edge of its hex.
**Gold.** A gold field has a number like any hex. When it comes up, each town beside it (one card; a city two)
pays its owner **any resource of their choice**, chosen after the ordinary production is dealt, in seat order from
the roller. The robber blocks a gold field like any hex. If the bank cannot supply the choice it is limited to
what it holds.
**New islands.** The first player to settle on each island other than the home island scores **2 bonus points**,
permanently (`SeaState.islandBonus`). A second settlement there earns nothing extra.
**Longest route.** Longest Road counts roads and ships together, as a path of your pieces that never runs
through an opponent's town and changes between road and ship only at a town of yours. Five or more, as before.
Everything else (development cards, trading, harbours, Largest Army, the discard on a seven) is the base game.
## Not included
The physical expansion has more scenarios and the Catan Universe app adds more still. These are **out of scope here**:
fog islands, treasure islands, the oasis, the great canal, the Explorers & Pirates and Cities & Knights combinations,
the scenarios after the first, and trade ships. The `SeaScenario` enum and `SeaBoards` are built so a second map
is a new text map and a new enum entry.
## How the AI plays it
It settles first and sails second. A ship is built when a settlement spot reachable by sea (up to five steps from its
open end) is worth having, with a bonus for an island nobody has landed on, and ahead of a development card when that
bonus is in play. It chooses the resource it values most from gold, always moves the robber (never the pirate) and does
not sail ships it has already launched. See `CatanAi.shipValue`.
+98
View File
@@ -0,0 +1,98 @@
# Meowdoku
A board of colours, and a cat in every row, column and colour — no two sharing
even a corner.
## The puzzle
The board is **N by N** and coloured into **N regions**. Exactly one cat hides
in every row, every column and every region, and no two cats may touch — not
along a side and not at a corner, so a cat rules out all eight squares around
itself. Those four constraints together always leave exactly one arrangement.
Uniqueness is not decoration. The game is deduction, and a second answer means
a square the player cannot prove either way. Every board is therefore generated
and then searched: build the colours, count the ways a board can be answered,
and if it is more than one, throw the board away and deal another. The count is
a plain depth-first search over the rows with the obvious exclusions propagated,
and it sees only the colours and the rules — never the cats it was grown from.
## Playing it
Controls are the original's, and they carry the game's whole psychology:
- **One tap crosses a square over.** Free, instant, erasable — the note you
make while thinking.
- **A second tap on the same square within 350 ms is the cat attempt.** The
board judges it against the answer it was dealt: correct, the cross is
replaced by a cat; wrong, the cross stays and a heart goes. This is why a
guess feels like a decision: your own note has to survive one more tap
before it becomes a bet.
- **Drag to cross a line.** The stroke paints the same note every square it
enters: start on a blank square and it crosses the row or column you drag
along; start on a crossed square and the stroke erases them instead. Cats
are skipped — placed cats are permanent.
A square you pencilled over can still be tapped for a cat, and the cross is
forgiven: the attempt replaces it if it is right. The one thing no gesture
undoes is a cat once placed.
The original ships a fifth gesture — a **draft** scratchpad mode, a tool you
buy, where crosses and cats are laid down without judgement and applied all at
once — and it is left out here with the rest of the shop: a scratchpad whose
notes cost nothing is what tapping already is.
A **Hint** spends a token and shows the cat in the first row that still lacks
one, replacing any cross drawn over it. The original sells these as tools; here
they are simply counted, three per board by default, which keeps the hint a
decision rather than a reflex.
## The heart
Why a wrong tap costs anything at all: the puzzle is only hard if guessing is.
A board with no penalty is a board you can solve by tapping every square, and
the hearts are what make the deduction on a colour you have not reasoned about
yet feel like a choice. Three is the number the original deals, and it stays
three by default.
## Options
| | |
| --- | --- |
| **Board** | 4×4 up to 10×10; the number of cats equals the side |
| **Hearts** | 1 to 5, default 3 |
| **Hints** | 0 to 3, default 3 |
Boards are dealt from the table's seed, so a saved game resumes to the same
cats in the same colours, and **New puzzle** moves the seed to deal a different
board. Smaller sides are gentler: a 4×4 is the shortest way to learn what
no-touch does to a corner, and the ten-wide boards are for people who have
already made that mistake.
## How a board is dealt
The order matters, and it is the order the original game uses:
1. **Settle the cats first.** One per row, one per column, no two touching,
chosen by backtracking over shuffled column orders.
2. **Grow the colours around them.** Each region starts as its cat's square and
expands outwards one square at a time; some regions are grown as straight
bars along the cat's row or column, which is what stops the board reading as
texture, and the rest take whichever neighbour is smallest so nothing
sprawls while another dies young.
3. **Check the answer.** Count solutions up to two; keep the board only when
the count comes back exactly one.
The generator's rejection loop is why the same seed can take a few attempts:
most boards are unique on the first try, and the odd seed has to walk until it
finds one.
## Where this engine differs from the original
The app is a game collection, not a live-service puzzle, so what sits around
the original's gameplay is left behind: lives-as-monetisation (watching an ad
for a heart), daily streaks and leaderboards, an Elo-matched bank of
hand-authored levels, and the cosmetic layer (cat animations, combo feedback,
confetti). This engine **deals its boards from a seed** instead of shipping a
level bank, and the rules are the part worth keeping: the cats, the colours,
the no-touch, the hearts, the one answer.
+39
View File
@@ -22,6 +22,8 @@ truth, so the UI can only ever offer a move the rules would accept.
| **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 |
| **HUD** | `CatanHud.kt` | The landscape controls: build column, ✓ / ✗, round turn / trade buttons, pinch-pan board | Move or restyle a landscape control |
| **Trade board** | `CatanTradeBoard.kt` | The base-game trade panel (offer, bank, reply) | Change how trades are composed or answered |
| **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 |
@@ -169,3 +171,40 @@ Not done yet, in rough priority order:
4. A timer or cancel button on offers (the engine has neither).
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.
## Landscape HUD and trade board (from Catan Universe)
Landscape screens use the control scheme of Catan Universe's match view, redrawn in 2D
(what was learned, and from where: [RE-catan-universe.md](RE-catan-universe.md)). Portrait keeps the stacked layout.
* **Drag to build.** The left column holds Road / Settlement / City (and Wall / Knight in
Cities & Knights). Drag a piece onto the island: lit spots snap under the finger
(`CatanInteraction.snap`), release over one to **stage** it, then press **✓** to place or **✕**
to cancel (`CatanInteraction.stageable`, `spotOf`). A tap on a piece still arms the board for
tapping a spot. Setup placements, free roads and chores are not staged: they commit on tap.
* **Pinch and pan** the island (`ZoomPanBox`). The board is drawn through a graphics layer, so
taps and drops arrive in board coordinates; hit-testing is unchanged.
* **Round buttons**, bottom right: the big one is roll / end turn / done (whatever the engine
currently allows); ⇄ is trade; the small pair is buy / play card (Improve / Cards in C&K).
* **Trade board** (`CatanTradeBoard`, base game): a panel over the bottom of the island with a
Bank / Player toggle, a round button per rival to address an offer to one player, a *get* row on
top, a *give* row below, and ▾ hide / ✕ clear / ✓ send on the right. Bank mode shows each resource's
ratio under the *give* card. An offer made to you opens the same panel read-only: ✓ accepts,
✕ declines, *Counter…* makes the rows editable.
Cities & Knights goods trades and the counter-offer chooser still use dialogs.
## Seafarers on the screen
| Layer | What it adds |
| --- | --- |
| `CatanGeometry.kt` | `BoardLayout` and `CatanGeometry.fit` take the game's `Topology`, so a map of islands fits the box like the standard island does |
| `CatanStyle.kt` | gold-field colour, the pirate's and the ship sail's colours |
| `CatanBoardView.kt` | sea hexes (a faint grid, no fill), ships (hull with a sail), the pirate (a flagged hull), a ring on ships that can sail |
| `CatanInteraction.kt` | `BuildKind.SHIP`; `PickShip`, `movingShip`, ship targets; the robber's and the pirate's hexes; `goldMove` |
| `CatanDialogs.kt` | `CatanGoldDialog`, the choice of what a gold field pays |
| `CatanScreen.kt` | the picked-up ship, the ship on the build menu / column |
**Interaction model.** Arm *Ship* (or drag it from the build column) and tap a lit sea edge: ✓ places it. To sail,
tap one of your ships that has a ring, then a lit sea edge. After a seven, tap a land hex for the robber or a sea
hex for the pirate. A gold field opens a dialog that cannot be dismissed. `CatanSeafarersInteractionTest` checks
that every build and sail the engine offers has a tap that produces it.