Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68cd799f89 | ||
|
|
6c549a8b76 | ||
|
|
ebab7a8415 | ||
|
|
a0b15b3140 | ||
|
|
8990dde363 | ||
|
|
c2b21a07f5 | ||
|
|
e98afbfd8d | ||
|
|
2c90de66d1 | ||
|
|
f93eba88e5 | ||
|
|
36ab518309 | ||
|
|
77cc6c5a05 | ||
|
|
166f85734b | ||
|
|
25e38c4bf8 | ||
|
|
6bf7522042 | ||
|
|
7942adeaf7 | ||
|
|
401ebe0118 | ||
|
|
3c0469fc81 | ||
|
|
5d36b7bd62 | ||
|
|
b5b4afa731 | ||
|
|
f88099f4e2 | ||
|
|
488f600f15 | ||
|
|
9b938ffd8b | ||
|
|
ccf8d5a1ba | ||
|
|
a00df43a44 | ||
|
|
fae031d2b5 | ||
|
|
d568ff7750 | ||
|
|
2e66567643 |
@@ -52,9 +52,15 @@ integration:
|
|||||||
## Katan
|
## Katan
|
||||||
|
|
||||||
The island game is catalogue id `catan`, shown as **Katan**. It has two rule
|
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
|
(`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.
|
`docs/UI-catan.md` before changing it.
|
||||||
|
|
||||||
## Bash output is filtered before it reaches the model
|
## 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.net.ScorekeeperSession
|
||||||
import org.prolibertate.games.score.ScorekeeperRepository
|
import org.prolibertate.games.score.ScorekeeperRepository
|
||||||
import org.prolibertate.games.settings.SettingsRepository
|
import org.prolibertate.games.settings.SettingsRepository
|
||||||
|
import org.prolibertate.games.settings.YahtzeeStatsRepository
|
||||||
import org.prolibertate.games.ui.AppEnv
|
import org.prolibertate.games.ui.AppEnv
|
||||||
import org.prolibertate.games.ui.AppRoot
|
import org.prolibertate.games.ui.AppRoot
|
||||||
import org.prolibertate.games.ui.theme.ProLibertateTheme
|
import org.prolibertate.games.ui.theme.ProLibertateTheme
|
||||||
@@ -43,6 +44,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
),
|
),
|
||||||
peerId = peerId,
|
peerId = peerId,
|
||||||
saves = SavedGameStore(File(filesDir, "saved-games")),
|
saves = SavedGameStore(File(filesDir, "saved-games")),
|
||||||
|
yahtzeeStats = YahtzeeStatsRepository(applicationContext),
|
||||||
)
|
)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ object GameCatalog {
|
|||||||
const val TAYU = "tayu"
|
const val TAYU = "tayu"
|
||||||
const val MORRIS = "morris"
|
const val MORRIS = "morris"
|
||||||
const val PIRATES = "pirates"
|
const val PIRATES = "pirates"
|
||||||
|
const val MEOWDOKU = "meowdoku"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The island game the collection is named for. Listed under Board Games
|
* The island game the collection is named for. Listed under Board Games
|
||||||
@@ -361,12 +362,18 @@ object GameCatalog {
|
|||||||
maxPlayers = 4,
|
maxPlayers = 4,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
blurb = "Trade, build, and settle the island, in the base game or Cities & Knights.",
|
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
|
available = true,
|
||||||
// devices yet. Dev builds can open it as a *Preview*; a production
|
),
|
||||||
// build does not list it. Flip `available` on, and `devPreview`
|
GameDescriptor(
|
||||||
// off, once two-device play has been verified.
|
id = MEOWDOKU,
|
||||||
available = false,
|
title = "Meowdoku",
|
||||||
devPreview = true,
|
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 }
|
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.DISCARDING -> chooseDiscard(state, seat, legal.filterIsInstance<Discard>())
|
||||||
|
|
||||||
CatanPhase.ROBBER -> chooseRobber(state, seat, legal.filterIsInstance<MoveRobber>())
|
CatanPhase.ROBBER -> chooseRobber(state, seat, legal.filterIsInstance<MoveRobber>())
|
||||||
@@ -70,9 +75,10 @@ class CatanAi : GameAi<CatanState, CatanMove> {
|
|||||||
* worth is what it reaches past it.
|
* worth is what it reaches past it.
|
||||||
*/
|
*/
|
||||||
private fun placeDirection(state: CatanState, place: SetupPlace): Double {
|
private fun placeDirection(state: CatanState, place: SetupPlace): Double {
|
||||||
val far = CatanBoard.otherEnd(place.edge, place.vertex)
|
val topo = state.topo
|
||||||
return CatanBoard.VERTEX_NEIGHBORS[far]
|
val far = topo.otherEnd(place.edge, place.vertex)
|
||||||
.filter { it != place.vertex && it !in CatanBoard.VERTEX_NEIGHBORS[place.vertex] }
|
return topo.VERTEX_NEIGHBORS[far]
|
||||||
|
.filter { it != place.vertex && it !in topo.VERTEX_NEIGHBORS[place.vertex] }
|
||||||
.maxOfOrNull { spotValue(state, it) }
|
.maxOfOrNull { spotValue(state, it) }
|
||||||
?: 0.0
|
?: 0.0
|
||||||
}
|
}
|
||||||
@@ -94,9 +100,9 @@ class CatanAi : GameAi<CatanState, CatanMove> {
|
|||||||
require(moves.isNotEmpty()) { "No legal robber move" }
|
require(moves.isNotEmpty()) { "No legal robber move" }
|
||||||
val points = CatanRules.publicVictoryPoints(state)
|
val points = CatanRules.publicVictoryPoints(state)
|
||||||
return moves.maxByOrNull { move ->
|
return moves.maxByOrNull { move ->
|
||||||
val hex = CatanBoard.hexIndex.getValue(move.hex)
|
val hex = state.topo.hexIndex.getValue(move.hex)
|
||||||
var score = 0.0
|
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
|
val building = state.buildings[v] ?: continue
|
||||||
if (building.owner == seat) continue
|
if (building.owner == seat) continue
|
||||||
val worth = if (building.type == BuildingType.CITY) 2 else 1
|
val worth = if (building.type == BuildingType.CITY) 2 else 1
|
||||||
@@ -124,6 +130,12 @@ class CatanAi : GameAi<CatanState, CatanMove> {
|
|||||||
spotValue(state, it.vertex)
|
spotValue(state, it.vertex)
|
||||||
}?.let { return it }
|
}?.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
|
// A knight is worth playing whenever one is playable: it blocks an
|
||||||
// opponent's best hex and marches toward Largest Army.
|
// opponent's best hex and marches toward Largest Army.
|
||||||
legal.filterIsInstance<PlayKnight>().firstOrNull()?.let { return it }
|
legal.filterIsInstance<PlayKnight>().firstOrNull()?.let { return it }
|
||||||
@@ -142,6 +154,12 @@ class CatanAi : GameAi<CatanState, CatanMove> {
|
|||||||
|
|
||||||
legal.filterIsInstance<BuyDevCard>().firstOrNull()?.let { return it }
|
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.
|
// A paid road only when it reaches toward a spot worth settling.
|
||||||
legal.filterIsInstance<BuildRoad>()
|
legal.filterIsInstance<BuildRoad>()
|
||||||
.filter { roadValue(state, it) > 0.1 }
|
.filter { roadValue(state, it) > 0.1 }
|
||||||
@@ -301,13 +319,13 @@ class CatanAi : GameAi<CatanState, CatanMove> {
|
|||||||
if (state.buildings[vertex] != null) return -1.0
|
if (state.buildings[vertex] != null) return -1.0
|
||||||
var value = 0.0
|
var value = 0.0
|
||||||
val kinds = mutableSetOf<Resource>()
|
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
|
val resource = state.terrains[h].produces ?: continue
|
||||||
value += CatanBoard.numberOdds(state.numbers[h]) / 36.0 * want(resource)
|
value += CatanBoard.numberOdds(state.numbers[h]) / 36.0 * want(resource)
|
||||||
kinds += resource
|
kinds += resource
|
||||||
}
|
}
|
||||||
value += 0.12 * kinds.size // variety keeps a build plan alive
|
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
|
val port = state.ports.firstOrNull { it.edge == e } ?: continue
|
||||||
value += if (port.resource == null) 0.1 else 0.2
|
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 {
|
internal fun roadValue(state: CatanState, move: BuildRoad): Double {
|
||||||
var best = 0.0
|
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 (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)
|
best = maxOf(best, spotValue(state, near) * 0.8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return best
|
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]. */
|
/** How much the AI wants to hold one card of [resource]. */
|
||||||
internal fun want(resource: Resource): Double = when (resource) {
|
internal fun want(resource: Resource): Double = when (resource) {
|
||||||
Resource.ORE -> 1.20 // the city
|
Resource.ORE -> 1.20 // the city
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import kotlin.math.atan2
|
|||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The island as geometry: the 19 hexes, the 54 shared vertices, the 72
|
* The geometry of one board: its hexes, the shared vertices and edges between
|
||||||
* shared edges, the coast and the nine harbour positions.
|
* them, and which hexes are land.
|
||||||
*
|
*
|
||||||
* Everything is derived once, deterministically, from axial coordinates —
|
* Everything is derived once, deterministically, from axial coordinates —
|
||||||
* there is no randomness and no state here; the layout of a particular
|
* 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
|
* game (which terrain and number sits on which hex) is generated elsewhere
|
||||||
* live state lives in [CatanState].
|
* ([CatanBoard.generate], [SeaBoards.generate]), and the live state lives in
|
||||||
|
* [CatanState].
|
||||||
*
|
*
|
||||||
* Coordinates: hexes are addressed axially ((q, r), pointy-top). Every
|
* Coordinates: hexes are addressed axially ((q, r), pointy-top). Every
|
||||||
* position is kept on an exact integer lattice so vertices shared by
|
* 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
|
* six corners follow the offsets in [CORNER_OFFSETS]. Vertex positions are
|
||||||
* in half-units of the hex grid; rendering scales and flips them (lattice
|
* in half-units of the hex grid; rendering scales and flips them (lattice
|
||||||
* +Y is toward the top of the board).
|
* +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 {
|
class Topology(
|
||||||
|
/** Every hex of the frame, land and sea alike. */
|
||||||
/** The 19 hexes: every (q, r) within distance 2 of the centre. */
|
val HEXES: List<HexCoord>,
|
||||||
val HEXES: List<HexCoord> = buildList {
|
/** Whether each hex is land. The base island is all land; Seafarers maps are mostly sea. */
|
||||||
for (r in -2..2) {
|
val land: List<Boolean> = List(HEXES.size) { true },
|
||||||
for (q in -2..2) {
|
/** The standard 19-hex island: checks its counts and uses the fixed nine-harbour frame. */
|
||||||
if (hexDistance(q, r, 0, 0) <= 2) add(HexCoord(q, r))
|
private val standardFrame: Boolean = false,
|
||||||
}
|
/** Harbours wanted on a non-standard map. */
|
||||||
}
|
harbours: Int = 0,
|
||||||
}
|
) {
|
||||||
|
|
||||||
val HEX_COUNT: Int = HEXES.size
|
val HEX_COUNT: Int = HEXES.size
|
||||||
|
|
||||||
val hexIndex: Map<HexCoord, Int> = HEXES.withIndex().associate { (i, hex) -> hex to i }
|
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
|
// Vertices and edges
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -93,9 +78,28 @@ object CatanBoard {
|
|||||||
/** The coastal edges (those with only one hex) in ring order. */
|
/** The coastal edges (those with only one hex) in ring order. */
|
||||||
val COASTAL: List<Int>
|
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>
|
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 {
|
init {
|
||||||
// Vertices: assign ids in first-seen order over hexes then corners.
|
// Vertices: assign ids in first-seen order over hexes then corners.
|
||||||
val vertexIds = mutableMapOf<Pair<Int, Int>, Int>()
|
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
|
// The coast in walking order: sort the coastal edges by the angle of
|
||||||
// their midpoint around the board centre, which is the lattice
|
// 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()
|
COASTAL = EDGE_HEXES.withIndex()
|
||||||
.filter { it.value.size == 1 }
|
.filter { it.value.size == 1 }
|
||||||
.map { (e, _) -> e }
|
.map { (e, _) -> e }
|
||||||
.sortedBy { e -> angleOf(midpoint(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" }
|
vertexLand = List(VERTEX_COUNT) { v -> VERTEX_HEXES[v].any { land[it] } }
|
||||||
check(VERTEX_COUNT == 54) { "A Catan island has 54 vertices, found $VERTEX_COUNT" }
|
edgeLand = List(EDGE_COUNT) { e -> EDGE_HEXES[e].any { land[it] } }
|
||||||
check(EDGE_COUNT == 72) { "A Catan island has 72 edges, found $EDGE_COUNT" }
|
edgeSea = List(EDGE_COUNT) { e -> EDGE_HEXES[e].any { !land[it] } }
|
||||||
check(COASTAL.size == 30) { "The coast has 30 edges, found ${COASTAL.size}" }
|
|
||||||
check(PORT_POSITIONS.size == 9) { "Nine harbours, found ${PORT_POSITIONS.size}" }
|
// Islands: connected groups of land hexes.
|
||||||
check(PORT_POSITIONS.all { EDGE_HEXES[it].size == 1 }) { "Harbours must be coastal" }
|
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. */
|
/** The other endpoint of [edge], given one [vertex] on it. */
|
||||||
@@ -207,19 +283,14 @@ object CatanBoard {
|
|||||||
/** Whether two hexes share an edge. */
|
/** Whether two hexes share an edge. */
|
||||||
fun hexesAdjacent(a: Int, b: Int): Boolean = b in HEX_NEIGHBORS[a]
|
fun hexesAdjacent(a: Int, b: Int): Boolean = b in HEX_NEIGHBORS[a]
|
||||||
|
|
||||||
/** Hex-grid distance between two axial positions. */
|
/** Lattice extent of the whole frame: min x, max x, min y, max y. */
|
||||||
fun hexDistance(q1: Int, r1: Int, q2: Int, r2: Int): Int {
|
val lattice: IntArray by lazy {
|
||||||
val dq = q1 - q2
|
intArrayOf(
|
||||||
val dr = r1 - r2
|
VERTEX_POS.minOf { it.first }, VERTEX_POS.maxOf { it.first },
|
||||||
return maxOf(kotlin.math.abs(dq), kotlin.math.abs(dr), kotlin.math.abs(dq + dr))
|
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> {
|
private fun midpoint(edge: Int): Pair<Double, Double> {
|
||||||
val (a, b) = EDGE_VERTICES[edge]
|
val (a, b) = EDGE_VERTICES[edge]
|
||||||
val pa = VERTEX_POS[a]
|
val pa = VERTEX_POS[a]
|
||||||
@@ -230,6 +301,84 @@ object CatanBoard {
|
|||||||
private fun angleOf(point: Pair<Double, Double>): Double =
|
private fun angleOf(point: Pair<Double, Double>): Double =
|
||||||
atan2(point.second, point.first)
|
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
|
// Island generation
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -273,6 +273,9 @@ object CatanCk {
|
|||||||
CatanPhase.FREE_ROADS ->
|
CatanPhase.FREE_ROADS ->
|
||||||
CatanRules.roadMoves(state, seat) + listOf<CatanMove>(EndFreeRoads)
|
CatanRules.roadMoves(state, seat) + listOf<CatanMove>(EndFreeRoads)
|
||||||
|
|
||||||
|
// Seafarers is never combined with Cities & Knights.
|
||||||
|
CatanPhase.GOLD -> emptyList()
|
||||||
|
|
||||||
CatanPhase.TRADE_PENDING -> {
|
CatanPhase.TRADE_PENDING -> {
|
||||||
val offer = state.ck!!.goodsOffer
|
val offer = state.ck!!.goodsOffer
|
||||||
if (offer == null) {
|
if (offer == null) {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ internal class CatanCkAi(private val ai: CatanAi) {
|
|||||||
|
|
||||||
CatanPhase.PLAYING -> choosePlaying(state, seat, legal)
|
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}")
|
?: error("Nothing to choose in phase ${state.phase}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ enum class Terrain(val produces: Resource?) {
|
|||||||
HILLS(Resource.BRICK),
|
HILLS(Resource.BRICK),
|
||||||
MOUNTAINS(Resource.ORE),
|
MOUNTAINS(Resource.ORE),
|
||||||
DESERT(null),
|
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]. */
|
/** 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. */
|
/** The player whose turn it is must roll the dice. */
|
||||||
AWAIT_ROLL,
|
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. */
|
/** Players holding more than 7 resource cards discard half. */
|
||||||
DISCARDING,
|
DISCARDING,
|
||||||
|
|
||||||
@@ -150,6 +159,9 @@ enum class CatanRuleSet {
|
|||||||
|
|
||||||
/** Cities & Knights: commodities, knights, barbarians, city improvements. */
|
/** Cities & Knights: commodities, knights, barbarians, city improvements. */
|
||||||
CITIES_AND_KNIGHTS,
|
CITIES_AND_KNIGHTS,
|
||||||
|
|
||||||
|
/** Seafarers: ships, a pirate, gold fields and islands to settle (see `docs/RULES-catan-seafarers.md`). */
|
||||||
|
SEAFARERS,
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -230,9 +242,17 @@ data class CatanState(
|
|||||||
val log: List<String>,
|
val log: List<String>,
|
||||||
/** Everything Cities & Knights adds; null in the base game. */
|
/** Everything Cities & Knights adds; null in the base game. */
|
||||||
val ck: CkState? = null,
|
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
|
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. */
|
/** Whether this table plays Cities & Knights. */
|
||||||
val isCk: Boolean get() = ck != null
|
val isCk: Boolean get() = ck != null
|
||||||
|
|
||||||
@@ -377,3 +397,48 @@ data object EndTurn : CatanMove
|
|||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("endFreeRoads")
|
@SerialName("endFreeRoads")
|
||||||
data object EndFreeRoads : CatanMove
|
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"
|
"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
|
// A different random stream than the island, so the same seed still
|
||||||
// produces both a fixed island and a fixed deck.
|
// produces both a fixed island and a fixed deck.
|
||||||
val ck = options.ruleSet == CatanRuleSet.CITIES_AND_KNIGHTS
|
val ck = options.ruleSet == CatanRuleSet.CITIES_AND_KNIGHTS
|
||||||
@@ -79,8 +84,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
numbers = board.numbers,
|
numbers = board.numbers,
|
||||||
robber = board.robber,
|
robber = board.robber,
|
||||||
ports = board.ports,
|
ports = board.ports,
|
||||||
buildings = List(CatanBoard.VERTEX_COUNT) { null },
|
buildings = List(topo.VERTEX_COUNT) { null },
|
||||||
roads = List(CatanBoard.EDGE_COUNT) { NO_PLAYER },
|
roads = List(topo.EDGE_COUNT) { NO_PLAYER },
|
||||||
resources = List(options.playerCount) {
|
resources = List(options.playerCount) {
|
||||||
Resource.entries.associateWith { 0 }
|
Resource.entries.associateWith { 0 }
|
||||||
},
|
},
|
||||||
@@ -98,10 +103,19 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
rngStep = 0,
|
rngStep = 0,
|
||||||
winner = null,
|
winner = null,
|
||||||
log = listOf(
|
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.",
|
"first to ${options.pointsToWin} victory points.",
|
||||||
),
|
),
|
||||||
ck = if (ck) CatanCk.initial(options, config.seed) else null,
|
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 }
|
?: state.ck?.goodsOffer?.asking?.takeIf { it >= 0 }
|
||||||
CatanPhase.TRADE_COUNTERS -> state.turn
|
CatanPhase.TRADE_COUNTERS -> state.turn
|
||||||
CatanPhase.CHORE -> state.ck?.chores?.firstOrNull()?.seat
|
CatanPhase.CHORE -> state.ck?.chores?.firstOrNull()?.seat
|
||||||
|
CatanPhase.GOLD -> state.sea?.gold?.firstOrNull()?.seat
|
||||||
CatanPhase.AWAIT_ROLL, CatanPhase.ROBBER,
|
CatanPhase.AWAIT_ROLL, CatanPhase.ROBBER,
|
||||||
CatanPhase.PLAYING, CatanPhase.FREE_ROADS,
|
CatanPhase.PLAYING, CatanPhase.FREE_ROADS,
|
||||||
-> state.turn
|
-> state.turn
|
||||||
@@ -162,12 +177,17 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
|
|
||||||
CatanPhase.DISCARDING -> discardMoves(state, seat)
|
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.PLAYING -> playingMoves(state, seat)
|
||||||
|
|
||||||
CatanPhase.FREE_ROADS ->
|
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)
|
CatanPhase.TRADE_PENDING -> tradeResponses(state, seat)
|
||||||
|
|
||||||
@@ -179,10 +199,12 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
|
|
||||||
internal fun setupMoves(state: CatanState): List<SetupPlace> {
|
internal fun setupMoves(state: CatanState): List<SetupPlace> {
|
||||||
val moves = mutableListOf<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
|
if (!vertexOpenForSettlement(state, v)) continue
|
||||||
for (e in CatanBoard.VERTEX_EDGES[v]) {
|
// Seafarers: everyone starts on the home island.
|
||||||
if (state.roads[e] != NO_PLAYER) continue
|
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)
|
moves += SetupPlace(v, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,18 +237,18 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
|
|
||||||
private fun robberMoves(state: CatanState, seat: Int): List<MoveRobber> {
|
private fun robberMoves(state: CatanState, seat: Int): List<MoveRobber> {
|
||||||
val moves = mutableListOf<MoveRobber>()
|
val moves = mutableListOf<MoveRobber>()
|
||||||
for (h in 0 until CatanBoard.HEX_COUNT) {
|
for (h in 0 until state.topo.HEX_COUNT) {
|
||||||
if (h == state.robber) continue
|
if (h == state.robber || !state.topo.land[h]) continue
|
||||||
val victims = CatanBoard.HEX_CORNERS[h]
|
val victims = state.topo.HEX_CORNERS[h]
|
||||||
.mapNotNull { state.buildings[it] }
|
.mapNotNull { state.buildings[it] }
|
||||||
.map { it.owner }
|
.map { it.owner }
|
||||||
.filter { it != seat }
|
.filter { it != seat }
|
||||||
.distinct()
|
.distinct()
|
||||||
.filter { state.resourceCount(it) > 0 }
|
.filter { state.resourceCount(it) > 0 }
|
||||||
if (victims.isEmpty()) {
|
if (victims.isEmpty()) {
|
||||||
moves += MoveRobber(CatanBoard.HEXES[h])
|
moves += MoveRobber(state.topo.HEXES[h])
|
||||||
} else {
|
} else {
|
||||||
victims.forEach { moves += MoveRobber(CatanBoard.HEXES[h], it) }
|
victims.forEach { moves += MoveRobber(state.topo.HEXES[h], it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return moves
|
return moves
|
||||||
@@ -245,6 +267,10 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
if (state.devDeck.isNotEmpty() && affordable(state, seat, DEV_COST)) {
|
if (state.devDeck.isNotEmpty() && affordable(state, seat, DEV_COST)) {
|
||||||
moves += BuyDevCard
|
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) {
|
if (!state.devCardPlayedThisTurn) {
|
||||||
for (card in state.devHands[seat]) {
|
for (card in state.devHands[seat]) {
|
||||||
@@ -301,8 +327,12 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
if (state.roads.count { it == seat } >= MAX_ROADS) {
|
if (state.roads.count { it == seat } >= MAX_ROADS) {
|
||||||
emptyList()
|
emptyList()
|
||||||
} else {
|
} else {
|
||||||
(0 until CatanBoard.EDGE_COUNT)
|
(0 until state.topo.EDGE_COUNT)
|
||||||
.filter { state.roads[it] == NO_PLAYER && roadConnects(state, seat, it) }
|
.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) }
|
.map { BuildRoad(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,9 +346,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
if (piecesOf(state, seat, BuildingType.SETTLEMENT) >= MAX_SETTLEMENTS) {
|
if (piecesOf(state, seat, BuildingType.SETTLEMENT) >= MAX_SETTLEMENTS) {
|
||||||
emptyList()
|
emptyList()
|
||||||
} else {
|
} else {
|
||||||
(0 until CatanBoard.VERTEX_COUNT).filter { v ->
|
(0 until state.topo.VERTEX_COUNT).filter { v ->
|
||||||
vertexOpenForSettlement(state, 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) {
|
if (piecesOf(state, seat, BuildingType.CITY) >= MAX_CITIES) {
|
||||||
emptyList()
|
emptyList()
|
||||||
} else {
|
} else {
|
||||||
(0 until CatanBoard.VERTEX_COUNT).filter { v ->
|
(0 until state.topo.VERTEX_COUNT).filter { v ->
|
||||||
state.buildings[v] == CatanBuilding(BuildingType.SETTLEMENT, seat)
|
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. */
|
/** An empty vertex with no building on it or one edge away. */
|
||||||
internal fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean =
|
internal fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean =
|
||||||
state.buildings[v] == null && !hasKnight(state, v) &&
|
state.buildings[v] == null && !hasKnight(state, v) && state.topo.vertexLand[v] &&
|
||||||
CatanBoard.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null }
|
state.topo.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A road on [edge] would join [seat]'s network of roads and buildings.
|
* A road on [edge] would join [seat]'s network of roads and buildings.
|
||||||
@@ -385,7 +415,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
* of your own roads ending at the same crossing.
|
* of your own roads ending at the same crossing.
|
||||||
*/
|
*/
|
||||||
private fun roadConnects(state: CatanState, seat: Int, edge: Int): Boolean {
|
private fun roadConnects(state: CatanState, seat: Int, edge: Int): Boolean {
|
||||||
val (a, b) = CatanBoard.EDGE_VERTICES[edge]
|
val (a, b) = state.topo.EDGE_VERTICES[edge]
|
||||||
return networkReaches(state, seat, a) || networkReaches(state, seat, b)
|
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
|
if (knight != null && knight.owner != seat) return false
|
||||||
// Your own building is always a way in; anyone else's is a wall.
|
// Your own building is always a way in; anyone else's is a wall.
|
||||||
if (building != null) return building.owner == seat
|
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 {
|
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 RollDice -> applyRoll(state, seat, move)
|
||||||
is Discard -> applyDiscard(state, seat, move)
|
is Discard -> applyDiscard(state, seat, move)
|
||||||
is MoveRobber -> applyRobber(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 BuildRoad -> applyBuildRoad(state, seat, move)
|
||||||
is BuildSettlement -> applyBuildSettlement(state, seat, move)
|
is BuildSettlement -> applyBuildSettlement(state, seat, move)
|
||||||
is BuildCity -> applyBuildCity(state, seat, move)
|
is BuildCity -> applyBuildCity(state, seat, move)
|
||||||
@@ -461,7 +495,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
if (second) {
|
if (second) {
|
||||||
// The second settlement earns one card from each adjacent
|
// The second settlement earns one card from each adjacent
|
||||||
// producing hex, supply permitting.
|
// 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) ->
|
val granted = adjacent.groupingBy { it }.eachCount().filter { (res, need) ->
|
||||||
need <= bankSupply(state, res)
|
need <= bankSupply(state, res)
|
||||||
}
|
}
|
||||||
@@ -517,11 +551,11 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
val gains = List(state.playerCount) { mutableMapOf<Resource, Int>() }
|
val gains = List(state.playerCount) { mutableMapOf<Resource, Int>() }
|
||||||
for (resource in Resource.entries) {
|
for (resource in Resource.entries) {
|
||||||
var demand = 0
|
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 (state.numbers[h] != number) continue
|
||||||
if (h == state.robber) continue
|
if (h == state.robber) continue
|
||||||
if (state.terrains[h].produces != resource) 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 building = state.buildings[v] ?: continue
|
||||||
val amount = if (building.type == BuildingType.CITY) 2 else 1
|
val amount = if (building.type == BuildingType.CITY) 2 else 1
|
||||||
gains[building.owner][resource] = (gains[building.owner][resource] ?: 0) + amount
|
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)
|
var s = state.copy(phase = CatanPhase.PLAYING)
|
||||||
val whoGot = mutableListOf<String>()
|
val whoGot = mutableListOf<String>()
|
||||||
for (seat in 0 until s.playerCount) {
|
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))
|
s = s.copy(resources = withCounts(s.resources, seat, got, 1))
|
||||||
whoGot += "seat $seat takes ${describe(got)}"
|
whoGot += "seat $seat takes ${describe(got)}"
|
||||||
}
|
}
|
||||||
val said = if (whoGot.isEmpty()) "nothing for anyone" else whoGot.joinToString(", ")
|
val goldLine = goldOwed.joinToString("") { ", seat ${it.seat} is owed ${it.count} gold" }
|
||||||
return s.copy(
|
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.",
|
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 {
|
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 {
|
private fun applyRobber(state: CatanState, seat: Int, move: MoveRobber): CatanState {
|
||||||
if (state.isCk) return CatanCk.applyRobber(state, seat, move)
|
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}")
|
?: throw IllegalArgumentException("No hex at ${move.hex}")
|
||||||
var s = state.copy(robber = hex, rngStep = state.rngStep + 1)
|
var s = state.copy(robber = hex, rngStep = state.rngStep + 1)
|
||||||
var stolen: String? = null
|
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}."))
|
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 {
|
private fun applyBuildSettlement(state: CatanState, seat: Int, move: BuildSettlement): CatanState {
|
||||||
var s = state.copy(
|
var s = state.copy(
|
||||||
buildings = state.buildings.toMutableList().also {
|
buildings = state.buildings.toMutableList().also {
|
||||||
@@ -606,6 +726,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
s = payCost(s, seat, SETTLEMENT_COST)
|
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
|
// A settlement can break another player's road, so the award is
|
||||||
// recomputed for everyone.
|
// recomputed for everyone.
|
||||||
s = refreshLongestRoad(s)
|
s = refreshLongestRoad(s)
|
||||||
@@ -831,6 +952,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
phase = CatanPhase.AWAIT_ROLL,
|
phase = CatanPhase.AWAIT_ROLL,
|
||||||
devCardPlayedThisTurn = false,
|
devCardPlayedThisTurn = false,
|
||||||
pendingTrade = null,
|
pendingTrade = null,
|
||||||
|
sea = state.sea?.copy(builtThisTurn = emptyList(), shipMoved = false),
|
||||||
)
|
)
|
||||||
// Someone can cross the target on another player's turn (a broken
|
// Someone can cross the target on another player's turn (a broken
|
||||||
// Longest Road re-awarded to a third player); they win when their
|
// 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.longestRoadOwner?.let { points[it] += 5 }
|
||||||
state.largestArmyOwner?.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()
|
return points.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -927,16 +1050,17 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
* one road, not two — the intersection is a branch).
|
* one road, not two — the intersection is a branch).
|
||||||
*/
|
*/
|
||||||
fun longestRoadFor(state: CatanState, seat: Int): Int {
|
fun longestRoadFor(state: CatanState, seat: Int): Int {
|
||||||
|
if (state.isSea) return SeaRules.longestRouteFor(state, seat)
|
||||||
var best = 0
|
var best = 0
|
||||||
val blocked = BooleanArray(CatanBoard.VERTEX_COUNT)
|
val blocked = BooleanArray(state.topo.VERTEX_COUNT)
|
||||||
for (v in 0 until CatanBoard.VERTEX_COUNT) {
|
for (v in 0 until state.topo.VERTEX_COUNT) {
|
||||||
val building = state.buildings[v]
|
val building = state.buildings[v]
|
||||||
if (building != null && building.owner != seat) blocked[v] = true
|
if (building != null && building.owner != seat) blocked[v] = true
|
||||||
}
|
}
|
||||||
state.ck?.knights?.forEach { if (it.owner != seat) blocked[it.vertex] = true }
|
state.ck?.knights?.forEach { if (it.owner != seat) blocked[it.vertex] = true }
|
||||||
for (start in 0 until CatanBoard.VERTEX_COUNT) {
|
for (start in 0 until state.topo.VERTEX_COUNT) {
|
||||||
if (CatanBoard.VERTEX_EDGES[start].none { state.roads[it] == seat }) continue
|
if (state.topo.VERTEX_EDGES[start].none { state.roads[it] == seat }) continue
|
||||||
val visited = BooleanArray(CatanBoard.VERTEX_COUNT)
|
val visited = BooleanArray(state.topo.VERTEX_COUNT)
|
||||||
best = maxOf(best, roadDfs(state, seat, start, blocked, visited))
|
best = maxOf(best, roadDfs(state, seat, start, blocked, visited))
|
||||||
}
|
}
|
||||||
return best
|
return best
|
||||||
@@ -951,9 +1075,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
): Int {
|
): Int {
|
||||||
visited[v] = true
|
visited[v] = true
|
||||||
var best = 0
|
var best = 0
|
||||||
for (e in CatanBoard.VERTEX_EDGES[v]) {
|
for (e in state.topo.VERTEX_EDGES[v]) {
|
||||||
if (state.roads[e] != seat) continue
|
if (state.roads[e] != seat) continue
|
||||||
val w = CatanBoard.otherEnd(e, v)
|
val w = state.topo.otherEnd(e, v)
|
||||||
if (visited[w]) continue
|
if (visited[w]) continue
|
||||||
if (blocked[w]) {
|
if (blocked[w]) {
|
||||||
// The segment may run up to an opponent's building, but not
|
// 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) {
|
for (port in state.ports) {
|
||||||
if (port.resource != null && port.resource != give) continue
|
if (port.resource != null && port.resource != give) continue
|
||||||
if (port.ratio >= best) 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
|
val mine = state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat
|
||||||
if (mine) best = port.ratio
|
if (mine) best = port.ratio
|
||||||
}
|
}
|
||||||
@@ -1050,7 +1174,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
fun genericPortRatio(state: CatanState, seat: Int): Int {
|
fun genericPortRatio(state: CatanState, seat: Int): Int {
|
||||||
for (port in state.ports) {
|
for (port in state.ports) {
|
||||||
if (port.resource != null) continue
|
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
|
if (state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat) return port.ratio
|
||||||
}
|
}
|
||||||
return 4
|
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 {
|
private object Keys {
|
||||||
val SHEET = stringPreferencesKey("sheet")
|
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
|
// 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()
|
?: 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) {
|
suspend fun save(sheet: ScoreSheet) {
|
||||||
context.scorekeeperDataStore.edit { it[Keys.SHEET] = json.encodeToString(sheet) }
|
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. */
|
/** Sets a fresh sheet up for [count] players, scored by round or by tally. */
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("start")
|
@SerialName("start")
|
||||||
data class StartSheet(val count: Int, val mode: ScoringMode = ScoringMode.ROUNDS) : SheetOp {
|
data class StartSheet(
|
||||||
override fun applyTo(sheet: ScoreSheet): ScoreSheet = ScoreSheet.of(count, mode)
|
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. */
|
/** Rubs everything out. */
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ data class Settings(
|
|||||||
val soundEnabled: Boolean = true,
|
val soundEnabled: Boolean = true,
|
||||||
val animationSpeed: Float = 1.0f,
|
val animationSpeed: Float = 1.0f,
|
||||||
val checkForUpdatesOnLaunch: Boolean = true,
|
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
|
* 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
|
* mid-edit. Nothing substitutes a default into this value: doing so meant
|
||||||
@@ -46,6 +48,8 @@ data class Settings(
|
|||||||
companion object {
|
companion object {
|
||||||
const val MIN_SPEED = 0.5f
|
const val MIN_SPEED = 0.5f
|
||||||
const val MAX_SPEED = 2.0f
|
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"
|
const val DEFAULT_PLAYER_NAME = "Player"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,6 +60,7 @@ class SettingsRepository(private val context: Context) {
|
|||||||
val SOUND = booleanPreferencesKey("sound_enabled")
|
val SOUND = booleanPreferencesKey("sound_enabled")
|
||||||
val SPEED = floatPreferencesKey("animation_speed")
|
val SPEED = floatPreferencesKey("animation_speed")
|
||||||
val UPDATE_ON_LAUNCH = booleanPreferencesKey("check_updates_on_launch")
|
val UPDATE_ON_LAUNCH = booleanPreferencesKey("check_updates_on_launch")
|
||||||
|
val TEXT_SCALE = floatPreferencesKey("text_scale")
|
||||||
val NAME = stringPreferencesKey("player_name")
|
val NAME = stringPreferencesKey("player_name")
|
||||||
// There is deliberately no update-channel key. The channel is a
|
// There is deliberately no update-channel key. The channel is a
|
||||||
// property of the installed APK, not a preference — an install that
|
// 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,
|
soundEnabled = prefs[Keys.SOUND] ?: true,
|
||||||
animationSpeed = prefs[Keys.SPEED] ?: 1.0f,
|
animationSpeed = prefs[Keys.SPEED] ?: 1.0f,
|
||||||
checkForUpdatesOnLaunch = prefs[Keys.UPDATE_ON_LAUNCH] ?: true,
|
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] ?: "",
|
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) {
|
suspend fun setCheckForUpdatesOnLaunch(enabled: Boolean) {
|
||||||
context.dataStore.edit { it[Keys.UPDATE_ON_LAUNCH] = enabled }
|
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.CardDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
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.OutlinedButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
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.score.ScorekeeperRepository
|
||||||
import org.prolibertate.games.settings.Settings
|
import org.prolibertate.games.settings.Settings
|
||||||
import org.prolibertate.games.settings.SettingsRepository
|
import org.prolibertate.games.settings.SettingsRepository
|
||||||
|
import org.prolibertate.games.settings.YahtzeeStatsRepository
|
||||||
import org.prolibertate.games.ui.game.LocalCardSpeed
|
import org.prolibertate.games.ui.game.LocalCardSpeed
|
||||||
import org.prolibertate.games.update.UpdateChannel
|
import org.prolibertate.games.update.UpdateChannel
|
||||||
import org.prolibertate.games.update.Updater
|
import org.prolibertate.games.update.Updater
|
||||||
@@ -68,6 +71,7 @@ class AppEnv(
|
|||||||
val peerId: String,
|
val peerId: String,
|
||||||
/** Games in progress, kept between sessions. */
|
/** Games in progress, kept between sessions. */
|
||||||
val saves: SavedGameStore,
|
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.
|
// Card motion reads its speed from here rather than from a parameter.
|
||||||
// Every card screen animates, most of them several layers deep, and the
|
// Every card screen animates, most of them several layers deep, and the
|
||||||
// alternative was passing one unchanging number down through all of it.
|
// 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(
|
AppContent(
|
||||||
env = env,
|
env = env,
|
||||||
settings = settings,
|
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.pyramid.PyramidOptions
|
||||||
import org.prolibertate.games.game.spider.SpiderOptions
|
import org.prolibertate.games.game.spider.SpiderOptions
|
||||||
import org.prolibertate.games.game.yahtzee.YahtzeeOptions
|
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.PegBoard
|
||||||
import org.prolibertate.games.game.pegsolitaire.PegGoal
|
import org.prolibertate.games.game.pegsolitaire.PegGoal
|
||||||
import org.prolibertate.games.game.pegsolitaire.PegSolitaireOptions
|
import org.prolibertate.games.game.pegsolitaire.PegSolitaireOptions
|
||||||
@@ -109,6 +111,7 @@ fun GameSetupScreen(
|
|||||||
var pirates by remember { mutableStateOf(PiratesOptions()) }
|
var pirates by remember { mutableStateOf(PiratesOptions()) }
|
||||||
var hearts by remember { mutableStateOf(HeartsOptions()) }
|
var hearts by remember { mutableStateOf(HeartsOptions()) }
|
||||||
var pegs by remember { mutableStateOf(PegSolitaireOptions()) }
|
var pegs by remember { mutableStateOf(PegSolitaireOptions()) }
|
||||||
|
var meowdoku by remember { mutableStateOf(MeowdokuOptions()) }
|
||||||
var yahtzee by remember { mutableStateOf(YahtzeeOptions()) }
|
var yahtzee by remember { mutableStateOf(YahtzeeOptions()) }
|
||||||
var klondike by remember { mutableStateOf(KlondikeOptions()) }
|
var klondike by remember { mutableStateOf(KlondikeOptions()) }
|
||||||
var freecell by remember { mutableStateOf(FreeCellOptions()) }
|
var freecell by remember { mutableStateOf(FreeCellOptions()) }
|
||||||
@@ -140,6 +143,7 @@ fun GameSetupScreen(
|
|||||||
GameCatalog.PIRATES -> setupJson.encodeToString(pirates)
|
GameCatalog.PIRATES -> setupJson.encodeToString(pirates)
|
||||||
GameCatalog.HEARTS -> setupJson.encodeToString(hearts)
|
GameCatalog.HEARTS -> setupJson.encodeToString(hearts)
|
||||||
GameCatalog.PEG_SOLITAIRE -> setupJson.encodeToString(pegs)
|
GameCatalog.PEG_SOLITAIRE -> setupJson.encodeToString(pegs)
|
||||||
|
GameCatalog.MEOWDOKU -> setupJson.encodeToString(meowdoku)
|
||||||
GameCatalog.YAHTZEE -> setupJson.encodeToString(yahtzee)
|
GameCatalog.YAHTZEE -> setupJson.encodeToString(yahtzee)
|
||||||
GameCatalog.KLONDIKE -> setupJson.encodeToString(klondike)
|
GameCatalog.KLONDIKE -> setupJson.encodeToString(klondike)
|
||||||
GameCatalog.FREECELL -> setupJson.encodeToString(freecell)
|
GameCatalog.FREECELL -> setupJson.encodeToString(freecell)
|
||||||
@@ -205,6 +209,8 @@ fun GameSetupScreen(
|
|||||||
|
|
||||||
GameCatalog.PEG_SOLITAIRE -> PegSolitaireOptionsEditor(pegs) { pegs = it }
|
GameCatalog.PEG_SOLITAIRE -> PegSolitaireOptionsEditor(pegs) { pegs = it }
|
||||||
|
|
||||||
|
GameCatalog.MEOWDOKU -> MeowdokuOptionsEditor(meowdoku) { meowdoku = it }
|
||||||
|
|
||||||
GameCatalog.YAHTZEE -> YahtzeeOptionsEditor(yahtzee) { yahtzee = it }
|
GameCatalog.YAHTZEE -> YahtzeeOptionsEditor(yahtzee) { yahtzee = it }
|
||||||
|
|
||||||
GameCatalog.KLONDIKE -> KlondikeOptionsEditor(klondike) { klondike = 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)
|
CatanRuleSet.BASE -> listOf(5, 8, 10, 12)
|
||||||
// Cities & Knights is played to thirteen, which is also why it runs longer.
|
// Cities & Knights is played to thirteen, which is also why it runs longer.
|
||||||
CatanRuleSet.CITIES_AND_KNIGHTS -> listOf(8, 10, 13, 15)
|
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. */
|
/** The published target for a rule set. */
|
||||||
internal fun catanDefaultPoints(ruleSet: CatanRuleSet): Int = when (ruleSet) {
|
internal fun catanDefaultPoints(ruleSet: CatanRuleSet): Int = when (ruleSet) {
|
||||||
CatanRuleSet.BASE -> 10
|
CatanRuleSet.BASE -> 10
|
||||||
CatanRuleSet.CITIES_AND_KNIGHTS -> 13
|
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) {
|
private fun catanRuleSetName(ruleSet: CatanRuleSet): String = when (ruleSet) {
|
||||||
CatanRuleSet.BASE -> "Base game"
|
CatanRuleSet.BASE -> "Base game"
|
||||||
CatanRuleSet.CITIES_AND_KNIGHTS -> "Cities & Knights"
|
CatanRuleSet.CITIES_AND_KNIGHTS -> "Cities & Knights"
|
||||||
|
CatanRuleSet.SEAFARERS -> "Seafarers"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun catanRuleSetBlurb(ruleSet: CatanRuleSet): String = when (ruleSet) {
|
private fun catanRuleSetBlurb(ruleSet: CatanRuleSet): String = when (ruleSet) {
|
||||||
@@ -389,6 +399,9 @@ private fun catanRuleSetBlurb(ruleSet: CatanRuleSet): String = when (ruleSet) {
|
|||||||
CatanRuleSet.CITIES_AND_KNIGHTS ->
|
CatanRuleSet.CITIES_AND_KNIGHTS ->
|
||||||
"Cities make commodities, knights hold off the barbarians, and improvements earn progress " +
|
"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."
|
"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
|
@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
|
@Composable
|
||||||
private fun YahtzeeOptionsEditor(options: YahtzeeOptions, onChange: (YahtzeeOptions) -> Unit) {
|
private fun YahtzeeOptionsEditor(options: YahtzeeOptions, onChange: (YahtzeeOptions) -> Unit) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.compose.runtime.DisposableEffect
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
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.MastermindMove
|
||||||
import org.prolibertate.games.game.mastermind.MastermindRules
|
import org.prolibertate.games.game.mastermind.MastermindRules
|
||||||
import org.prolibertate.games.game.mastermind.MastermindState
|
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.MorrisAi
|
||||||
import org.prolibertate.games.game.morris.MorrisMove
|
import org.prolibertate.games.game.morris.MorrisMove
|
||||||
import org.prolibertate.games.game.morris.MorrisRules
|
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.GolfScreen
|
||||||
import org.prolibertate.games.ui.game.KaiserScreen
|
import org.prolibertate.games.ui.game.KaiserScreen
|
||||||
import org.prolibertate.games.ui.game.MastermindScreen
|
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.MorrisScreen
|
||||||
import org.prolibertate.games.ui.game.PegSolitaireScreen
|
import org.prolibertate.games.ui.game.PegSolitaireScreen
|
||||||
import org.prolibertate.games.ui.game.SpiderScreen
|
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 -> {
|
GameCatalog.YAHTZEE -> {
|
||||||
val controller = remember(route) {
|
val controller = remember(route) {
|
||||||
MatchController<YahtzeeState, YahtzeeMove>(
|
MatchController<YahtzeeState, YahtzeeMove>(
|
||||||
@@ -560,7 +594,12 @@ fun PlayScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
StartMatch(env, controller, route)
|
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 -> {
|
GameCatalog.KLONDIKE -> {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import androidx.compose.runtime.DisposableEffect
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -114,6 +115,8 @@ fun ScorekeeperScreen(
|
|||||||
local = repository.sheet.first()
|
local = repository.sheet.first()
|
||||||
loaded = true
|
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 shared by session.state.collectAsState()
|
||||||
val role = shared.role
|
val role = shared.role
|
||||||
@@ -220,7 +223,7 @@ fun ScorekeeperScreen(
|
|||||||
|
|
||||||
role == Role.GUEST -> Column(modifier = modifier) {
|
role == Role.GUEST -> Column(modifier = modifier) {
|
||||||
ConnectionBanner(shared = shared, onSync = { session.sync() })
|
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 ->
|
joining && role == Role.NONE ->
|
||||||
@@ -228,11 +231,16 @@ fun ScorekeeperScreen(
|
|||||||
|
|
||||||
!sheet.started -> SetupPane(
|
!sheet.started -> SetupPane(
|
||||||
modifier = modifier,
|
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 },
|
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)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@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 count by remember { mutableStateOf(ScoreSheet.DEFAULT_PLAYERS) }
|
||||||
var mode by remember { mutableStateOf(ScoringMode.ROUNDS) }
|
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(
|
Column(
|
||||||
modifier = modifier.verticalScroll(rememberScrollState()),
|
modifier = modifier.verticalScroll(rememberScrollState()),
|
||||||
@@ -339,33 +384,98 @@ private fun SetupPane(modifier: Modifier, onStart: (Int, ScoringMode) -> Unit, o
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = "Names come next: tap a column head to name it. Players can be " +
|
text = "You will be asked for each name next, or skip them and tap a " +
|
||||||
"added, removed or dragged into a different order at any point " +
|
"column head later. Players can be added, removed or dragged into " +
|
||||||
"without losing anybody's score.",
|
"a different order at any point without losing anybody's score.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
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()) {
|
OutlinedButton(onClick = onJoin, modifier = Modifier.fillMaxWidth()) {
|
||||||
Text("Join a shared sheet nearby")
|
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
|
// The sheet: a column per player, a row per round, totals ruled off at the foot
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@Composable
|
@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) {
|
if (sheet.mode == ScoringMode.TALLY) {
|
||||||
TallySheet(modifier = modifier, sheet = sheet, onOp = onOp)
|
TallySheet(modifier, sheet, onOp, recentNames, onNameUsed)
|
||||||
} else {
|
} else {
|
||||||
Sheet(modifier = modifier, sheet = sheet, onOp = onOp)
|
Sheet(modifier, sheet, onOp, recentNames, onNameUsed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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
|
// 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
|
// 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
|
// 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) }
|
var lastTyped by remember { mutableStateOf(-1) }
|
||||||
// The cell to put the cursor in when a round is opened by tapping it.
|
// The cell to put the cursor in when a round is opened by tapping it.
|
||||||
var focusPlayer by remember { mutableStateOf(-1) }
|
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
|
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) },
|
trailing = { EmptyCell(width = ADD_COLUMN, height = ROW_HEIGHT) },
|
||||||
) { _, player ->
|
) { index, player ->
|
||||||
if (correcting >= 0) {
|
if (correcting >= 0) {
|
||||||
// A round is being corrected above; what is typed here
|
// A round is being corrected above; what is typed here
|
||||||
// is kept, but only one row is edited at a time.
|
// 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 {
|
} else {
|
||||||
PointsField(
|
PointsField(
|
||||||
text = pending[player.id].orEmpty(),
|
text = pending[player.id].orEmpty(),
|
||||||
|
autoFocus = index == 0 && nextRoundFocus > 0,
|
||||||
|
focusRequest = nextRoundFocus,
|
||||||
onText = {
|
onText = {
|
||||||
pending[player.id] = it
|
pending[player.id] = it
|
||||||
lastTyped = player.id
|
lastTyped = player.id
|
||||||
@@ -563,6 +678,7 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
|
|||||||
onOp(AddRound(amountsAsPoints(pending)))
|
onOp(AddRound(amountsAsPoints(pending)))
|
||||||
pending.clear()
|
pending.clear()
|
||||||
}
|
}
|
||||||
|
nextRoundFocus += 1
|
||||||
lastTyped = -1
|
lastTyped = -1
|
||||||
focusPlayer = -1
|
focusPlayer = -1
|
||||||
},
|
},
|
||||||
@@ -587,6 +703,8 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
|
|||||||
RenameDialog(
|
RenameDialog(
|
||||||
sheet = sheet,
|
sheet = sheet,
|
||||||
player = renamed,
|
player = renamed,
|
||||||
|
recentNames = recentNames,
|
||||||
|
onNameUsed = onNameUsed,
|
||||||
onName = { onOp(RenamePlayer(renamed.id, it)) },
|
onName = { onOp(RenamePlayer(renamed.id, it)) },
|
||||||
onRemove = {
|
onRemove = {
|
||||||
onOp(RemovePlayer(renamed.id))
|
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.
|
* sheet, so tapping one in the list takes it back off.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@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) }
|
var renaming by remember { mutableStateOf(-1) }
|
||||||
|
|
||||||
Column(modifier = modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(modifier = modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
@@ -676,6 +800,8 @@ private fun TallySheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) ->
|
|||||||
RenameDialog(
|
RenameDialog(
|
||||||
sheet = sheet,
|
sheet = sheet,
|
||||||
player = renamed,
|
player = renamed,
|
||||||
|
recentNames = recentNames,
|
||||||
|
onNameUsed = onNameUsed,
|
||||||
onName = { onOp(RenamePlayer(renamed.id, it)) },
|
onName = { onOp(RenamePlayer(renamed.id, it)) },
|
||||||
onRemove = {
|
onRemove = {
|
||||||
onOp(RemovePlayer(renamed.id))
|
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. */
|
/** A cell being typed into. Blank means nothing entered; a typed 0 is kept and shown. */
|
||||||
@Composable
|
@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() }
|
val focus = remember { FocusRequester() }
|
||||||
LaunchedEffect(autoFocus) { if (autoFocus) focus.requestFocus() }
|
LaunchedEffect(autoFocus, focusRequest) { if (autoFocus) focus.requestFocus() }
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = text,
|
value = text,
|
||||||
onValueChange = { onText(sanitiseAmount(it)) },
|
onValueChange = { onText(sanitiseAmount(it)) },
|
||||||
@@ -1064,16 +1195,27 @@ private fun ActionBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun RenameDialog(
|
private fun RenameDialog(
|
||||||
sheet: ScoreSheet,
|
sheet: ScoreSheet,
|
||||||
player: ScorePlayer,
|
player: ScorePlayer,
|
||||||
|
recentNames: List<String>,
|
||||||
|
onNameUsed: (String) -> Unit,
|
||||||
onName: (String) -> Unit,
|
onName: (String) -> Unit,
|
||||||
onRemove: () -> Unit,
|
onRemove: () -> Unit,
|
||||||
onDismiss: () -> 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(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = finish,
|
||||||
title = { Text("Name this column") },
|
title = { Text("Name this column") },
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
@@ -1084,6 +1226,21 @@ private fun RenameDialog(
|
|||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
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(
|
TextButton(
|
||||||
onClick = onRemove,
|
onClick = onRemove,
|
||||||
// The last two columns stay: a sheet needs somebody on it.
|
// 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") }
|
) { 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.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.roundToInt
|
||||||
import org.prolibertate.games.settings.Settings
|
import org.prolibertate.games.settings.Settings
|
||||||
import org.prolibertate.games.update.Release
|
import org.prolibertate.games.update.Release
|
||||||
import org.prolibertate.games.update.UpdateChannel
|
import org.prolibertate.games.update.UpdateChannel
|
||||||
@@ -83,6 +84,26 @@ fun SettingsScreen(
|
|||||||
|
|
||||||
Divider()
|
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 {
|
Column {
|
||||||
Text("Your name", fontWeight = FontWeight.Bold)
|
Text("Your name", fontWeight = FontWeight.Bold)
|
||||||
Text(
|
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.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
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.layout.Column
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -30,12 +34,17 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import org.prolibertate.games.game.yahtzee.DICE_COUNT
|
import org.prolibertate.games.game.yahtzee.DICE_COUNT
|
||||||
import org.prolibertate.games.game.yahtzee.RollDice
|
import org.prolibertate.games.game.yahtzee.RollDice
|
||||||
import org.prolibertate.games.game.yahtzee.ScoreIn
|
import org.prolibertate.games.game.yahtzee.ScoreIn
|
||||||
import org.prolibertate.games.game.yahtzee.YahtzeeCategory
|
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.YahtzeeMove
|
||||||
import org.prolibertate.games.game.yahtzee.YahtzeeSection
|
import org.prolibertate.games.game.yahtzee.YahtzeeSection
|
||||||
import org.prolibertate.games.game.yahtzee.YahtzeeState
|
import org.prolibertate.games.game.yahtzee.YahtzeeState
|
||||||
@@ -61,6 +70,7 @@ private const val TITLE = "Yahtzee"
|
|||||||
fun YahtzeeScreen(
|
fun YahtzeeScreen(
|
||||||
controller: MatchController<YahtzeeState, YahtzeeMove>,
|
controller: MatchController<YahtzeeState, YahtzeeMove>,
|
||||||
localSeat: Int,
|
localSeat: Int,
|
||||||
|
stats: YahtzeeStatsRepository,
|
||||||
onExit: () -> Unit,
|
onExit: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val state by controller.state.collectAsState()
|
val state by controller.state.collectAsState()
|
||||||
@@ -69,6 +79,11 @@ fun YahtzeeScreen(
|
|||||||
|
|
||||||
val kept = remember { mutableStateListOf<Int>() }
|
val kept = remember { mutableStateListOf<Int>() }
|
||||||
var confirmingEnd by remember { mutableStateOf(false) }
|
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 = {
|
val endGameAction: @Composable () -> Unit = {
|
||||||
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
|
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
|
||||||
@@ -89,6 +104,15 @@ fun YahtzeeScreen(
|
|||||||
// refused, and the Throw button would look broken again.
|
// refused, and the Throw button would look broken again.
|
||||||
LaunchedEffect(current.turn, current.round) { kept.clear() }
|
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 yours = current.turn == localSeat && !current.isOver
|
||||||
val canRoll = legal.any { it is RollDice }
|
val canRoll = legal.any { it is RollDice }
|
||||||
val writable = legal.filterIsInstance<ScoreIn>().map { it.category }.toSet()
|
val writable = legal.filterIsInstance<ScoreIn>().map { it.category }.toSet()
|
||||||
@@ -138,8 +162,14 @@ fun YahtzeeScreen(
|
|||||||
localSeat = localSeat,
|
localSeat = localSeat,
|
||||||
writable = if (yours) writable else emptySet(),
|
writable = if (yours) writable else emptySet(),
|
||||||
onWrite = {
|
onWrite = {
|
||||||
controller.submit(ScoreIn(it))
|
// A zero is a box spent for nothing, and one mis-tap on a
|
||||||
kept.clear()
|
// 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) }
|
abandoned?.let { LeftCardDialog(it, onExit, controller::dismissAbandoned) }
|
||||||
if (confirmingEnd) EndCardDialog(onExit) { confirmingEnd = false }
|
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
|
@Composable
|
||||||
private fun DiceRow(
|
private fun DiceRow(
|
||||||
dice: List<Int>,
|
dice: List<Int>,
|
||||||
@@ -190,12 +290,29 @@ private fun Die(face: Int?, held: Boolean, enabled: Boolean, onClick: () -> Unit
|
|||||||
.clickable(enabled = enabled) { onClick() },
|
.clickable(enabled = enabled) { onClick() },
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
if (face != null) {
|
||||||
text = face?.toString() ?: "·",
|
Canvas(modifier = Modifier.size(48.dp)) {
|
||||||
style = MaterialTheme.typography.titleLarge,
|
val radius = size.minDimension * 0.08f
|
||||||
fontWeight = FontWeight.Bold,
|
pipPositions(face).forEach { (x, y) ->
|
||||||
color = WallaceRed,
|
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>,
|
writable: Set<YahtzeeCategory>,
|
||||||
onWrite: (YahtzeeCategory) -> Unit,
|
onWrite: (YahtzeeCategory) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
// Upper boxes and bonus on the left, lower boxes and total on the right, so
|
||||||
Row(modifier = Modifier.fillMaxWidth()) {
|
// each half is wide enough for text that can actually be read. Each half
|
||||||
Text("", modifier = Modifier.weight(2f), style = MaterialTheme.typography.labelSmall)
|
// has a floor on its width; if two of them do not fit (many players, large
|
||||||
state.cards.indices.forEach { seat ->
|
// text) the pair scrolls sideways instead of squeezing.
|
||||||
Text(
|
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||||
text = if (seat == localSeat) "You" else "S$seat",
|
val needed = (CARD_LABEL_WIDTH + CARD_SEAT_WIDTH * state.cards.size).dp
|
||||||
modifier = Modifier.weight(1f),
|
val half = maxOf((maxWidth - CARD_GAP.dp) / 2, needed)
|
||||||
style = MaterialTheme.typography.labelSmall,
|
Row(
|
||||||
fontWeight = FontWeight.Bold,
|
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||||
)
|
horizontalArrangement = Arrangement.spacedBy(CARD_GAP.dp),
|
||||||
}
|
) {
|
||||||
}
|
Column(modifier = Modifier.width(half)) {
|
||||||
YahtzeeSection.entries.forEach { section ->
|
CardHeader(state, localSeat)
|
||||||
YahtzeeCategory.entries.filter { it.section == section }.forEach { category ->
|
CardSection(YahtzeeSection.UPPER, state, localSeat, writable, onWrite)
|
||||||
CardRow(state, localSeat, category, writable, onWrite)
|
|
||||||
}
|
|
||||||
if (section == YahtzeeSection.UPPER) {
|
|
||||||
TotalRow("Bonus at 63", state.cards.map { it.upperBonus })
|
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
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(enabled = open) { onWrite(category) }
|
.clickable(enabled = open) { onWrite(category) }
|
||||||
.padding(vertical = 2.dp),
|
.padding(vertical = 5.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = category.label,
|
text = category.label,
|
||||||
modifier = Modifier.weight(2f),
|
modifier = Modifier.weight(2f),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontWeight = if (open) FontWeight.Bold else FontWeight.Normal,
|
fontWeight = if (open) FontWeight.Bold else FontWeight.Normal,
|
||||||
)
|
)
|
||||||
state.cards.forEachIndexed { seat, card ->
|
state.cards.forEachIndexed { seat, card ->
|
||||||
@@ -263,7 +413,7 @@ private fun CardRow(
|
|||||||
else -> "—"
|
else -> "—"
|
||||||
},
|
},
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = if (written == null && open) WallaceRed else MaterialTheme.colorScheme.onSurface,
|
color = if (written == null && open) WallaceRed else MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -272,18 +422,18 @@ private fun CardRow(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TotalRow(label: String, values: List<Int>, bold: Boolean = false) {
|
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(
|
||||||
text = label,
|
text = label,
|
||||||
modifier = Modifier.weight(2f),
|
modifier = Modifier.weight(2f),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
|
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
|
||||||
)
|
)
|
||||||
values.forEach {
|
values.forEach {
|
||||||
Text(
|
Text(
|
||||||
text = "$it",
|
text = "$it",
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
|
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.CatanState
|
||||||
import org.prolibertate.games.game.catan.NO_PLAYER
|
import org.prolibertate.games.game.catan.NO_PLAYER
|
||||||
import org.prolibertate.games.game.catan.Resource
|
import org.prolibertate.games.game.catan.Resource
|
||||||
|
import org.prolibertate.games.game.catan.Terrain
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The island, drawn and tappable.
|
* The island, drawn and tappable.
|
||||||
@@ -61,12 +62,18 @@ fun CatanBoardView(
|
|||||||
rolledHexes: Set<Int> = emptySet(),
|
rolledHexes: Set<Int> = emptySet(),
|
||||||
/** Cities & Knights: the knight whose menu is open or that is about to move. */
|
/** Cities & Knights: the knight whose menu is open or that is about to move. */
|
||||||
selectedKnight: Int? = null,
|
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()
|
val measurer = rememberTextMeasurer()
|
||||||
var size by remember { mutableStateOf(IntSize.Zero) }
|
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) {
|
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 {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
@@ -80,7 +87,7 @@ fun CatanBoardView(
|
|||||||
|
|
||||||
Canvas(
|
Canvas(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.aspectRatio(CatanGeometry.ASPECT)
|
.aspectRatio(CatanGeometry.aspect(topo))
|
||||||
.onSizeChanged { size = it }
|
.onSizeChanged { size = it }
|
||||||
.semantics { contentDescription = "Katan island board" }
|
.semantics { contentDescription = "Katan island board" }
|
||||||
.pointerInput(layout, targets, style) {
|
.pointerInput(layout, targets, style) {
|
||||||
@@ -92,8 +99,8 @@ fun CatanBoardView(
|
|||||||
targets.tappableVertices.isNotEmpty() ->
|
targets.tappableVertices.isNotEmpty() ->
|
||||||
board.nearestVertex(at, reach, targets.tappableVertices)?.let { vertexTap(it) }
|
board.nearestVertex(at, reach, targets.tappableVertices)?.let { vertexTap(it) }
|
||||||
|
|
||||||
targets.edges.isNotEmpty() ->
|
targets.edges.isNotEmpty() || targets.ships.isNotEmpty() ->
|
||||||
board.nearestEdge(at, reach, targets.edges)?.let { edgeTap(it) }
|
board.nearestEdge(at, reach, targets.edges + targets.ships)?.let { edgeTap(it) }
|
||||||
|
|
||||||
targets.hexes.isNotEmpty() ->
|
targets.hexes.isNotEmpty() ->
|
||||||
// A hex is big: accept a tap anywhere near its middle.
|
// A hex is big: accept a tap anywhere near its middle.
|
||||||
@@ -108,9 +115,12 @@ fun CatanBoardView(
|
|||||||
drawHexes(state, board, style, measurer)
|
drawHexes(state, board, style, measurer)
|
||||||
drawRolled(rolledHexes, board, style)
|
drawRolled(rolledHexes, board, style)
|
||||||
drawRobber(state, board, style)
|
drawRobber(state, board, style)
|
||||||
|
drawPirate(state, board, style)
|
||||||
drawMerchant(state, board, style, measurer)
|
drawMerchant(state, board, style, measurer)
|
||||||
drawRoads(state, board, style)
|
drawRoads(state, board, style)
|
||||||
|
drawShips(state, board, style)
|
||||||
drawEdgeTargets(targets, board, style)
|
drawEdgeTargets(targets, board, style)
|
||||||
|
drawShipTargets(targets, board, style)
|
||||||
drawBuildings(state, board, style)
|
drawBuildings(state, board, style)
|
||||||
drawWalls(state, board, style)
|
drawWalls(state, board, style)
|
||||||
drawMetropolises(state, board, style)
|
drawMetropolises(state, board, style)
|
||||||
@@ -118,6 +128,7 @@ fun CatanBoardView(
|
|||||||
drawKnightTargets(targets, board, style)
|
drawKnightTargets(targets, board, style)
|
||||||
drawVertexTargets(targets, selectedVertex ?: selectedKnight, board, style)
|
drawVertexTargets(targets, selectedVertex ?: selectedKnight, board, style)
|
||||||
drawHexTargets(targets, board, style)
|
drawHexTargets(targets, board, style)
|
||||||
|
drawStaged(stagedVertex, stagedEdge, stagedSeat, board, style)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,10 +142,15 @@ private fun DrawScope.drawHexes(
|
|||||||
style: CatanStyle,
|
style: CatanStyle,
|
||||||
measurer: TextMeasurer,
|
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 centre = board.hexCenter(hex)
|
||||||
val corners = board.hexCorners(hex).map { insetToward(it, centre, style.hexInset) }
|
val corners = board.hexCorners(hex).map { insetToward(it, centre, style.hexInset) }
|
||||||
val path = polygon(corners)
|
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.terrain.getValue(state.terrains[hex]))
|
||||||
drawPath(path, color = style.hexEdge, style = Stroke(width = board.radius * 0.03f))
|
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) {
|
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]
|
val owner = state.roads[edge]
|
||||||
if (owner == NO_PLAYER) continue
|
if (owner == NO_PLAYER) continue
|
||||||
val (a, b) = board.edgeEnds(edge)
|
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) {
|
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 building = state.buildings[vertex] ?: continue
|
||||||
val at = board.vertex(vertex)
|
val at = board.vertex(vertex)
|
||||||
val fill = style.seats[building.owner % style.seats.size]
|
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),
|
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
|
// The robber
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.prolibertate.games.ui.game.catan
|
package org.prolibertate.games.ui.game.catan
|
||||||
|
|
||||||
import org.prolibertate.games.game.catan.CatanBoard
|
import org.prolibertate.games.game.catan.CatanBoard
|
||||||
|
import org.prolibertate.games.game.catan.Topology
|
||||||
import kotlin.math.sqrt
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
/** A point in board pixels. Plain floats, so this file needs no Compose types. */
|
/** A point in board pixels. Plain floats, so this file needs no Compose types. */
|
||||||
@@ -24,29 +25,35 @@ class BoardLayout(
|
|||||||
val height: Float,
|
val height: Float,
|
||||||
/** Circumradius of one hex, in pixels. */
|
/** Circumradius of one hex, in pixels. */
|
||||||
val radius: Float,
|
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 centreX = width / 2f
|
||||||
private val centreY = height / 2f
|
private val centreY = height / 2f
|
||||||
private val unitX = radius * SQRT3 / 2f
|
private val unitX = radius * SQRT3 / 2f
|
||||||
private val unitY = radius / 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 =
|
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 {
|
fun hexCenter(hex: Int): Pt {
|
||||||
val (lx, ly) = CatanBoard.hexCenter(hex)
|
val (lx, ly) = topo.hexCenter(hex)
|
||||||
return point(lx, ly)
|
return point(lx, ly)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun vertex(vertex: Int): Pt {
|
fun vertex(vertex: Int): Pt {
|
||||||
val (lx, ly) = CatanBoard.VERTEX_POS[vertex]
|
val (lx, ly) = topo.VERTEX_POS[vertex]
|
||||||
return point(lx, ly)
|
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> {
|
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)
|
return vertex(a) to vertex(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,8 +69,15 @@ class BoardLayout(
|
|||||||
*/
|
*/
|
||||||
fun portAnchor(edge: Int): Pt {
|
fun portAnchor(edge: Int): Pt {
|
||||||
val mid = edgeMidpoint(edge)
|
val mid = edgeMidpoint(edge)
|
||||||
val dx = mid.x - centreX
|
// Straight out from the middle of the island on the standard board; on a map of
|
||||||
val dy = mid.y - centreY
|
// 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)
|
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)
|
return Pt(mid.x + dx / length * radius * PORT_OFFSET, mid.y + dy / length * radius * PORT_OFFSET)
|
||||||
}
|
}
|
||||||
@@ -97,23 +111,29 @@ class BoardLayout(
|
|||||||
|
|
||||||
object CatanGeometry {
|
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. */
|
/** Empty space round the island, in hex radii, for the sea and the harbours. */
|
||||||
const val MARGIN = 1.75f
|
const val MARGIN = 1.75f
|
||||||
|
|
||||||
private val boardWidthInRadii = LATTICE_X_UNITS * SQRT3 / 2f + 2 * MARGIN
|
/** A Seafarers frame already ends in a ring of sea, so it needs almost none. */
|
||||||
private val boardHeightInRadii = LATTICE_Y_UNITS / 2f + 2 * MARGIN
|
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. */
|
/** 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. */
|
/** The largest layout that fits [width] × [height] pixels. */
|
||||||
fun fit(width: Float, height: Float): BoardLayout {
|
fun fit(width: Float, height: Float, topo: Topology = CatanBoard.base): BoardLayout {
|
||||||
val radius = minOf(width / boardWidthInRadii, height / boardHeightInRadii)
|
val radius = minOf(width / widthInRadii(topo), height / heightInRadii(topo))
|
||||||
return BoardLayout(width, height, radius)
|
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.ActivateKnight
|
||||||
import org.prolibertate.games.game.catan.BuildCity
|
import org.prolibertate.games.game.catan.BuildCity
|
||||||
import org.prolibertate.games.game.catan.BuildRoad
|
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.BuildSettlement
|
||||||
import org.prolibertate.games.game.catan.BuildWall
|
import org.prolibertate.games.game.catan.BuildWall
|
||||||
import org.prolibertate.games.game.catan.BuyDevCard
|
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.MoveRobber
|
||||||
import org.prolibertate.games.game.catan.OfferGoods
|
import org.prolibertate.games.game.catan.OfferGoods
|
||||||
import org.prolibertate.games.game.catan.OfferTrade
|
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.PillageCity
|
||||||
import org.prolibertate.games.game.catan.PlaceKnight
|
import org.prolibertate.games.game.catan.PlaceKnight
|
||||||
import org.prolibertate.games.game.catan.PlaceMetropolis
|
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.
|
* only in Cities & Knights; the base game's build menu lists the first three.
|
||||||
*/
|
*/
|
||||||
enum class BuildKind {
|
enum class BuildKind {
|
||||||
ROAD, SETTLEMENT, CITY, WALL, KNIGHT;
|
ROAD, SETTLEMENT, CITY, WALL, KNIGHT,
|
||||||
|
|
||||||
|
/** Seafarers: a ship, laid on a sea edge. */
|
||||||
|
SHIP;
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val BASE: List<BuildKind> = listOf(ROAD, SETTLEMENT, CITY)
|
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 edges: Set<Int> = emptySet(),
|
||||||
val hexes: Set<Int> = emptySet(),
|
val hexes: Set<Int> = emptySet(),
|
||||||
val knights: 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. */
|
/** The player's other knights: tappable for their menu, but with nothing to do, so not ringed. */
|
||||||
val idleKnights: Set<Int> = emptySet(),
|
val idleKnights: Set<Int> = emptySet(),
|
||||||
) {
|
) {
|
||||||
val isEmpty: Boolean
|
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. */
|
/** Every crossing a tap can land on. */
|
||||||
val tappableVertices: Set<Int> get() = vertices + knights + idleKnights
|
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. */
|
/** Setup: remember this settlement; the road that goes with it is tapped next. */
|
||||||
data class PickSetupVertex(val vertex: Int) : TapOutcome
|
data class PickSetupVertex(val vertex: Int) : TapOutcome
|
||||||
|
|
||||||
/** The robber's hex has more than one possible victim: ask which. */
|
/** 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>) : TapOutcome
|
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. */
|
/** One of the player's own knights was tapped: open its menu. */
|
||||||
data class PickKnight(val vertex: Int) : TapOutcome
|
data class PickKnight(val vertex: Int) : TapOutcome
|
||||||
@@ -142,6 +159,43 @@ data class DevCardInHand(val card: DevCard, val plays: List<CatanMove>) {
|
|||||||
*/
|
*/
|
||||||
object CatanInteraction {
|
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 ---------------------------------------------------------------
|
// --- Board ---------------------------------------------------------------
|
||||||
|
|
||||||
fun targets(
|
fun targets(
|
||||||
@@ -153,6 +207,8 @@ object CatanInteraction {
|
|||||||
armed: ArmedCard? = null,
|
armed: ArmedCard? = null,
|
||||||
/** The local seat, so its sleeping knights can be tapped for their menu too. */
|
/** The local seat, so its sleeping knights can be tapped for their menu too. */
|
||||||
localSeat: Int? = null,
|
localSeat: Int? = null,
|
||||||
|
/** Seafarers: the ship that has been picked up and is waiting for a destination. */
|
||||||
|
movingShip: Int? = null,
|
||||||
): BoardTargets {
|
): BoardTargets {
|
||||||
val setup = legal.filterIsInstance<SetupPlace>()
|
val setup = legal.filterIsInstance<SetupPlace>()
|
||||||
if (setup.isNotEmpty()) {
|
if (setup.isNotEmpty()) {
|
||||||
@@ -164,8 +220,11 @@ object CatanInteraction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val robber = legal.filterIsInstance<MoveRobber>()
|
val robber = legal.filterIsInstance<MoveRobber>()
|
||||||
if (robber.isNotEmpty()) {
|
val pirate = legal.filterIsInstance<MovePirate>()
|
||||||
return BoardTargets(hexes = robber.mapNotNull { CatanBoard.hexIndex[it.hex] }.toSet())
|
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.
|
// 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)
|
if (chore.isNotEmpty()) return BoardTargets(vertices = chore)
|
||||||
|
|
||||||
val roads = legal.filterIsInstance<BuildRoad>().map { it.edge }.toSet()
|
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)
|
if (armed != null) return armedTargets(armed)
|
||||||
|
|
||||||
@@ -185,6 +251,7 @@ object CatanInteraction {
|
|||||||
|
|
||||||
return when (building) {
|
return when (building) {
|
||||||
BuildKind.ROAD -> BoardTargets(edges = roads)
|
BuildKind.ROAD -> BoardTargets(edges = roads)
|
||||||
|
BuildKind.SHIP -> BoardTargets(edges = ships)
|
||||||
BuildKind.SETTLEMENT ->
|
BuildKind.SETTLEMENT ->
|
||||||
BoardTargets(vertices = legal.filterIsInstance<BuildSettlement>().map { it.vertex }.toSet())
|
BoardTargets(vertices = legal.filterIsInstance<BuildSettlement>().map { it.vertex }.toSet())
|
||||||
|
|
||||||
@@ -204,7 +271,11 @@ object CatanInteraction {
|
|||||||
} else {
|
} else {
|
||||||
emptySet()
|
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 ----------------------------------------------
|
// --- 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. */
|
/** Bank trades on offer in Cities & Knights: what can be given, and for what. */
|
||||||
fun goodsBankTrades(legal: List<CatanMove>): Map<Good, List<Good>> =
|
fun goodsBankTrades(legal: List<CatanMove>): Map<Good, List<Good>> =
|
||||||
legal.filterIsInstance<GoodsBankTrade>()
|
legal.filterIsInstance<GoodsBankTrade>()
|
||||||
@@ -396,6 +476,8 @@ object CatanInteraction {
|
|||||||
building: BuildKind?,
|
building: BuildKind?,
|
||||||
setupVertex: Int?,
|
setupVertex: Int?,
|
||||||
armed: ArmedCard? = null,
|
armed: ArmedCard? = null,
|
||||||
|
/** Seafarers: the ship picked up and waiting for a destination. */
|
||||||
|
movingShip: Int? = null,
|
||||||
): TapOutcome {
|
): TapOutcome {
|
||||||
if (legal.any { it is SetupPlace }) {
|
if (legal.any { it is SetupPlace }) {
|
||||||
if (setupVertex == null) return TapOutcome.Ignored
|
if (setupVertex == null) return TapOutcome.Ignored
|
||||||
@@ -406,21 +488,51 @@ object CatanInteraction {
|
|||||||
val play = armed.plays.firstOrNull { it.edge == edge } ?: return TapOutcome.Ignored
|
val play = armed.plays.firstOrNull { it.edge == edge } ?: return TapOutcome.Ignored
|
||||||
return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored
|
return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored
|
||||||
}
|
}
|
||||||
// Free roads need no build mode; a paid road does.
|
if (movingShip != null) {
|
||||||
val canBuild = building == BuildKind.ROAD || legal.any { it is EndFreeRoads }
|
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
|
if (!canBuild) return TapOutcome.Ignored
|
||||||
val move = BuildRoad(edge)
|
val move = BuildRoad(edge)
|
||||||
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
|
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)
|
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 moves = legal.filterIsInstance<MoveRobber>().filter { it.hex == coord }
|
||||||
|
val pirates = legal.filterIsInstance<MovePirate>().filter { it.hex == coord }
|
||||||
return when {
|
return when {
|
||||||
moves.isEmpty() -> TapOutcome.Ignored
|
moves.isNotEmpty() -> if (moves.size == 1) {
|
||||||
moves.size == 1 -> TapOutcome.Submit(moves.first())
|
TapOutcome.Submit(moves.first())
|
||||||
else -> TapOutcome.PickRobberVictim(coord, moves.mapNotNull { it.victim })
|
} 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()
|
val total = state.lastDice.sum()
|
||||||
if (total == 7) return emptySet()
|
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 }
|
.filter { state.numbers[it] == total && it != state.robber }
|
||||||
.toSet()
|
.toSet()
|
||||||
}
|
}
|
||||||
@@ -471,6 +583,7 @@ object CatanInteraction {
|
|||||||
|
|
||||||
fun canBuild(legal: List<CatanMove>): Set<BuildKind> = buildSet {
|
fun canBuild(legal: List<CatanMove>): Set<BuildKind> = buildSet {
|
||||||
if (legal.any { it is BuildRoad }) add(BuildKind.ROAD)
|
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 BuildSettlement }) add(BuildKind.SETTLEMENT)
|
||||||
if (legal.any { it is BuildCity }) add(BuildKind.CITY)
|
if (legal.any { it is BuildCity }) add(BuildKind.CITY)
|
||||||
if (legal.any { it is BuildWall }) add(BuildKind.WALL)
|
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.CITY -> CatanText.CITY
|
||||||
BuildKind.WALL -> CatanText.WALL
|
BuildKind.WALL -> CatanText.WALL
|
||||||
BuildKind.KNIGHT -> CatanText.KNIGHT
|
BuildKind.KNIGHT -> CatanText.KNIGHT
|
||||||
|
BuildKind.SHIP -> CatanText.SHIP
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildCost(kind: BuildKind): String = when (kind) {
|
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.CITY -> CatanText.CITY_COST
|
||||||
BuildKind.WALL -> CatanText.WALL_COST
|
BuildKind.WALL -> CatanText.WALL_COST
|
||||||
BuildKind.KNIGHT -> CatanText.KNIGHT_COST
|
BuildKind.KNIGHT -> CatanText.KNIGHT_COST
|
||||||
|
BuildKind.SHIP -> CatanText.SHIP_COST_TEXT
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
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.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -22,6 +31,7 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
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.GiveChore
|
||||||
import org.prolibertate.games.game.catan.GiveGoods
|
import org.prolibertate.games.game.catan.GiveGoods
|
||||||
import org.prolibertate.games.game.catan.MoveKnight
|
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.MoveRobber
|
||||||
import org.prolibertate.games.game.catan.PickDeckChore
|
import org.prolibertate.games.game.catan.PickDeckChore
|
||||||
import org.prolibertate.games.game.catan.RollDice
|
import org.prolibertate.games.game.catan.RollDice
|
||||||
@@ -102,8 +113,22 @@ fun CatanScreen(
|
|||||||
var knightMenu by remember { mutableStateOf<Int?>(null) }
|
var knightMenu by remember { mutableStateOf<Int?>(null) }
|
||||||
var movingKnight by remember { mutableStateOf<Int?>(null) }
|
var movingKnight by remember { mutableStateOf<Int?>(null) }
|
||||||
var armed by remember { mutableStateOf<ArmedCard?>(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 showImprove by remember { mutableStateOf(false) }
|
||||||
var showProgress 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 dark = isSystemInDarkTheme()
|
||||||
val style = remember(dark) { catanStyle(dark) }
|
val style = remember(dark) { catanStyle(dark) }
|
||||||
@@ -114,10 +139,12 @@ fun CatanScreen(
|
|||||||
// settlement that is no longer pending).
|
// settlement that is no longer pending).
|
||||||
LaunchedEffect(legal) {
|
LaunchedEffect(legal) {
|
||||||
if (building != null && building !in CatanInteraction.canBuild(legal)) building = null
|
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 }) {
|
if (setupVertex != null && legal.none { it is SetupPlace && it.vertex == setupVertex }) {
|
||||||
setupVertex = null
|
setupVertex = null
|
||||||
}
|
}
|
||||||
if (movingKnight != null && legal.none { it is MoveKnight && it.from == movingKnight }) movingKnight = 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 }
|
armed?.let { card -> if (card.plays.none { it in legal }) armed = null }
|
||||||
if (legal.isEmpty()) {
|
if (legal.isEmpty()) {
|
||||||
showCards = false
|
showCards = false
|
||||||
@@ -128,6 +155,7 @@ fun CatanScreen(
|
|||||||
knightMenu = null
|
knightMenu = null
|
||||||
movingKnight = null
|
movingKnight = null
|
||||||
armed = null
|
armed = null
|
||||||
|
staged = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +170,11 @@ fun CatanScreen(
|
|||||||
showProgress = false
|
showProgress = false
|
||||||
knightMenu = null
|
knightMenu = null
|
||||||
movingKnight = null
|
movingKnight = null
|
||||||
|
movingShip = null
|
||||||
armed = null
|
armed = null
|
||||||
|
staged = null
|
||||||
|
dragKind = null
|
||||||
|
dragAt = null
|
||||||
}
|
}
|
||||||
|
|
||||||
val endGameAction: @Composable () -> Unit = {
|
val endGameAction: @Composable () -> Unit = {
|
||||||
@@ -159,13 +191,17 @@ fun CatanScreen(
|
|||||||
return
|
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 ownKnights = current.ck?.knights?.filter { it.owner == localSeat }?.map { it.vertex }?.toSet().orEmpty()
|
||||||
val devHand = CatanInteraction.devHand(current, localSeat, legal)
|
val devHand = CatanInteraction.devHand(current, localSeat, legal)
|
||||||
val progressHand = CatanInteraction.progressHand(current, localSeat, legal)
|
val progressHand = CatanInteraction.progressHand(current, localSeat, legal)
|
||||||
val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty()
|
val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty()
|
||||||
val actions = CatanActions(
|
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,
|
ck = current.isCk,
|
||||||
canImprove = CatanInteraction.improvable(legal).isNotEmpty(),
|
canImprove = CatanInteraction.improvable(legal).isNotEmpty(),
|
||||||
canPlayProgress = progressHand.any { it.playable },
|
canPlayProgress = progressHand.any { it.playable },
|
||||||
@@ -189,16 +225,22 @@ fun CatanScreen(
|
|||||||
building = building,
|
building = building,
|
||||||
movingKnight = movingKnight != null,
|
movingKnight = movingKnight != null,
|
||||||
armed = armed?.kind,
|
armed = armed?.kind,
|
||||||
|
movingShip = movingShip != null,
|
||||||
)
|
)
|
||||||
|
|
||||||
val handle: (TapOutcome) -> Unit = { outcome ->
|
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
|
TapOutcome.Ignored -> Unit
|
||||||
is TapOutcome.Submit -> submit(outcome.move)
|
is TapOutcome.Submit -> submit(outcome.move)
|
||||||
is TapOutcome.PickSetupVertex -> setupVertex = outcome.vertex
|
is TapOutcome.PickSetupVertex -> setupVertex = outcome.vertex
|
||||||
is TapOutcome.PickRobberVictim -> victimChoice = outcome
|
is TapOutcome.PickRobberVictim -> victimChoice = outcome
|
||||||
is TapOutcome.PickKnight -> knightMenu = outcome.vertex
|
is TapOutcome.PickKnight -> knightMenu = outcome.vertex
|
||||||
|
is TapOutcome.PickShip -> movingShip = outcome.edge
|
||||||
is TapOutcome.PickFirstHex -> armed = armed?.copy(firstHex = outcome.hex)
|
is TapOutcome.PickFirstHex -> armed = armed?.copy(firstHex = outcome.hex)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,10 +252,14 @@ fun CatanScreen(
|
|||||||
CompositionLocalProvider(LocalCatanStyle provides style) {
|
CompositionLocalProvider(LocalCatanStyle provides style) {
|
||||||
ScreenScaffold(title = CatanText.TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
ScreenScaffold(title = CatanText.TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||||
BoxWithConstraints(modifier = modifier) {
|
BoxWithConstraints(modifier = modifier) {
|
||||||
val wide = maxWidth > maxHeight * 1.1f
|
|
||||||
// Phones (either way up) get the one-button build menu.
|
// Phones (either way up) get the one-button build menu.
|
||||||
val compactBuild = !wide || maxWidth < 600.dp
|
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 ->
|
val board: @Composable (Modifier) -> Unit = { boardModifier ->
|
||||||
CatanBoardView(
|
CatanBoardView(
|
||||||
state = current,
|
state = current,
|
||||||
@@ -227,12 +273,15 @@ fun CatanScreen(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onEdgeTap = { handle(CatanInteraction.onEdgeTap(it, legal, building, setupVertex, armed)) },
|
onEdgeTap = { handle(CatanInteraction.onEdgeTap(it, legal, building, setupVertex, armed, movingShip)) },
|
||||||
onHexTap = { handle(CatanInteraction.onHexTap(it, legal, armed)) },
|
onHexTap = { handle(CatanInteraction.onHexTap(it, legal, armed, current.topo)) },
|
||||||
modifier = boardModifier,
|
modifier = boardModifier,
|
||||||
style = style,
|
style = style,
|
||||||
rolledHexes = rolledHexes,
|
rolledHexes = rolledHexes,
|
||||||
selectedKnight = movingKnight ?: knightMenu,
|
selectedKnight = movingKnight ?: knightMenu,
|
||||||
|
stagedVertex = ghostVertex,
|
||||||
|
stagedEdge = ghostEdge,
|
||||||
|
stagedSeat = localSeat,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val players: @Composable (Boolean) -> Unit = { grid ->
|
val players: @Composable (Boolean) -> Unit = { grid ->
|
||||||
@@ -265,21 +314,61 @@ fun CatanScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (wide) {
|
if (wide) {
|
||||||
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
CatanHud(
|
||||||
Box(Modifier.weight(1.5f).fillMaxSize(), contentAlignment = Alignment.Center) {
|
state = current,
|
||||||
board(Modifier)
|
seats = seats,
|
||||||
}
|
style = style,
|
||||||
Column(
|
actions = actions,
|
||||||
modifier = Modifier.weight(1f).fillMaxSize().verticalScroll(rememberScrollState()),
|
prompt = prompt,
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
notice = notice,
|
||||||
) {
|
legal = legal,
|
||||||
players(true)
|
localSeat = localSeat,
|
||||||
if (current.isCk) CatanBarbarians(current, style = style)
|
targets = targets,
|
||||||
promptRow()
|
pointsOf = pointsOf,
|
||||||
handAndActions()
|
staged = staged,
|
||||||
CatanLog(current, Modifier.fillMaxWidth().height(120.dp))
|
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 {
|
} else {
|
||||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
players(false)
|
players(false)
|
||||||
@@ -291,6 +380,39 @@ fun CatanScreen(
|
|||||||
handAndActions()
|
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(
|
CatanDialogHost(
|
||||||
@@ -432,7 +554,6 @@ private fun CatanDialogHost(
|
|||||||
onCancelCards: () -> Unit,
|
onCancelCards: () -> Unit,
|
||||||
onCancelTrade: () -> Unit,
|
onCancelTrade: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val offer = state.pendingTrade
|
|
||||||
val goodsOffer = state.ck?.goodsOffer
|
val goodsOffer = state.ck?.goodsOffer
|
||||||
when {
|
when {
|
||||||
abandoned != null -> CatanAbandonedDialog(abandoned, onExit, onStay)
|
abandoned != null -> CatanAbandonedDialog(abandoned, onExit, onStay)
|
||||||
@@ -450,6 +571,9 @@ private fun CatanDialogHost(
|
|||||||
onSubmit = onSubmit,
|
onSubmit = onSubmit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
state.phase == CatanPhase.GOLD && CatanRules.currentSeat(state) == localSeat && legal.isNotEmpty() ->
|
||||||
|
CatanGoldDialog(legal, onSubmit)
|
||||||
|
|
||||||
state.phase == CatanPhase.CHORE && CatanRules.currentSeat(state) == localSeat ->
|
state.phase == CatanPhase.CHORE && CatanRules.currentSeat(state) == localSeat ->
|
||||||
ChoreDialog(state, localSeat, legal, onSubmit)
|
ChoreDialog(state, localSeat, legal, onSubmit)
|
||||||
|
|
||||||
@@ -473,18 +597,6 @@ private fun CatanDialogHost(
|
|||||||
onNoDeal = { onSubmit(DismissCounters) },
|
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() ->
|
state.phase == CatanPhase.TRADE_COUNTERS && state.turn == localSeat && legal.isNotEmpty() ->
|
||||||
CatanCounterChoiceDialog(
|
CatanCounterChoiceDialog(
|
||||||
counters = CatanInteraction.counterChoices(state, legal),
|
counters = CatanInteraction.counterChoices(state, legal),
|
||||||
@@ -497,7 +609,15 @@ private fun CatanDialogHost(
|
|||||||
CatanVictimDialog(
|
CatanVictimDialog(
|
||||||
victims = victimChoice.victims,
|
victims = victimChoice.victims,
|
||||||
seats = seats,
|
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,
|
onCancel = onCancelVictim,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -541,17 +661,6 @@ private fun CatanDialogHost(
|
|||||||
onOffer = onSubmit,
|
onOffer = onSubmit,
|
||||||
onCancel = onCancelTrade,
|
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
|
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 citySize: Float = 0.42f,
|
||||||
val tokenRadius: Float = 0.36f,
|
val tokenRadius: Float = 0.36f,
|
||||||
val robberSize: Float = 0.34f,
|
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 portRadius: Float = 0.46f,
|
||||||
val targetRadius: Float = 0.20f,
|
val targetRadius: Float = 0.20f,
|
||||||
/** How close a tap must land to a vertex / edge / hex centre to count. */
|
/** 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.HILLS to Color(0xFFB4532F),
|
||||||
Terrain.MOUNTAINS to Color(0xFF7C8794),
|
Terrain.MOUNTAINS to Color(0xFF7C8794),
|
||||||
Terrain.DESERT to Color(0xFFE6D3A0),
|
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),
|
hexEdge = Color(0x55000000),
|
||||||
token = Color(0xFFF7EFD9),
|
token = Color(0xFFF7EFD9),
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ object CatanText {
|
|||||||
Terrain.HILLS -> "Hills"
|
Terrain.HILLS -> "Hills"
|
||||||
Terrain.MOUNTAINS -> "Mountains"
|
Terrain.MOUNTAINS -> "Mountains"
|
||||||
Terrain.DESERT -> "Desert"
|
Terrain.DESERT -> "Desert"
|
||||||
|
Terrain.SEA -> "Sea"
|
||||||
|
Terrain.GOLD -> "Gold field"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun devCard(type: DevCardType): String = when (type) {
|
fun devCard(type: DevCardType): String = when (type) {
|
||||||
@@ -108,6 +110,11 @@ object CatanText {
|
|||||||
|
|
||||||
// --- Buttons and headings --------------------------------------------------
|
// --- 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 ROLL = "Roll dice"
|
||||||
const val END_TURN = "End turn"
|
const val END_TURN = "End turn"
|
||||||
const val ROAD = "Road"
|
const val ROAD = "Road"
|
||||||
@@ -139,6 +146,28 @@ object CatanText {
|
|||||||
const val YOU_GIVE = "You give"
|
const val YOU_GIVE = "You give"
|
||||||
const val YOU_GET = "You get"
|
const val YOU_GET = "You get"
|
||||||
const val SEND_OFFER = "Send offer"
|
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 NO_LOG = "Nothing has happened yet."
|
||||||
const val END_GAME = "End game"
|
const val END_GAME = "End game"
|
||||||
const val LOOK_AT_BOARD = "Look at the board"
|
const val LOOK_AT_BOARD = "Look at the board"
|
||||||
@@ -161,6 +190,8 @@ object CatanText {
|
|||||||
movingKnight: Boolean = false,
|
movingKnight: Boolean = false,
|
||||||
/** Cities & Knights: a progress card is waiting for a tap on the board. */
|
/** Cities & Knights: a progress card is waiting for a tap on the board. */
|
||||||
armed: ProgressType? = null,
|
armed: ProgressType? = null,
|
||||||
|
/** Seafarers: a ship has been picked to sail, and its destination is awaited. */
|
||||||
|
movingShip: Boolean = false,
|
||||||
): String {
|
): String {
|
||||||
if (state.isCk) return ckPrompt(state, seats, setupVertexChosen, building, movingKnight, armed)
|
if (state.isCk) return ckPrompt(state, seats, setupVertexChosen, building, movingKnight, armed)
|
||||||
val me = seats.me
|
val me = seats.me
|
||||||
@@ -198,10 +229,23 @@ object CatanText {
|
|||||||
}
|
}
|
||||||
|
|
||||||
CatanPhase.ROBBER ->
|
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 {
|
CatanPhase.PLAYING -> when {
|
||||||
!mine -> "$turn is playing…"
|
!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.ROAD -> "Tap an edge to build a road."
|
||||||
building == BuildKind.SETTLEMENT -> "Tap an empty crossing to build a settlement."
|
building == BuildKind.SETTLEMENT -> "Tap an empty crossing to build a settlement."
|
||||||
building == BuildKind.CITY -> "Tap one of your settlements to upgrade it."
|
building == BuildKind.CITY -> "Tap one of your settlements to upgrade it."
|
||||||
@@ -364,6 +408,8 @@ object CatanText {
|
|||||||
val ck = state.ck!!
|
val ck = state.ck!!
|
||||||
|
|
||||||
return when (state.phase) {
|
return when (state.phase) {
|
||||||
|
CatanPhase.GOLD -> ""
|
||||||
|
|
||||||
CatanPhase.GAME_OVER -> {
|
CatanPhase.GAME_OVER -> {
|
||||||
val winner = state.winner
|
val winner = state.winner
|
||||||
if (winner == null) "The game is over." else "${seats.label(winner)} won the game."
|
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
|
@Test
|
||||||
fun `every reference game in the catalogue can be played`() {
|
fun `every game in the catalogue can be played`() {
|
||||||
// Every game the app ships is playable. The one exception is Katan,
|
// `available` is the release gate. Nothing is held back at the moment,
|
||||||
// listed so the slot exists and marked *Coming soon* until its engine
|
// so a production menu lists every game; a game parked behind the gate
|
||||||
// and screen land — `available = false` is what keeps an unfinished
|
// belongs in `devPreview`, and must say so rather than being quietly
|
||||||
// game out of a production build while leaving it visible on a dev one.
|
// unavailable everywhere.
|
||||||
assertEquals(
|
GameCatalog.all.filterNot { it.available }.forEach { game ->
|
||||||
listOf(GameCatalog.CATAN),
|
assertTrue("${game.id} is unavailable but not a dev preview", game.devPreview)
|
||||||
GameCatalog.all.filterNot { it.available }.map { it.id },
|
}
|
||||||
)
|
GameMenu.entries.forEach { menu ->
|
||||||
// It is also the only difference the two channels see in a menu.
|
val held = GameCatalog.inMenu(menu, includeComingSoon = true) -
|
||||||
val hiddenFromProduction = GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = true) -
|
GameCatalog.inMenu(menu, includeComingSoon = false).toSet()
|
||||||
GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = false)
|
held.forEach { assertTrue("${it.id} hidden from production without a preview flag", it.devPreview) }
|
||||||
assertEquals(listOf(GameCatalog.byId(GameCatalog.CATAN)!!), hiddenFromProduction)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a preview game opens on a dev build and nowhere else`() {
|
fun `a preview game opens on a dev build and nowhere else`() {
|
||||||
val preview = GameCatalog.all.filter { it.devPreview }
|
GameCatalog.all.filter { it.devPreview }.forEach { game ->
|
||||||
assertTrue("Katan is the preview game", preview.map { it.id } == listOf(GameCatalog.CATAN))
|
|
||||||
preview.forEach { game ->
|
|
||||||
assertFalse("${game.id}: preview is for games that are not yet available", game.available)
|
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))
|
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))
|
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.
|
// An ordinary finished game opens everywhere.
|
||||||
val chess = GameCatalog.byId(GameCatalog.CHESS)!!
|
val chess = GameCatalog.byId(GameCatalog.CHESS)!!
|
||||||
assertTrue(chess.playableIn(devBuild = true) && chess.playableIn(devBuild = false))
|
assertTrue(chess.playableIn(devBuild = true) && chess.playableIn(devBuild = false))
|
||||||
@@ -174,6 +177,8 @@ class CatalogTest {
|
|||||||
GameCatalog.MASTERMIND,
|
GameCatalog.MASTERMIND,
|
||||||
GameCatalog.BACKGAMMON,
|
GameCatalog.BACKGAMMON,
|
||||||
GameCatalog.PIRATES,
|
GameCatalog.PIRATES,
|
||||||
|
GameCatalog.CATAN,
|
||||||
|
GameCatalog.MEOWDOKU,
|
||||||
),
|
),
|
||||||
GameCatalog.playable.map { it.id }.toSet(),
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
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
|
@Test
|
||||||
fun `tally entries add up per player and can be taken back`() {
|
fun `tally entries add up per player and can be taken back`() {
|
||||||
val sheet = ScoreSheet.of(2, ScoringMode.TALLY)
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+149
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -24,9 +24,10 @@ Naming: the catalogue id, lowercase, e.g. `crazy8s` → `RULES-crazy8s.md`.
|
|||||||
| Golf | [RULES-golf.md](RULES-golf.md) |
|
| Golf | [RULES-golf.md](RULES-golf.md) |
|
||||||
| Hearts | — (TODO) |
|
| Hearts | — (TODO) |
|
||||||
| Kaiser | [RULES-kaiser.md](RULES-kaiser.md) |
|
| 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) |
|
| Klondike | — (TODO) |
|
||||||
| Mastermind | [RULES-mastermind.md](RULES-mastermind.md) |
|
| Mastermind | [RULES-mastermind.md](RULES-mastermind.md) |
|
||||||
|
| Meowdoku | [RULES-meowdoku.md](RULES-meowdoku.md) |
|
||||||
| Morris | [RULES-morris.md](RULES-morris.md) |
|
| Morris | [RULES-morris.md](RULES-morris.md) |
|
||||||
| Peg solitaire | — (TODO) |
|
| Peg solitaire | — (TODO) |
|
||||||
| Pirates | [RULES-pirates.md](RULES-pirates.md) |
|
| Pirates | [RULES-pirates.md](RULES-pirates.md) |
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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.
|
||||||
@@ -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) |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **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).
|
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).
|
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.
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user