Compare commits
23
Commits
33a253d80c
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e66567643 | ||
|
|
a8ff9863a6 | ||
|
|
bd12a7a70b | ||
|
|
e370aa0ceb | ||
|
|
59a6ffc940 | ||
|
|
98506c05b7 | ||
|
|
f9b793d56e | ||
|
|
c8368416c4 | ||
|
|
e344d1d817 | ||
|
|
d88083679d | ||
|
|
2d85df0de0 | ||
|
|
d7d3c8a3ed | ||
|
|
12fec14034 | ||
|
|
5e84119670 | ||
|
|
b90058afda | ||
|
|
ef69704e00 | ||
|
|
3b0b307997 | ||
|
|
87b00ad84f | ||
|
|
30dcf026bd | ||
|
|
a778e4732f | ||
|
|
fb59372802 | ||
|
|
653e7a17a3 | ||
|
|
3a930f5423 |
@@ -1,78 +1,61 @@
|
|||||||
> **Moved to Gitea.** CI now runs on Gitea Actions at
|
|
||||||
> `git.wallacenet.ca/devadmin/ProLibertateGames`, publishing to Cloudflare Pages
|
|
||||||
> (`plg.wallacenet.ca`) only. The sandbox notes below describe the earlier GitHub
|
|
||||||
> setup (no CI run from session pushes, `workflow_dispatch` as the workaround);
|
|
||||||
> re-verify them against Gitea before relying on them.
|
|
||||||
|
|
||||||
# Working on this repo with Claude
|
# Working on this repo with Claude
|
||||||
|
|
||||||
## Nothing a session does triggers CI. Dispatch it explicitly.
|
CI runs on **Gitea Actions** at `git.wallacenet.ca/devadmin/ProLibertateGames`
|
||||||
|
(workflows in `.github/workflows/`), publishing to Cloudflare Pages
|
||||||
|
(`plg.wallacenet.ca`). The workflow below was measured on the dev machine
|
||||||
|
(`~/ProLibertateGames`); it replaces the earlier GitHub-sandbox notes, which no
|
||||||
|
longer apply.
|
||||||
|
|
||||||
GitHub creates **no workflow run** for anything this sandbox does — pushing a
|
## Build, test and lint locally
|
||||||
branch, opening a pull request, or merging one. All of it goes through the same
|
|
||||||
integration, and GitHub suppresses event-triggered runs from it. The pull
|
|
||||||
request will say it was opened by the repo owner; that is only how the UI
|
|
||||||
attributes it, and it does not change the suppression.
|
|
||||||
|
|
||||||
This was measured, not assumed:
|
Gradle works on the dev machine. Two environment variables are required: the
|
||||||
|
default JDK is 25, which Gradle 8.14 cannot use, and the Android SDK lives in the
|
||||||
|
home directory.
|
||||||
|
|
||||||
| Action | Result |
|
```
|
||||||
| --- | --- |
|
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
|
||||||
| Human pushes a Claude-authored commit (`d165e5ef`, `469c900e`) | CI runs |
|
export ANDROID_HOME=$HOME/android-sdk # platform 34, build-tools 34.0.0
|
||||||
| Session pushes `dev` / `main` (`43394b8`, `742add4`) | no run |
|
./gradlew testDebugUnitTest lintDebug assembleDebug
|
||||||
| Session opens a PR against `dev` (#1) | no run |
|
```
|
||||||
| Session calls `workflow_dispatch` | **runs** |
|
|
||||||
|
|
||||||
`workflow_dispatch` works because it creates a run directly instead of relying
|
The Katan tests run in seconds; a clean build takes a few minutes. Run the full
|
||||||
on an event. It is the only mechanism available from a session, so every build
|
suite, lint and `assembleDebug` before pushing: they are what CI runs. There is
|
||||||
has to be asked for by hand.
|
no KVM, so no emulator; the engine and the UI logic are covered by unit tests
|
||||||
|
(see `docs/UI-catan.md`).
|
||||||
|
|
||||||
## Verify before merging, without publishing
|
## Shipping: branch, PR, CI, merge
|
||||||
|
|
||||||
`ci.yml` builds, tests and lints on any ref, but its staging and publishing
|
1. Branch from `dev`; before pushing, merge `origin/dev` in (it moves between
|
||||||
steps are gated on `github.ref == 'refs/heads/dev'`. Dispatching it on a
|
sessions).
|
||||||
**feature branch** therefore gives the full check — build, unit tests, Android
|
2. Plain `git push` has no credentials. Use the Gitea token as a one-off header:
|
||||||
lint — and publishes nothing.
|
`git -c "http.extraheader=Authorization: token $(cat ~/.config/gitea/token)" push -u origin <branch>`.
|
||||||
|
3. Open the PR with `POST /api/v1/repos/devadmin/ProLibertateGames/pulls`
|
||||||
So the working loop is:
|
(same token, `Authorization: token ...`). CI (`ci.yml`) runs on the PR: build,
|
||||||
|
unit tests and Android lint; it publishes nothing. Poll
|
||||||
1. Commit and push the feature branch.
|
`/actions/runs` until it completes (about 7 minutes).
|
||||||
2. Dispatch `ci.yml` on that branch. Wait for green. This is the real review
|
4. Merge with `POST .../pulls/N/merge {"Do":"merge"}`. The push to `dev` runs
|
||||||
gate, and it is the only verification that exists (see below).
|
`ci.yml` again and publishes the build (about 9 minutes).
|
||||||
3. Open the PR through the API and merge it there, so the change is reviewable
|
|
||||||
and the merge commit is honest about what happened. Neither step builds
|
|
||||||
anything.
|
|
||||||
4. Dispatch the target branch's workflow to actually build and publish:
|
|
||||||
`ci.yml` on `dev`, `release.yml` on `main`.
|
|
||||||
|
|
||||||
Skipping step 4 leaves a branch whose head was never built and no release.
|
|
||||||
|
|
||||||
## Merging is a shipping action
|
## Merging is a shipping action
|
||||||
|
|
||||||
Both long-lived branches publish, so a merge is a release, not just an
|
Both long-lived branches publish, so a merge is a release, not just an
|
||||||
integration:
|
integration:
|
||||||
|
|
||||||
- **`dev`** → `ci.yml` publishes a `v<series>.N-dev` **prerelease**, offered by the
|
- **`dev`** publishes a `v<series>.N-dev` build to
|
||||||
in-app updater to anyone on the Dev channel.
|
`https://plg.wallacenet.ca/dev/latest.json`, offered by the in-app updater to
|
||||||
- **`main`** → `release.yml` publishes a **production** release, which the
|
anyone on the Dev channel.
|
||||||
updater installs for everyone on the Production channel.
|
- **`main`** runs `release.yml` and publishes a **production** release for
|
||||||
|
everyone on the Production channel. Confirm before merging to `main`.
|
||||||
|
`release.yml` runs `assembleDebug` only (no tests, no lint), so a `main`
|
||||||
|
release is only as verified as the `dev` build it replays.
|
||||||
|
|
||||||
Confirm before shipping to `main`. Note that `release.yml` runs `assembleDebug`
|
## Katan
|
||||||
only — no tests, no lint — so a `main` release is only as verified as the `dev`
|
|
||||||
build it replays.
|
|
||||||
|
|
||||||
## Gradle does not run in the sandbox
|
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
|
||||||
`./gradlew` cannot resolve the Android plugin here: the proxy blocks
|
(`docs/RULES-catan-ck.md`, code in `game/catan/CatanCk*.kt`, state behind
|
||||||
`dl.google.com` with a 403. No local build, no local test run, no local lint. A
|
`CatanState.ck`, which is null in the base game). The screen is layered; see
|
||||||
dispatched CI run is the only way to find out whether a change compiles.
|
`docs/UI-catan.md` before changing it.
|
||||||
|
|
||||||
## A permanent fix, if you want one
|
|
||||||
|
|
||||||
The suppression is a property of the sandbox's credential, so it cannot be
|
|
||||||
fixed from inside a session. Giving the environment a personal access token to
|
|
||||||
push with would make pushes and PRs behave normally for everyone. Until then,
|
|
||||||
the dispatch step above is the workaround.
|
|
||||||
|
|
||||||
## Bash output is filtered before it reaches the model
|
## Bash output is filtered before it reaches the model
|
||||||
|
|
||||||
|
|||||||
@@ -372,6 +372,10 @@ game:
|
|||||||
- [Ta Yü](docs/RULES-tayu.md) — **note the caveats about the reconstruction**:
|
- [Ta Yü](docs/RULES-tayu.md) — **note the caveats about the reconstruction**:
|
||||||
the game is out of print and the rulebook could not be obtained, so the
|
the game is out of print and the rulebook could not be obtained, so the
|
||||||
document says which parts are attested and which were derived
|
document says which parts are attested and which were derived
|
||||||
|
- [Katan](docs/RULES-catan.md) — the island game (catalogue id `catan`); the
|
||||||
|
rules engine, AI and screen are in place
|
||||||
|
- [Katan: Cities & Knights](docs/RULES-catan-ck.md) — the expansion rule set, chosen on
|
||||||
|
the setup screen: commodities, knights, barbarians, city improvements and progress cards
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
@@ -394,6 +398,7 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested
|
|||||||
mastermind/ Mastermind codes, scoring, and a code breaker
|
mastermind/ Mastermind codes, scoring, and a code breaker
|
||||||
pirates/ Pirates and Bulgars — the cross board, the hunt, search
|
pirates/ Pirates and Bulgars — the cross board, the hunt, search
|
||||||
tayu/ Ta Yü tile geometry, river rules, scoring, AI
|
tayu/ Ta Yü tile geometry, river rules, scoring, AI
|
||||||
|
catan/ Katan island geometry, production, trade, dev cards, Cities & Knights, heuristic AI
|
||||||
net/ wire protocol, LAN transport, lobby, match driver
|
net/ wire protocol, LAN transport, lobby, match driver
|
||||||
score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store
|
score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store
|
||||||
settings/ DataStore-backed preferences
|
settings/ DataStore-backed preferences
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ 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
|
||||||
import org.prolibertate.games.update.Updater
|
import org.prolibertate.games.update.Updater
|
||||||
|
import java.io.File
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import org.prolibertate.games.game.engine.SavedGameStore
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
@@ -40,6 +42,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
deviceId = peerId,
|
deviceId = peerId,
|
||||||
),
|
),
|
||||||
peerId = peerId,
|
peerId = peerId,
|
||||||
|
saves = SavedGameStore(File(filesDir, "saved-games")),
|
||||||
)
|
)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
|
|||||||
@@ -42,7 +42,20 @@ data class GameDescriptor(
|
|||||||
val teamBased: Boolean,
|
val teamBased: Boolean,
|
||||||
val blurb: String,
|
val blurb: String,
|
||||||
val available: Boolean,
|
val available: Boolean,
|
||||||
|
/**
|
||||||
|
* Playable on a dev build while still hidden from production.
|
||||||
|
*
|
||||||
|
* [available] is the release gate: it is what a production build lists and
|
||||||
|
* lets people open. A game that has an engine and a screen but has not yet
|
||||||
|
* been verified on real devices sets this instead, so it can be played and
|
||||||
|
* tested from the dev channel without being offered to everyone. Flip
|
||||||
|
* [available] on (and this off) once it has been verified.
|
||||||
|
*/
|
||||||
|
val devPreview: Boolean = false,
|
||||||
) {
|
) {
|
||||||
|
/** Whether the menu should let this game be opened in the given kind of build. */
|
||||||
|
fun playableIn(devBuild: Boolean): Boolean = available || (devBuild && devPreview)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The one menu this game is listed under.
|
* The one menu this game is listed under.
|
||||||
*
|
*
|
||||||
@@ -94,6 +107,13 @@ object GameCatalog {
|
|||||||
const val MORRIS = "morris"
|
const val MORRIS = "morris"
|
||||||
const val PIRATES = "pirates"
|
const val PIRATES = "pirates"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The island game the collection is named for. Listed under Board Games
|
||||||
|
* and marked *Coming soon*; the engine and its screen land with the game
|
||||||
|
* task, and `available` flips on once two-device play is verified.
|
||||||
|
*/
|
||||||
|
const val CATAN = "catan"
|
||||||
|
|
||||||
val all: List<GameDescriptor> = listOf(
|
val all: List<GameDescriptor> = listOf(
|
||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = EUCHRE,
|
id = EUCHRE,
|
||||||
@@ -333,6 +353,21 @@ object GameCatalog {
|
|||||||
"because your score multiplies them.",
|
"because your score multiplies them.",
|
||||||
available = true,
|
available = true,
|
||||||
),
|
),
|
||||||
|
GameDescriptor(
|
||||||
|
id = CATAN,
|
||||||
|
title = "Katan",
|
||||||
|
menus = setOf(GameMenu.BOARD),
|
||||||
|
minPlayers = 3,
|
||||||
|
maxPlayers = 4,
|
||||||
|
teamBased = false,
|
||||||
|
blurb = "Trade, build, and settle the island, in the base game or Cities & Knights.",
|
||||||
|
// Engine and screen exist but have not been verified on two real
|
||||||
|
// devices yet. Dev builds can open it as a *Preview*; a production
|
||||||
|
// build does not list it. Flip `available` on, and `devPreview`
|
||||||
|
// off, once two-device play has been verified.
|
||||||
|
available = false,
|
||||||
|
devPreview = true,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun byId(id: String): GameDescriptor? = all.firstOrNull { it.id == id }
|
fun byId(id: String): GameDescriptor? = all.firstOrNull { it.id == id }
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
package org.prolibertate.games.game.catan
|
||||||
|
|
||||||
|
import org.prolibertate.games.game.engine.GameAi
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A heuristic Catan opponent.
|
||||||
|
*
|
||||||
|
* It plays a legal, mildly strategic game: it settles on the best-producing
|
||||||
|
* intersections it can reach, raises cities, chases Largest Army with its
|
||||||
|
* knights, spends its progress cards when they complete something, trades
|
||||||
|
* its surplus to the bank or the table to close a one-card gap, and robs
|
||||||
|
* whoever is winning.
|
||||||
|
*
|
||||||
|
* Honesty is a design constraint, not an accident: this runs on the host,
|
||||||
|
* where the authoritative state is fully visible. It reads only public
|
||||||
|
* information — terrain, numbers, buildings, roads, resource counts,
|
||||||
|
* dev-card counts, knights played — plus its own hand, and never looks
|
||||||
|
* into the deck, another seat's development cards, or the seed beyond the
|
||||||
|
* roll the rules derive from it.
|
||||||
|
*/
|
||||||
|
class CatanAi : GameAi<CatanState, CatanMove> {
|
||||||
|
|
||||||
|
/** Plays the Cities & Knights tables; this class keeps the base game. */
|
||||||
|
private val ck = CatanCkAi(this)
|
||||||
|
|
||||||
|
override fun chooseMove(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
|
||||||
|
require(legal.isNotEmpty() || state.phase == CatanPhase.PLAYING) {
|
||||||
|
"No legal move for seat $seat"
|
||||||
|
}
|
||||||
|
if (state.isCk) return ck.choose(state, seat, legal)
|
||||||
|
return when (state.phase) {
|
||||||
|
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND ->
|
||||||
|
chooseSetup(state, seat, legal.filterIsInstance<SetupPlace>())
|
||||||
|
|
||||||
|
CatanPhase.AWAIT_ROLL -> legal.first { it is RollDice }
|
||||||
|
|
||||||
|
CatanPhase.DISCARDING -> chooseDiscard(state, seat, legal.filterIsInstance<Discard>())
|
||||||
|
|
||||||
|
CatanPhase.ROBBER -> chooseRobber(state, seat, legal.filterIsInstance<MoveRobber>())
|
||||||
|
|
||||||
|
CatanPhase.FREE_ROADS -> chooseFreeRoad(state, seat, legal)
|
||||||
|
|
||||||
|
CatanPhase.TRADE_PENDING -> chooseResponse(state, seat, legal)
|
||||||
|
|
||||||
|
CatanPhase.TRADE_COUNTERS -> chooseCounter(state, legal)
|
||||||
|
|
||||||
|
CatanPhase.PLAYING -> choosePlaying(state, seat, legal)
|
||||||
|
|
||||||
|
CatanPhase.CHORE, CatanPhase.GAME_OVER -> legal.firstOrNull()
|
||||||
|
?: error("Nothing to choose in phase ${state.phase}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Setup
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
internal fun chooseSetup(state: CatanState, seat: Int, places: List<SetupPlace>): SetupPlace {
|
||||||
|
require(places.isNotEmpty()) { "No legal settlement placement" }
|
||||||
|
return places.maxByOrNull { place ->
|
||||||
|
spotValue(state, place.vertex) +
|
||||||
|
0.25 * placeDirection(state, place)
|
||||||
|
} ?: places.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the setup road points: the best settlement spot two edges out
|
||||||
|
* through the far end of the road. The vertex next to the new
|
||||||
|
* settlement can never hold one (the distance rule), so what a road is
|
||||||
|
* worth is what it reaches past it.
|
||||||
|
*/
|
||||||
|
private fun placeDirection(state: CatanState, place: SetupPlace): Double {
|
||||||
|
val far = CatanBoard.otherEnd(place.edge, place.vertex)
|
||||||
|
return CatanBoard.VERTEX_NEIGHBORS[far]
|
||||||
|
.filter { it != place.vertex && it !in CatanBoard.VERTEX_NEIGHBORS[place.vertex] }
|
||||||
|
.maxOfOrNull { spotValue(state, it) }
|
||||||
|
?: 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Dice, discards and the robber
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun chooseDiscard(state: CatanState, seat: Int, discards: List<Discard>): Discard {
|
||||||
|
require(discards.isNotEmpty()) { "No legal discard" }
|
||||||
|
// Throw away what it wants least; grain and ore are the city, wool
|
||||||
|
// is mostly a dev-card tax.
|
||||||
|
return discards.minByOrNull { discard ->
|
||||||
|
discard.cards.sumOf { want(it) }
|
||||||
|
} ?: discards.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chooseRobber(state: CatanState, seat: Int, moves: List<MoveRobber>): MoveRobber {
|
||||||
|
require(moves.isNotEmpty()) { "No legal robber move" }
|
||||||
|
val points = CatanRules.publicVictoryPoints(state)
|
||||||
|
return moves.maxByOrNull { move ->
|
||||||
|
val hex = CatanBoard.hexIndex.getValue(move.hex)
|
||||||
|
var score = 0.0
|
||||||
|
for (v in CatanBoard.HEX_CORNERS[hex]) {
|
||||||
|
val building = state.buildings[v] ?: continue
|
||||||
|
if (building.owner == seat) continue
|
||||||
|
val worth = if (building.type == BuildingType.CITY) 2 else 1
|
||||||
|
score += worth * CatanBoard.numberOdds(state.numbers[hex]) / 36.0
|
||||||
|
}
|
||||||
|
if (move.victim != null) {
|
||||||
|
score += 0.15 * state.resourceCount(move.victim) +
|
||||||
|
0.3 * points[move.victim]
|
||||||
|
}
|
||||||
|
score
|
||||||
|
} ?: moves.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// The main phase
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun choosePlaying(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
|
||||||
|
// A city doubles the best production the seat already owns.
|
||||||
|
legal.filterIsInstance<BuildCity>().maxByOrNull {
|
||||||
|
spotValue(state, it.vertex)
|
||||||
|
}?.let { return it }
|
||||||
|
|
||||||
|
legal.filterIsInstance<BuildSettlement>().maxByOrNull {
|
||||||
|
spotValue(state, it.vertex)
|
||||||
|
}?.let { return it }
|
||||||
|
|
||||||
|
// A knight is worth playing whenever one is playable: it blocks an
|
||||||
|
// opponent's best hex and marches toward Largest Army.
|
||||||
|
legal.filterIsInstance<PlayKnight>().firstOrNull()?.let { return it }
|
||||||
|
|
||||||
|
yearOfPlenty(state, legal)?.let { return it }
|
||||||
|
|
||||||
|
monopoly(state, legal)?.let { return it }
|
||||||
|
|
||||||
|
// Road Building is worth two free segments whenever there is
|
||||||
|
// somewhere to put them.
|
||||||
|
if (legal.filterIsInstance<PlayRoadBuilding>().isNotEmpty() &&
|
||||||
|
legal.filterIsInstance<BuildRoad>().size >= 2
|
||||||
|
) {
|
||||||
|
return legal.filterIsInstance<PlayRoadBuilding>().first()
|
||||||
|
}
|
||||||
|
|
||||||
|
legal.filterIsInstance<BuyDevCard>().firstOrNull()?.let { return it }
|
||||||
|
|
||||||
|
// A paid road only when it reaches toward a spot worth settling.
|
||||||
|
legal.filterIsInstance<BuildRoad>()
|
||||||
|
.filter { roadValue(state, it) > 0.1 }
|
||||||
|
.maxByOrNull { roadValue(state, it) }
|
||||||
|
?.let { return it }
|
||||||
|
|
||||||
|
// A bank trade only when it completes a build this turn; trading
|
||||||
|
// four for one on spec is how hands evaporate.
|
||||||
|
legal.filterIsInstance<MaritimeTrade>()
|
||||||
|
.filter { completesBuild(state, seat, it) }
|
||||||
|
.maxByOrNull { CatanRules.bestPortRatio(state, seat, it.give) * -1.0 }
|
||||||
|
?.let { return it }
|
||||||
|
|
||||||
|
offer(state, seat)?.let { return it }
|
||||||
|
|
||||||
|
return legal.lastOrNull { it is EndTurn } ?: legal.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Year of Plenty: two of whatever the nearest build is shortest of. */
|
||||||
|
private fun yearOfPlenty(state: CatanState, legal: List<CatanMove>): PlayYearOfPlenty? {
|
||||||
|
val takes = legal.filterIsInstance<PlayYearOfPlenty>()
|
||||||
|
if (takes.isEmpty()) return null
|
||||||
|
val shortage = shortage(state) ?: return null
|
||||||
|
return takes.maxByOrNull { take ->
|
||||||
|
take.take.count { it == shortage } * 2 + take.take.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Monopoly: declare the resource everyone else is hoarding, of the ones wanted. */
|
||||||
|
private fun monopoly(state: CatanState, legal: List<CatanMove>): PlayMonopoly? {
|
||||||
|
val plays = legal.filterIsInstance<PlayMonopoly>()
|
||||||
|
if (plays.isEmpty()) return null
|
||||||
|
val best = plays.maxByOrNull {
|
||||||
|
19 - CatanRules.bankSupply(state, it.resource) + want(it.resource)
|
||||||
|
} ?: return null
|
||||||
|
// Only worth the card when the table is actually holding a pile.
|
||||||
|
return if (19 - CatanRules.bankSupply(state, best.resource) >= 10) best else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the seat is shortest of for its next city, else its next settlement. */
|
||||||
|
private fun shortage(state: CatanState): Resource? {
|
||||||
|
val seat = state.turn
|
||||||
|
val targets = listOf(CatanRules.CITY_COST, CatanRules.SETTLEMENT_COST)
|
||||||
|
for (cost in targets) {
|
||||||
|
val missing = cost.entries.filter { (res, need) ->
|
||||||
|
(state.resources[seat][res] ?: 0) < need
|
||||||
|
}
|
||||||
|
if (missing.isNotEmpty()) {
|
||||||
|
return missing.maxByOrNull { (res, need) -> need - (state.resources[seat][res] ?: 0) }?.key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun completesBuild(state: CatanState, seat: Int, move: MaritimeTrade): Boolean {
|
||||||
|
val hand = state.resources[seat].toMutableMap()
|
||||||
|
val ratio = CatanRules.bestPortRatio(state, seat, move.give)
|
||||||
|
hand[move.give] = (hand[move.give] ?: 0) - ratio
|
||||||
|
hand[move.get] = (hand[move.get] ?: 0) + 1
|
||||||
|
return covers(hand, CatanRules.CITY_COST) || covers(hand, CatanRules.SETTLEMENT_COST)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun covers(hand: Map<Resource, Int>, cost: Map<Resource, Int>): Boolean =
|
||||||
|
cost.all { (res, need) -> (hand[res] ?: 0) >= need }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A domestic offer for exactly the card that closes a build, paid for
|
||||||
|
* with two of the surplus — offered to whoever visibly holds the most
|
||||||
|
* of what is missing. Constructed rather than enumerated (see
|
||||||
|
* [OfferTrade]), so it is checked against the rules before it is spoken.
|
||||||
|
*
|
||||||
|
* At most one offer a turn: a declined offer changes nothing, so
|
||||||
|
* re-offering it would only bounce between the same two seats forever.
|
||||||
|
*/
|
||||||
|
private fun offer(state: CatanState, seat: Int): OfferTrade? {
|
||||||
|
if (offeredThisTurn(state, seat)) return null
|
||||||
|
val seatHand = state.resources[seat]
|
||||||
|
val builds = listOf(CatanRules.CITY_COST, CatanRules.SETTLEMENT_COST)
|
||||||
|
for (cost in builds) {
|
||||||
|
val missing = cost.entries.filter { (res, need) -> (seatHand[res] ?: 0) < need }
|
||||||
|
if (missing.size != 1) continue
|
||||||
|
val need = missing.first().key
|
||||||
|
val surplus = Resource.entries
|
||||||
|
.filter { it != need }
|
||||||
|
.filter { (seatHand[it] ?: 0) > (cost[it] ?: 0) }
|
||||||
|
.sortedByDescending { seatHand[it] ?: 0 }
|
||||||
|
if (surplus.isEmpty()) continue
|
||||||
|
val target = (0 until state.playerCount)
|
||||||
|
.filter { it != seat }
|
||||||
|
.filter { (state.resources[it][need] ?: 0) > 0 }
|
||||||
|
.maxByOrNull { state.resources[it][need] ?: 0 }
|
||||||
|
?: continue
|
||||||
|
val give = listOf(surplus.first(), surplus.first())
|
||||||
|
val trade = OfferTrade(to = target, give = give, get = listOf(need))
|
||||||
|
if (CatanRules.isLegalMove(state, seat, trade)) return trade
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [seat] has already put an offer on the table this turn, read
|
||||||
|
* from the public log: the lines after this seat's last roll are this
|
||||||
|
* turn's, and an offer can only be spoken on the offerer's own turn.
|
||||||
|
*/
|
||||||
|
private fun offeredThisTurn(state: CatanState, seat: Int): Boolean {
|
||||||
|
val sinceRoll = state.log
|
||||||
|
.drop(state.log.indexOfLast { it.startsWith("Seat $seat rolls") } + 1)
|
||||||
|
return sinceRoll.any { it.startsWith("Seat $seat offers") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Free roads and trade responses
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun chooseFreeRoad(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
|
||||||
|
val roads = legal.filterIsInstance<BuildRoad>()
|
||||||
|
.filter { roadValue(state, it) > 0.05 }
|
||||||
|
.maxByOrNull { roadValue(state, it) }
|
||||||
|
return roads ?: legal.firstOrNull { it is EndFreeRoads }
|
||||||
|
?: legal.filterIsInstance<BuildRoad>().firstOrNull()
|
||||||
|
?: legal.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chooseResponse(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
|
||||||
|
val accept = legal.filterIsInstance<AcceptTrade>().firstOrNull()
|
||||||
|
val offer = state.pendingTrade
|
||||||
|
if (accept == null || offer == null) return legal.first { it is DeclineTrade }
|
||||||
|
// Accept when what arrives (the offerer's give) is worth more than what
|
||||||
|
// leaves (the offerer's get): a mild table, not a shark.
|
||||||
|
val incoming = offer.give.sumOf { want(it) }
|
||||||
|
val outgoing = offer.get.sumOf { want(it) }
|
||||||
|
return if (incoming > outgoing) accept else legal.first { it is DeclineTrade }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks the counter-offer that gains the most by the same valuation, or
|
||||||
|
* turns them all down. The AI never counters itself: it only answers.
|
||||||
|
*/
|
||||||
|
private fun chooseCounter(state: CatanState, legal: List<CatanMove>): CatanMove {
|
||||||
|
val offer = state.pendingTrade ?: return legal.first()
|
||||||
|
val best = legal.filterIsInstance<AcceptCounter>()
|
||||||
|
.map { choice -> choice to offer.counters.first { it.by == choice.seat } }
|
||||||
|
.maxByOrNull { (_, c) -> c.give.sumOf { want(it) } - c.get.sumOf { want(it) } }
|
||||||
|
if (best != null) {
|
||||||
|
val (choice, c) = best
|
||||||
|
if (c.give.sumOf { want(it) } > c.get.sumOf { want(it) }) return choice
|
||||||
|
}
|
||||||
|
return legal.first { it is DismissCounters }
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Valuations
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** What a seat would think of building on [vertex]: production and ports. */
|
||||||
|
internal fun spotValue(state: CatanState, vertex: Int): Double {
|
||||||
|
if (state.buildings[vertex] != null) return -1.0
|
||||||
|
var value = 0.0
|
||||||
|
val kinds = mutableSetOf<Resource>()
|
||||||
|
for (h in CatanBoard.VERTEX_HEXES[vertex]) {
|
||||||
|
val resource = state.terrains[h].produces ?: continue
|
||||||
|
value += CatanBoard.numberOdds(state.numbers[h]) / 36.0 * want(resource)
|
||||||
|
kinds += resource
|
||||||
|
}
|
||||||
|
value += 0.12 * kinds.size // variety keeps a build plan alive
|
||||||
|
for (e in CatanBoard.VERTEX_EDGES[vertex]) {
|
||||||
|
val port = state.ports.firstOrNull { it.edge == e } ?: continue
|
||||||
|
value += if (port.resource == null) 0.1 else 0.2
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a road on this edge leads to: the best settlement spot it brings
|
||||||
|
* within reach, discounted for being one step further out.
|
||||||
|
*/
|
||||||
|
internal fun roadValue(state: CatanState, move: BuildRoad): Double {
|
||||||
|
var best = 0.0
|
||||||
|
val (from, to) = CatanBoard.EDGE_VERTICES[move.edge]
|
||||||
|
for (endpoint in listOf(from, to)) {
|
||||||
|
for (near in CatanBoard.VERTEX_NEIGHBORS[endpoint]) {
|
||||||
|
best = maxOf(best, spotValue(state, near) * 0.8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How much the AI wants to hold one card of [resource]. */
|
||||||
|
internal fun want(resource: Resource): Double = when (resource) {
|
||||||
|
Resource.ORE -> 1.20 // the city
|
||||||
|
Resource.GRAIN -> 1.15 // the city and the dev cards
|
||||||
|
Resource.LUMBER -> 1.0 // every road and settlement
|
||||||
|
Resource.BRICK -> 0.95
|
||||||
|
Resource.WOOL -> 0.85 // abundant and only half of a dev card
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
package org.prolibertate.games.game.catan
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlin.math.atan2
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The island as geometry: the 19 hexes, the 54 shared vertices, the 72
|
||||||
|
* shared edges, the coast and the nine harbour positions.
|
||||||
|
*
|
||||||
|
* Everything is derived once, deterministically, from axial coordinates —
|
||||||
|
* there is no randomness and no state here; the layout of a particular
|
||||||
|
* game (which terrain and number sits on which hex) is [generate], and the
|
||||||
|
* live state lives in [CatanState].
|
||||||
|
*
|
||||||
|
* Coordinates: hexes are addressed axially ((q, r), pointy-top). Every
|
||||||
|
* position is kept on an exact integer lattice so vertices shared by
|
||||||
|
* neighbouring hexes are recognised by position instead of by floating
|
||||||
|
* point: the centre of hex (q, r) is at lattice point (2q + r, 3r), and its
|
||||||
|
* six corners follow the offsets in [CORNER_OFFSETS]. Vertex positions are
|
||||||
|
* in half-units of the hex grid; rendering scales and flips them (lattice
|
||||||
|
* +Y is toward the top of the board).
|
||||||
|
*/
|
||||||
|
object CatanBoard {
|
||||||
|
|
||||||
|
/** The 19 hexes: every (q, r) within distance 2 of the centre. */
|
||||||
|
val HEXES: List<HexCoord> = buildList {
|
||||||
|
for (r in -2..2) {
|
||||||
|
for (q in -2..2) {
|
||||||
|
if (hexDistance(q, r, 0, 0) <= 2) add(HexCoord(q, r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val HEX_COUNT: Int = HEXES.size
|
||||||
|
|
||||||
|
val hexIndex: Map<HexCoord, Int> = HEXES.withIndex().associate { (i, hex) -> hex to i }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corner k of a hex, as a lattice offset from its centre. Pointy-top:
|
||||||
|
* corner 0 is the top corner, then clockwise.
|
||||||
|
*/
|
||||||
|
private val CORNER_OFFSETS = listOf(
|
||||||
|
0 to 2, 1 to 1, 1 to -1, 0 to -2, -1 to -1, -1 to 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Unit steps to a hex's six neighbours, matching the corner order. */
|
||||||
|
private val NEIGHBOR_STEPS = listOf(
|
||||||
|
0 to 1, 1 to 0, 1 to -1, 0 to -1, -1 to 0, -1 to 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The six board-corner hexes: pairwise non-adjacent by construction. */
|
||||||
|
private val CORNER_HEXES = listOf(
|
||||||
|
HexCoord(2, 0), HexCoord(0, 2), HexCoord(-2, 2),
|
||||||
|
HexCoord(-2, 0), HexCoord(0, -2), HexCoord(2, -2),
|
||||||
|
)
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Vertices and edges
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Lattice position of each vertex, by vertex id. */
|
||||||
|
val VERTEX_POS: List<Pair<Int, Int>>
|
||||||
|
|
||||||
|
val VERTEX_COUNT: Int
|
||||||
|
|
||||||
|
/** Vertex ids of hex h's six corners, corner 0 first (see [CORNER_OFFSETS]). */
|
||||||
|
val HEX_CORNERS: List<List<Int>>
|
||||||
|
|
||||||
|
/** Both endpoints of each edge, as vertex ids, low id first. */
|
||||||
|
val EDGE_VERTICES: List<Pair<Int, Int>>
|
||||||
|
|
||||||
|
val EDGE_COUNT: Int
|
||||||
|
|
||||||
|
/** Edges touching each vertex. */
|
||||||
|
val VERTEX_EDGES: List<List<Int>>
|
||||||
|
|
||||||
|
/** Vertices one edge away from each vertex: the distance rule's horizon. */
|
||||||
|
val VERTEX_NEIGHBORS: List<List<Int>>
|
||||||
|
|
||||||
|
/** Hexes touching each vertex. */
|
||||||
|
val VERTEX_HEXES: List<List<Int>>
|
||||||
|
|
||||||
|
/** Hexes touching each edge: two for interior edges, one on the coast. */
|
||||||
|
val EDGE_HEXES: List<List<Int>>
|
||||||
|
|
||||||
|
/** Edges adjacent to each edge (sharing a vertex). */
|
||||||
|
val EDGE_NEIGHBORS: List<List<Int>>
|
||||||
|
|
||||||
|
/** Hexes adjacent to each hex, in [NEIGHBOR_STEPS] order. */
|
||||||
|
val HEX_NEIGHBORS: List<List<Int>>
|
||||||
|
|
||||||
|
/** The coastal edges (those with only one hex) in ring order. */
|
||||||
|
val COASTAL: List<Int>
|
||||||
|
|
||||||
|
/** The nine fixed harbour positions, spread around the coast. */
|
||||||
|
val PORT_POSITIONS: List<Int>
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Vertices: assign ids in first-seen order over hexes then corners.
|
||||||
|
val vertexIds = mutableMapOf<Pair<Int, Int>, Int>()
|
||||||
|
val vertexPos = mutableListOf<Pair<Int, Int>>()
|
||||||
|
val hexCorners = HEXES.map { hex ->
|
||||||
|
val cx = 2 * hex.q + hex.r
|
||||||
|
val cy = 3 * hex.r
|
||||||
|
CORNER_OFFSETS.map { (dx, dy) ->
|
||||||
|
val pos = (cx + dx) to (cy + dy)
|
||||||
|
vertexIds.getOrPut(pos) {
|
||||||
|
vertexPos += pos
|
||||||
|
vertexPos.size - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VERTEX_POS = vertexPos
|
||||||
|
VERTEX_COUNT = vertexPos.size
|
||||||
|
HEX_CORNERS = hexCorners
|
||||||
|
|
||||||
|
// Edges between a hex's consecutive corners, deduplicated by the
|
||||||
|
// pair of vertex ids.
|
||||||
|
val edgeIds = mutableMapOf<Pair<Int, Int>, Int>()
|
||||||
|
val edgeVertices = mutableListOf<Pair<Int, Int>>()
|
||||||
|
hexCorners.forEach { corners ->
|
||||||
|
for (k in 0..5) {
|
||||||
|
val a = corners[k]
|
||||||
|
val b = corners[(k + 1) % 6]
|
||||||
|
val key = minOf(a, b) to maxOf(a, b)
|
||||||
|
edgeIds.getOrPut(key) {
|
||||||
|
edgeVertices += key
|
||||||
|
edgeVertices.size - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EDGE_VERTICES = edgeVertices
|
||||||
|
EDGE_COUNT = edgeVertices.size
|
||||||
|
|
||||||
|
val vertexEdges = List(VERTEX_COUNT) { mutableListOf<Int>() }
|
||||||
|
val vertexHexes = List(VERTEX_COUNT) { mutableSetOf<Int>() }
|
||||||
|
val edgeHexes = List(EDGE_COUNT) { mutableSetOf<Int>() }
|
||||||
|
hexCorners.forEachIndexed { h, corners ->
|
||||||
|
corners.forEach { v -> vertexHexes[v] += h }
|
||||||
|
for (k in 0..5) {
|
||||||
|
val a = corners[k]
|
||||||
|
val b = corners[(k + 1) % 6]
|
||||||
|
val key = minOf(a, b) to maxOf(a, b)
|
||||||
|
val e = edgeIds.getValue(key)
|
||||||
|
edgeHexes[e] += h
|
||||||
|
vertexEdges[a] += e
|
||||||
|
vertexEdges[b] += e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Distinct: an interior edge is walked once from each of the two hexes
|
||||||
|
// it separates, so without this every shared edge would be counted
|
||||||
|
// twice and an interior vertex would appear to have six edges.
|
||||||
|
VERTEX_EDGES = vertexEdges.map { it.distinct() }
|
||||||
|
VERTEX_HEXES = vertexHexes.map { it.sorted() }
|
||||||
|
EDGE_HEXES = edgeHexes.map { it.sorted() }
|
||||||
|
VERTEX_NEIGHBORS = List(VERTEX_COUNT) { v ->
|
||||||
|
VERTEX_EDGES[v].map { otherEnd(it, v) }.distinct().sorted()
|
||||||
|
}
|
||||||
|
EDGE_NEIGHBORS = List(EDGE_COUNT) { e ->
|
||||||
|
val (a, b) = EDGE_VERTICES[e]
|
||||||
|
(VERTEX_EDGES[a] + VERTEX_EDGES[b]).distinct().filter { it != e }.sorted()
|
||||||
|
}
|
||||||
|
HEX_NEIGHBORS = HEXES.map { hex ->
|
||||||
|
NEIGHBOR_STEPS.mapNotNull { (dq, dr) ->
|
||||||
|
hexIndex[HexCoord(hex.q + dq, hex.r + dr)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The coast in walking order: sort the coastal edges by the angle of
|
||||||
|
// their midpoint around the board centre, which is the lattice
|
||||||
|
// origin. The board is a hexagon, so this yields one closed ring.
|
||||||
|
COASTAL = EDGE_HEXES.withIndex()
|
||||||
|
.filter { it.value.size == 1 }
|
||||||
|
.map { (e, _) -> e }
|
||||||
|
.sortedBy { e -> angleOf(midpoint(e)) }
|
||||||
|
// Harbours sit roughly every third or fourth coastal edge, like the
|
||||||
|
// physical frame: nine positions, steps summing to the whole ring.
|
||||||
|
PORT_POSITIONS = buildList {
|
||||||
|
var at = 0
|
||||||
|
for (step in listOf(3, 4, 3, 3, 4, 3, 3, 4, 3)) {
|
||||||
|
add(COASTAL[at % COASTAL.size])
|
||||||
|
at += step
|
||||||
|
}
|
||||||
|
}.distinct()
|
||||||
|
|
||||||
|
check(HEX_COUNT == 19) { "A Catan island has 19 hexes, found $HEX_COUNT" }
|
||||||
|
check(VERTEX_COUNT == 54) { "A Catan island has 54 vertices, found $VERTEX_COUNT" }
|
||||||
|
check(EDGE_COUNT == 72) { "A Catan island has 72 edges, found $EDGE_COUNT" }
|
||||||
|
check(COASTAL.size == 30) { "The coast has 30 edges, found ${COASTAL.size}" }
|
||||||
|
check(PORT_POSITIONS.size == 9) { "Nine harbours, found ${PORT_POSITIONS.size}" }
|
||||||
|
check(PORT_POSITIONS.all { EDGE_HEXES[it].size == 1 }) { "Harbours must be coastal" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The other endpoint of [edge], given one [vertex] on it. */
|
||||||
|
fun otherEnd(edge: Int, vertex: Int): Int {
|
||||||
|
val (a, b) = EDGE_VERTICES[edge]
|
||||||
|
return if (vertex == a) b else a
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lattice position of hex h's centre. */
|
||||||
|
fun hexCenter(h: Int): Pair<Int, Int> {
|
||||||
|
val hex = HEXES[h]
|
||||||
|
return (2 * hex.q + hex.r) to (3 * hex.r)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether two hexes share an edge. */
|
||||||
|
fun hexesAdjacent(a: Int, b: Int): Boolean = b in HEX_NEIGHBORS[a]
|
||||||
|
|
||||||
|
/** Hex-grid distance between two axial positions. */
|
||||||
|
fun hexDistance(q1: Int, r1: Int, q2: Int, r2: Int): Int {
|
||||||
|
val dq = q1 - q2
|
||||||
|
val dr = r1 - r2
|
||||||
|
return maxOf(kotlin.math.abs(dq), kotlin.math.abs(dr), kotlin.math.abs(dq + dr))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How often a number is expected to come up, in thirty-sixths. A 6 or an
|
||||||
|
* 8 comes up five times in thirty-six, a 2 or a 12 once.
|
||||||
|
*/
|
||||||
|
fun numberOdds(number: Int): Int = 6 - kotlin.math.abs(7 - number)
|
||||||
|
|
||||||
|
private fun midpoint(edge: Int): Pair<Double, Double> {
|
||||||
|
val (a, b) = EDGE_VERTICES[edge]
|
||||||
|
val pa = VERTEX_POS[a]
|
||||||
|
val pb = VERTEX_POS[b]
|
||||||
|
return ((pa.first + pb.first) / 2.0) to ((pa.second + pb.second) / 2.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun angleOf(point: Pair<Double, Double>): Double =
|
||||||
|
atan2(point.second, point.first)
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Island generation
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The island layout for one game: terrain and number per hex, the robber
|
||||||
|
* on the desert, and the nine harbours.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class Generated(
|
||||||
|
val terrains: List<Terrain>,
|
||||||
|
/** 0 where there is no token (the desert). */
|
||||||
|
val numbers: List<Int>,
|
||||||
|
val robber: Int,
|
||||||
|
val ports: List<Port>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The terrain tile mix: four forests, four pastures, four fields, three hills, three mountains, one desert. */
|
||||||
|
private val TERRAIN_TILES: List<Terrain> = listOf(
|
||||||
|
Terrain.FOREST, Terrain.FOREST, Terrain.FOREST, Terrain.FOREST,
|
||||||
|
Terrain.PASTURE, Terrain.PASTURE, Terrain.PASTURE, Terrain.PASTURE,
|
||||||
|
Terrain.FIELDS, Terrain.FIELDS, Terrain.FIELDS, Terrain.FIELDS,
|
||||||
|
Terrain.HILLS, Terrain.HILLS, Terrain.HILLS,
|
||||||
|
Terrain.MOUNTAINS, Terrain.MOUNTAINS, Terrain.MOUNTAINS,
|
||||||
|
Terrain.DESERT,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The 18 number tokens, the two hardest numbers withheld: 7 never appears. */
|
||||||
|
private val RED_TOKENS = listOf(6, 6, 8, 8)
|
||||||
|
private val OTHER_TOKENS = listOf(2, 3, 3, 4, 4, 5, 5, 9, 9, 10, 10, 11, 11, 12)
|
||||||
|
|
||||||
|
/** Shuffle attempts before falling back to the corner placement for 6s and 8s. */
|
||||||
|
private const val RED_ATTEMPTS = 60
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the island from [seed].
|
||||||
|
*
|
||||||
|
* Terrain is shuffled over the hexes, exactly as shuffling the land
|
||||||
|
* tiles face down does. Number tokens are shuffled over the land hexes
|
||||||
|
* with one repair rule: no two of the red tokens (6 and 8) may sit on
|
||||||
|
* adjacent hexes, the balance convention the published game asks for.
|
||||||
|
* The robber starts on the desert. Harbour *positions* are fixed (the
|
||||||
|
* physical frame is not shuffled); the nine harbour *types* — five
|
||||||
|
* 2:1 resource ports and four 3:1 ports — are shuffled onto them.
|
||||||
|
*
|
||||||
|
* The 6/8 separation is guaranteed, not merely probable: if no shuffled
|
||||||
|
* placement separates them (about one shuffle in five does), the red
|
||||||
|
* tokens go to four of the six board-corner hexes, which are pairwise
|
||||||
|
* non-adjacent, and at most one corner can be desert.
|
||||||
|
*/
|
||||||
|
fun generate(seed: Long): Generated {
|
||||||
|
val random = Random(seed)
|
||||||
|
val terrains = TERRAIN_TILES.shuffled(random)
|
||||||
|
val desert = terrains.indexOf(Terrain.DESERT)
|
||||||
|
val land = terrains.indices.filter { it != desert }
|
||||||
|
|
||||||
|
var redHexes: List<Int>? = null
|
||||||
|
var attempt = 0
|
||||||
|
while (redHexes == null && attempt < RED_ATTEMPTS) {
|
||||||
|
val candidate = land.shuffled(random).take(4)
|
||||||
|
val separated = candidate.withIndex().all { (i, a) ->
|
||||||
|
candidate.drop(i + 1).none { hexesAdjacent(a, it) }
|
||||||
|
}
|
||||||
|
if (separated) redHexes = candidate
|
||||||
|
attempt++
|
||||||
|
}
|
||||||
|
val reds = redHexes ?: CORNER_HEXES
|
||||||
|
.mapNotNull { hexIndex[it] }
|
||||||
|
.filter { it != desert }
|
||||||
|
.shuffled(random)
|
||||||
|
.take(4)
|
||||||
|
|
||||||
|
val numbers = IntArray(HEX_COUNT)
|
||||||
|
RED_TOKENS.forEachIndexed { i, token -> numbers[reds[i]] = token }
|
||||||
|
val rest = land.filter { it !in reds }.shuffled(random)
|
||||||
|
OTHER_TOKENS.forEachIndexed { i, token -> numbers[rest[i]] = token }
|
||||||
|
|
||||||
|
val portTypes = (
|
||||||
|
listOf(
|
||||||
|
Resource.LUMBER, Resource.BRICK, Resource.WOOL, Resource.GRAIN, Resource.ORE,
|
||||||
|
) + List(4) { null }
|
||||||
|
).shuffled(random)
|
||||||
|
val ports = PORT_POSITIONS.mapIndexed { i, edge ->
|
||||||
|
val resource = portTypes[i]
|
||||||
|
if (resource == null) Port(edge, null, 3) else Port(edge, resource, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Generated(terrains, numbers.toList(), desert, ports)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,345 @@
|
|||||||
|
package org.prolibertate.games.game.catan
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The computer opponent for Cities & Knights, reached through [CatanAi].
|
||||||
|
*
|
||||||
|
* It plays the same cautious game as the base AI — settle well, raise cities —
|
||||||
|
* and adds the three things this rule set asks of a player: keep enough active
|
||||||
|
* knights standing when the barbarians land, spend commodities on improvements
|
||||||
|
* as soon as it can, and answer every chore the table raises with the card it
|
||||||
|
* values least. It reads only what a player at the table could see, plus its
|
||||||
|
* own hand.
|
||||||
|
*
|
||||||
|
* It is not a strong player. It never moves knights about (they stay where
|
||||||
|
* hired, and are activated when the barbarians draw near), never plays the
|
||||||
|
* progress cards whose value depends on a plan (Inventor, Alchemist, Diplomat,
|
||||||
|
* Deserter, Intrigue), and makes no domestic offers, though it answers them.
|
||||||
|
*/
|
||||||
|
internal class CatanCkAi(private val ai: CatanAi) {
|
||||||
|
|
||||||
|
fun choose(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove = when (state.phase) {
|
||||||
|
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND ->
|
||||||
|
ai.chooseSetup(state, seat, legal.filterIsInstance<SetupPlace>())
|
||||||
|
|
||||||
|
CatanPhase.AWAIT_ROLL -> legal.first { it is RollDice }
|
||||||
|
|
||||||
|
CatanPhase.CHORE -> chooseChore(state, seat, legal)
|
||||||
|
|
||||||
|
CatanPhase.ROBBER -> chooseRobber(state, seat, legal.filterIsInstance<MoveRobber>())
|
||||||
|
|
||||||
|
CatanPhase.FREE_ROADS -> chooseFreeRoad(state, seat, legal)
|
||||||
|
|
||||||
|
CatanPhase.TRADE_PENDING -> chooseResponse(state, seat, legal)
|
||||||
|
|
||||||
|
CatanPhase.TRADE_COUNTERS -> chooseCounter(state, legal)
|
||||||
|
|
||||||
|
CatanPhase.PLAYING -> choosePlaying(state, seat, legal)
|
||||||
|
|
||||||
|
CatanPhase.DISCARDING, CatanPhase.GAME_OVER -> legal.firstOrNull()
|
||||||
|
?: error("Nothing to choose in phase ${state.phase}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Chores
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun chooseChore(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
|
||||||
|
val ck = state.ck!!
|
||||||
|
return when (val chore = ck.chores.first()) {
|
||||||
|
is DiscardChore -> {
|
||||||
|
val n = minOf(chore.count, CatanCk.goodsCount(state, seat))
|
||||||
|
DiscardGoods(CatanCk.goodsList(state, seat).sortedBy { worth(state, seat, it) }.take(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
is GiveChore -> {
|
||||||
|
val pool = CatanCk.goodsList(state, seat)
|
||||||
|
.filter { !chore.commoditiesOnly || it.isCommodity }
|
||||||
|
.sortedBy { worth(state, seat, it) }
|
||||||
|
GiveGoods(pool.take(minOf(chore.count, pool.size)))
|
||||||
|
}
|
||||||
|
|
||||||
|
is PillageChore ->
|
||||||
|
legal.filterIsInstance<PillageCity>().minByOrNull { production(state, it.vertex) } ?: legal.first()
|
||||||
|
|
||||||
|
is PickDeckChore -> {
|
||||||
|
val picks = legal.filterIsInstance<PickDeck>()
|
||||||
|
picks.maxByOrNull { CatanCk.improvement(state, seat, it.track) } ?: legal.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
is DropProgressChore ->
|
||||||
|
legal.filterIsInstance<DropProgress>().minByOrNull { drop ->
|
||||||
|
ck.progressHands[seat].first { it.id == drop.card }.kind.let(::cardWorth)
|
||||||
|
} ?: legal.first()
|
||||||
|
|
||||||
|
is MetropolisChore ->
|
||||||
|
legal.filterIsInstance<PlaceMetropolis>().maxByOrNull { production(state, it.vertex) }
|
||||||
|
?: legal.first()
|
||||||
|
|
||||||
|
is AqueductChore ->
|
||||||
|
legal.filterIsInstance<TakeAqueduct>().maxByOrNull { ai.want(it.resource) } ?: legal.first()
|
||||||
|
|
||||||
|
is RelocateChore -> legal.first()
|
||||||
|
|
||||||
|
is RemoveKnightChore ->
|
||||||
|
legal.filterIsInstance<RemoveKnight>().minByOrNull {
|
||||||
|
CatanCk.knightAt(state, it.vertex)?.level ?: 0
|
||||||
|
} ?: legal.first()
|
||||||
|
|
||||||
|
is PlaceKnightChore -> legal.first()
|
||||||
|
|
||||||
|
is SpyChore -> legal.first()
|
||||||
|
|
||||||
|
is SmithChore ->
|
||||||
|
legal.filterIsInstance<PromoteKnight>().firstOrNull() ?: legal.first { it is EndSmith }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// The robber
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun chooseRobber(state: CatanState, seat: Int, moves: List<MoveRobber>): MoveRobber {
|
||||||
|
require(moves.isNotEmpty()) { "No legal robber move" }
|
||||||
|
val points = CatanRules.publicVictoryPoints(state)
|
||||||
|
return moves.maxByOrNull { move ->
|
||||||
|
val hex = CatanBoard.hexIndex.getValue(move.hex)
|
||||||
|
var score = 0.0
|
||||||
|
for (v in CatanBoard.HEX_CORNERS[hex]) {
|
||||||
|
val building = state.buildings[v] ?: continue
|
||||||
|
val worth = if (building.type == BuildingType.CITY) 2 else 1
|
||||||
|
val odds = CatanBoard.numberOdds(state.numbers[hex]) / 36.0
|
||||||
|
score += if (building.owner == seat) -worth * odds else worth * odds
|
||||||
|
}
|
||||||
|
if (move.victim != null) {
|
||||||
|
score += 0.15 * CatanCk.goodsCount(state, move.victim) + 0.3 * points[move.victim]
|
||||||
|
}
|
||||||
|
score
|
||||||
|
} ?: moves.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// The main phase
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun choosePlaying(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
|
||||||
|
val ck = state.ck!!
|
||||||
|
val soon = ck.barbarians <= 3
|
||||||
|
|
||||||
|
// Chase the robber off a hex it is squatting on.
|
||||||
|
if (ck.attacked) {
|
||||||
|
val onMine = legal.filterIsInstance<ChaseRobber>().firstOrNull()
|
||||||
|
val robberHurtsMe = CatanBoard.HEX_CORNERS[state.robber].any { state.buildings[it]?.owner == seat }
|
||||||
|
if (onMine != null && robberHurtsMe) return onMine
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cheap upgrade first, then the full-price one.
|
||||||
|
legal.filterIsInstance<PlayProgress>()
|
||||||
|
.filter { kindOf(state, seat, it) == ProgressType.MEDICINE }
|
||||||
|
.maxByOrNull { production(state, it.vertex!!) }
|
||||||
|
?.let { return it }
|
||||||
|
|
||||||
|
legal.filterIsInstance<BuildCity>().maxByOrNull { production(state, it.vertex) }
|
||||||
|
?.let { return it }
|
||||||
|
|
||||||
|
legal.filterIsInstance<BuildSettlement>().maxByOrNull { ai.spotValue(state, it.vertex) }
|
||||||
|
?.let { return it }
|
||||||
|
|
||||||
|
// Improvements spend cards nothing else needs. Favour the track furthest along.
|
||||||
|
legal.filterIsInstance<BuyImprovement>()
|
||||||
|
.maxByOrNull { CatanCk.improvement(state, seat, it.track) * 10 + trackLean(it.track) }
|
||||||
|
?.let { return it }
|
||||||
|
|
||||||
|
// Knights: stand ready by the time the barbarians land.
|
||||||
|
defence(state, seat, legal, soon)?.let { return it }
|
||||||
|
|
||||||
|
progress(state, seat, legal)?.let { return it }
|
||||||
|
|
||||||
|
legal.filterIsInstance<BuildWall>().firstOrNull()?.let {
|
||||||
|
if (CatanCk.goodsCount(state, seat) >= 7) return it
|
||||||
|
}
|
||||||
|
|
||||||
|
legal.filterIsInstance<BuildRoad>()
|
||||||
|
.filter { ai.roadValue(state, it) > 0.1 }
|
||||||
|
.maxByOrNull { ai.roadValue(state, it) }
|
||||||
|
?.let { return it }
|
||||||
|
|
||||||
|
legal.filterIsInstance<GoodsBankTrade>()
|
||||||
|
.filter { completes(state, seat, it) }
|
||||||
|
.minByOrNull { CatanCk.bankRatio(state, seat, it.give) }
|
||||||
|
?.let { return it }
|
||||||
|
|
||||||
|
return legal.lastOrNull { it is EndTurn } ?: legal.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hire, promote and activate so the knights are standing when the barbarians land. */
|
||||||
|
private fun defence(state: CatanState, seat: Int, legal: List<CatanMove>, soon: Boolean): CatanMove? {
|
||||||
|
val mine = CatanCk.knightsOf(state, seat)
|
||||||
|
val grainLeft = state.resources[seat][Resource.GRAIN] ?: 0
|
||||||
|
|
||||||
|
if (soon) {
|
||||||
|
legal.filterIsInstance<ActivateKnight>().firstOrNull()?.let { return it }
|
||||||
|
}
|
||||||
|
val want = if (soon) 4 else 2
|
||||||
|
if (mine.size < want) {
|
||||||
|
legal.filterIsInstance<HireKnight>().maxByOrNull { ai.spotValue(state, it.vertex) }?.let { hire ->
|
||||||
|
// Keep a grain back for waking it, once the ship is close.
|
||||||
|
if (!soon || grainLeft >= 1) return hire
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (soon || mine.size >= 2) {
|
||||||
|
legal.filterIsInstance<PromoteKnight>().firstOrNull()?.let { return it }
|
||||||
|
}
|
||||||
|
if (soon) {
|
||||||
|
legal.filterIsInstance<ActivateKnight>().firstOrNull()?.let { return it }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Progress cards worth playing without a plan. */
|
||||||
|
private fun progress(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove? {
|
||||||
|
val ck = state.ck!!
|
||||||
|
val soon = ck.barbarians <= 4
|
||||||
|
val plays = legal.filterIsInstance<PlayProgress>()
|
||||||
|
fun kindOf(play: PlayProgress) = ck.progressHands[seat].first { it.id == play.card }.kind
|
||||||
|
fun first(kind: ProgressType) = plays.firstOrNull { kindOf(it) == kind }
|
||||||
|
|
||||||
|
first(ProgressType.IRRIGATION)?.let { return it }
|
||||||
|
first(ProgressType.MINING)?.let { return it }
|
||||||
|
first(ProgressType.ENGINEER)?.let { return it }
|
||||||
|
first(ProgressType.SMITH)?.let { return it }
|
||||||
|
first(ProgressType.WEDDING)?.let { return it }
|
||||||
|
if (soon) first(ProgressType.WARLORD)?.let { return it }
|
||||||
|
|
||||||
|
plays.filter { kindOf(it) == ProgressType.MASTER_MERCHANT }
|
||||||
|
.maxByOrNull { play -> play.goods.orEmpty().sumOf { worth(state, seat, it) } }
|
||||||
|
?.let { return it }
|
||||||
|
|
||||||
|
first(ProgressType.SPY)?.let { return it }
|
||||||
|
first(ProgressType.BISHOP)?.let { return it }
|
||||||
|
|
||||||
|
if (legal.filterIsInstance<BuildRoad>().size >= 2) first(ProgressType.ROAD_BUILDING)?.let { return it }
|
||||||
|
|
||||||
|
// Crane: only when it makes the difference to an improvement.
|
||||||
|
first(ProgressType.CRANE)?.let { crane ->
|
||||||
|
val helps = Track.entries.any { track ->
|
||||||
|
val level = CatanCk.improvement(state, seat, track)
|
||||||
|
val have = ck.commodities[seat][track.commodity] ?: 0
|
||||||
|
level < IMPROVEMENT_MAX && have == level // one short of the next level's price
|
||||||
|
}
|
||||||
|
if (helps) return crane
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saboteur: when someone level or ahead is sitting on a pile.
|
||||||
|
first(ProgressType.SABOTEUR)?.let { saboteur ->
|
||||||
|
val vp = CatanRules.publicVictoryPoints(state)
|
||||||
|
val pile = (0 until state.playerCount).filter { it != seat && vp[it] >= vp[seat] }
|
||||||
|
.sumOf { CatanCk.goodsCount(state, it) / 2 }
|
||||||
|
if (pile >= 4) return saboteur
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monopolies: only when the table is holding a pile of it.
|
||||||
|
plays.filter { kindOf(it) == ProgressType.RESOURCE_MONOPOLY || kindOf(it) == ProgressType.TRADE_MONOPOLY }
|
||||||
|
.maxByOrNull { held(state, seat, it.good!!) }
|
||||||
|
?.let { best ->
|
||||||
|
val each = if (kindOf(best) == ProgressType.RESOURCE_MONOPOLY) 2 else 1
|
||||||
|
val take = (0 until state.playerCount).filter { it != seat }
|
||||||
|
.sumOf { minOf(each, CatanCk.goodsOf(state, it)[best.good!!] ?: 0) }
|
||||||
|
if (take >= 3) return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// The merchant, on the best hex beside a building of ours.
|
||||||
|
plays.filter { kindOf(it) == ProgressType.MERCHANT }
|
||||||
|
.maxByOrNull { play ->
|
||||||
|
val hex = play.hexA!!
|
||||||
|
CatanBoard.numberOdds(state.numbers[hex]).toDouble()
|
||||||
|
}?.let { return it }
|
||||||
|
|
||||||
|
// A commercial harbour turns spare resources into commodities.
|
||||||
|
plays.filter { kindOf(it) == ProgressType.COMMERCIAL_HARBOR }
|
||||||
|
.maxByOrNull { held(state, seat, it.good!!) }
|
||||||
|
?.let { if (held(state, seat, it.good!!) >= 2) return it }
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun kindOf(state: CatanState, seat: Int, play: PlayProgress): ProgressType =
|
||||||
|
state.ck!!.progressHands[seat].first { it.id == play.card }.kind
|
||||||
|
|
||||||
|
/** What a building on [vertex] would draw, by the odds of its hexes and what the seat wants. */
|
||||||
|
private fun production(state: CatanState, vertex: Int): Double =
|
||||||
|
CatanBoard.VERTEX_HEXES[vertex].sumOf { h ->
|
||||||
|
val want = state.terrains[h].produces?.let { ai.want(it) } ?: 0.0
|
||||||
|
want * CatanBoard.numberOdds(state.numbers[h]) / 36.0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun held(state: CatanState, seat: Int, good: Good): Int = CatanCk.goodsOf(state, seat)[good] ?: 0
|
||||||
|
|
||||||
|
/** Whether the trade leaves a hand that can pay for a city, a settlement or an improvement. */
|
||||||
|
private fun completes(state: CatanState, seat: Int, trade: GoodsBankTrade): Boolean {
|
||||||
|
val hand = CatanCk.goodsOf(state, seat).toMutableMap()
|
||||||
|
hand[trade.give] = (hand[trade.give] ?: 0) - CatanCk.bankRatio(state, seat, trade.give)
|
||||||
|
hand[trade.get] = (hand[trade.get] ?: 0) + 1
|
||||||
|
fun covers(cost: Map<Resource, Int>) = cost.all { (r, n) -> (hand[Good.of(r)] ?: 0) >= n }
|
||||||
|
if (covers(CatanRules.CITY_COST) || covers(CatanRules.SETTLEMENT_COST)) return true
|
||||||
|
return Track.entries.any { track ->
|
||||||
|
val level = CatanCk.improvement(state, seat, track)
|
||||||
|
level < IMPROVEMENT_MAX &&
|
||||||
|
(hand[Good.of(track.commodity)] ?: 0) >= level + 1 &&
|
||||||
|
(state.ck!!.commodities[seat][track.commodity] ?: 0) < level + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chooseFreeRoad(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove =
|
||||||
|
legal.filterIsInstance<BuildRoad>()
|
||||||
|
.filter { ai.roadValue(state, it) > 0.05 }
|
||||||
|
.maxByOrNull { ai.roadValue(state, it) }
|
||||||
|
?: legal.firstOrNull { it is EndFreeRoads }
|
||||||
|
?: legal.first()
|
||||||
|
|
||||||
|
private fun chooseResponse(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
|
||||||
|
val decline = legal.first { it is DeclineTrade }
|
||||||
|
val accept = legal.firstOrNull { it is AcceptTrade } ?: return decline
|
||||||
|
val offer = state.ck?.goodsOffer
|
||||||
|
if (offer != null) {
|
||||||
|
val incoming = offer.give.sumOf { worth(state, seat, it) }
|
||||||
|
val outgoing = offer.get.sumOf { worth(state, seat, it) }
|
||||||
|
return if (incoming > outgoing) accept else decline
|
||||||
|
}
|
||||||
|
val plain = state.pendingTrade ?: return decline
|
||||||
|
val incoming = plain.give.sumOf { ai.want(it) }
|
||||||
|
val outgoing = plain.get.sumOf { ai.want(it) }
|
||||||
|
return if (incoming > outgoing) accept else decline
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Counters to a human's... the AI never makes an offer, so this only guards the phase: take none. */
|
||||||
|
private fun chooseCounter(state: CatanState, legal: List<CatanMove>): CatanMove =
|
||||||
|
legal.firstOrNull { it is DismissCounters } ?: legal.first()
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Valuations
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** What a card is worth to this seat: resources as the base AI sees them, commodities by what they buy. */
|
||||||
|
private fun worth(state: CatanState, seat: Int, good: Good): Double =
|
||||||
|
good.resource?.let { ai.want(it) } ?: when (good) {
|
||||||
|
Good.PAPER -> 0.95
|
||||||
|
Good.COIN -> 0.9
|
||||||
|
else -> 0.85
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which track to lean toward when two are level: politics for the knights, then science. */
|
||||||
|
private fun trackLean(track: Track): Int = when (track) {
|
||||||
|
Track.POLITICS -> 2
|
||||||
|
Track.SCIENCE -> 1
|
||||||
|
Track.TRADE -> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cardWorth(kind: ProgressType): Double = when (kind) {
|
||||||
|
ProgressType.INVENTOR, ProgressType.ALCHEMIST, ProgressType.DIPLOMAT,
|
||||||
|
ProgressType.DESERTER, ProgressType.INTRIGUE, ProgressType.MERCHANT_FLEET,
|
||||||
|
-> 0.0
|
||||||
|
|
||||||
|
ProgressType.MERCHANT, ProgressType.COMMERCIAL_HARBOR, ProgressType.CRANE -> 0.5
|
||||||
|
else -> 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
package org.prolibertate.games.game.catan
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The data of the Cities & Knights rule set. The rules themselves live in
|
||||||
|
* [CatanCk]; docs/RULES-catan-ck.md describes them and the choices made.
|
||||||
|
*
|
||||||
|
* Everything here is reached through [CatanState.ck], which is null for the
|
||||||
|
* base game, so a base game never sees any of it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Steps the barbarian ship must make before it lands. */
|
||||||
|
const val BARBARIAN_STEPS = 7
|
||||||
|
|
||||||
|
/** Commodity cards of each type the bank starts with (resources have 19). */
|
||||||
|
const val BANK_PER_COMMODITY = 12
|
||||||
|
|
||||||
|
/** Knights of each strength one player owns. */
|
||||||
|
const val KNIGHTS_PER_LEVEL = 2
|
||||||
|
|
||||||
|
/** City walls one player owns. */
|
||||||
|
const val MAX_WALLS = 3
|
||||||
|
|
||||||
|
/** Levels of city improvement in each track. */
|
||||||
|
const val IMPROVEMENT_MAX = 5
|
||||||
|
|
||||||
|
/** Progress cards a player may keep in hand. */
|
||||||
|
const val PROGRESS_HAND_MAX = 4
|
||||||
|
|
||||||
|
/** Cards a player may hold through a seven, before walls. */
|
||||||
|
const val BASE_HAND_LIMIT = 7
|
||||||
|
|
||||||
|
/** Longest Road is worth two points here; there is no Largest Army. */
|
||||||
|
const val CK_LONGEST_ROAD_VP = 2
|
||||||
|
|
||||||
|
/** Improvement level that unlocks the track's special power. */
|
||||||
|
const val IMPROVEMENT_POWER_LEVEL = 3
|
||||||
|
|
||||||
|
/** Level at which a track can claim its metropolis, and the level that can steal one. */
|
||||||
|
const val METROPOLIS_LEVEL = 4
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the three cities-of-industry give in place of a second resource:
|
||||||
|
* forest gives paper, pasture cloth and mountains coin.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
enum class Commodity { CLOTH, COIN, PAPER }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Any card a player can hold in hand and trade: a resource or a commodity.
|
||||||
|
* Progress cards are not goods.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
enum class Good {
|
||||||
|
LUMBER, BRICK, WOOL, GRAIN, ORE, CLOTH, COIN, PAPER;
|
||||||
|
|
||||||
|
val resource: Resource?
|
||||||
|
get() = when (this) {
|
||||||
|
LUMBER -> Resource.LUMBER
|
||||||
|
BRICK -> Resource.BRICK
|
||||||
|
WOOL -> Resource.WOOL
|
||||||
|
GRAIN -> Resource.GRAIN
|
||||||
|
ORE -> Resource.ORE
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
val commodity: Commodity?
|
||||||
|
get() = when (this) {
|
||||||
|
CLOTH -> Commodity.CLOTH
|
||||||
|
COIN -> Commodity.COIN
|
||||||
|
PAPER -> Commodity.PAPER
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
val isCommodity: Boolean get() = commodity != null
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun of(resource: Resource): Good = when (resource) {
|
||||||
|
Resource.LUMBER -> LUMBER
|
||||||
|
Resource.BRICK -> BRICK
|
||||||
|
Resource.WOOL -> WOOL
|
||||||
|
Resource.GRAIN -> GRAIN
|
||||||
|
Resource.ORE -> ORE
|
||||||
|
}
|
||||||
|
|
||||||
|
fun of(commodity: Commodity): Good = when (commodity) {
|
||||||
|
Commodity.CLOTH -> CLOTH
|
||||||
|
Commodity.COIN -> COIN
|
||||||
|
Commodity.PAPER -> PAPER
|
||||||
|
}
|
||||||
|
|
||||||
|
val RESOURCES: List<Good> = Resource.entries.map { of(it) }
|
||||||
|
val COMMODITIES: List<Good> = Commodity.entries.map { of(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three tracks of city improvement. Each is bought with one commodity, has
|
||||||
|
* a colour of progress-card deck and a power that unlocks at level three.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
enum class Track(val commodity: Commodity) {
|
||||||
|
/** Yellow, bought with cloth: trade commodities two for one. */
|
||||||
|
TRADE(Commodity.CLOTH),
|
||||||
|
|
||||||
|
/** Blue, bought with coin: knights may become mighty. */
|
||||||
|
POLITICS(Commodity.COIN),
|
||||||
|
|
||||||
|
/** Green, bought with paper: the aqueduct. */
|
||||||
|
SCIENCE(Commodity.PAPER),
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One face of the event die: a ship, or a city gate of a track's colour.
|
||||||
|
* Three faces are ships.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
enum class EventFace { SHIP, TRADE, POLITICS, SCIENCE }
|
||||||
|
|
||||||
|
/** The progress cards: [copies] of each in the deck of its [track]. */
|
||||||
|
@Serializable
|
||||||
|
enum class ProgressType(val track: Track, val copies: Int, val scoresAtOnce: Boolean = false) {
|
||||||
|
// Science (green)
|
||||||
|
ALCHEMIST(Track.SCIENCE, 2),
|
||||||
|
CRANE(Track.SCIENCE, 2),
|
||||||
|
ENGINEER(Track.SCIENCE, 1),
|
||||||
|
INVENTOR(Track.SCIENCE, 2),
|
||||||
|
IRRIGATION(Track.SCIENCE, 2),
|
||||||
|
MEDICINE(Track.SCIENCE, 2),
|
||||||
|
MINING(Track.SCIENCE, 2),
|
||||||
|
PRINTER(Track.SCIENCE, 1, scoresAtOnce = true),
|
||||||
|
ROAD_BUILDING(Track.SCIENCE, 2),
|
||||||
|
SMITH(Track.SCIENCE, 2),
|
||||||
|
|
||||||
|
// Politics (blue)
|
||||||
|
BISHOP(Track.POLITICS, 2),
|
||||||
|
CONSTITUTION(Track.POLITICS, 1, scoresAtOnce = true),
|
||||||
|
DESERTER(Track.POLITICS, 2),
|
||||||
|
DIPLOMAT(Track.POLITICS, 2),
|
||||||
|
INTRIGUE(Track.POLITICS, 2),
|
||||||
|
SABOTEUR(Track.POLITICS, 2),
|
||||||
|
SPY(Track.POLITICS, 3),
|
||||||
|
WARLORD(Track.POLITICS, 2),
|
||||||
|
WEDDING(Track.POLITICS, 2),
|
||||||
|
|
||||||
|
// Trade (yellow)
|
||||||
|
COMMERCIAL_HARBOR(Track.TRADE, 2),
|
||||||
|
MASTER_MERCHANT(Track.TRADE, 2),
|
||||||
|
MERCHANT(Track.TRADE, 6),
|
||||||
|
MERCHANT_FLEET(Track.TRADE, 2),
|
||||||
|
RESOURCE_MONOPOLY(Track.TRADE, 4),
|
||||||
|
TRADE_MONOPOLY(Track.TRADE, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One progress card; [id] is stable for the life of the game. */
|
||||||
|
@Serializable
|
||||||
|
data class ProgressCard(val id: Int, val kind: ProgressType)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A knight on the board. [level] is its strength: 1 basic, 2 strong, 3 mighty.
|
||||||
|
*
|
||||||
|
* [activatedTurn] and [promotedTurn] record the turn number each happened on:
|
||||||
|
* a knight cannot act on the turn it was activated, and cannot be promoted twice
|
||||||
|
* in one turn.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class Knight(
|
||||||
|
val vertex: Int,
|
||||||
|
val owner: Int,
|
||||||
|
val level: Int,
|
||||||
|
val active: Boolean = false,
|
||||||
|
val activatedTurn: Int = 0,
|
||||||
|
val promotedTurn: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A metropolis stands on one city: [vertex] of seat [seat]. */
|
||||||
|
@Serializable
|
||||||
|
data class MetropolisAt(val seat: Int, val vertex: Int)
|
||||||
|
|
||||||
|
/** The merchant sits on a hex, controlled by [seat] (who holds its point). */
|
||||||
|
@Serializable
|
||||||
|
data class MerchantAt(val seat: Int, val hex: Int)
|
||||||
|
|
||||||
|
/** A counter-proposal to a [GoodsOffer]: seat [by] would [give] these if the offerer handed over [get]. */
|
||||||
|
@Serializable
|
||||||
|
data class GoodsCounter(val by: Int, val give: List<Good>, val get: List<Good>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A domestic trade offer in goods, which may include commodities. It is the
|
||||||
|
* base game's [TradeOffer] in cards of any kind, and works the same way: [to] is
|
||||||
|
* the one seat it is addressed to or null for the whole table, [asking] is the
|
||||||
|
* seat that may answer now, and [counters] collect for the offerer to choose from.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class GoodsOffer(
|
||||||
|
val by: Int,
|
||||||
|
val to: Int?,
|
||||||
|
val give: List<Good>,
|
||||||
|
val get: List<Good>,
|
||||||
|
val asking: Int = to ?: -1,
|
||||||
|
val counters: List<GoodsCounter> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Where play goes when the queue of [Chore]s runs out. */
|
||||||
|
@Serializable
|
||||||
|
enum class Back { PLAYING, AWAIT_ROLL, AFTER_EVENT, AFTER_DISCARDS, AFTER_PRODUCTION }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Something one seat must decide before the game can go on. A chore is queued
|
||||||
|
* when a roll, an attack or a card needs an answer from somebody (possibly not
|
||||||
|
* the player whose turn it is); the first in [CkState.chores] is the one being
|
||||||
|
* asked.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
sealed interface Chore {
|
||||||
|
val seat: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Give [count] cards to the bank, of the seat's choosing (a seven, a saboteur). */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("c_discard")
|
||||||
|
data class DiscardChore(override val seat: Int, val count: Int) : Chore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand [count] cards of the seat's choosing to seat [to] (a wedding, a
|
||||||
|
* commercial harbour). [commoditiesOnly] restricts them to commodities, and
|
||||||
|
* [reward] is the resource [to] gives back for each.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("c_give")
|
||||||
|
data class GiveChore(
|
||||||
|
override val seat: Int,
|
||||||
|
val count: Int,
|
||||||
|
val to: Int,
|
||||||
|
val commoditiesOnly: Boolean = false,
|
||||||
|
val reward: Good? = null,
|
||||||
|
) : Chore
|
||||||
|
|
||||||
|
/** The barbarians won and this seat lost the contest: pick a city to fall. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("c_pillage")
|
||||||
|
data class PillageChore(override val seat: Int) : Chore
|
||||||
|
|
||||||
|
/** Choose a colour of progress deck to draw from. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("c_pickDeck")
|
||||||
|
data class PickDeckChore(override val seat: Int) : Chore
|
||||||
|
|
||||||
|
/** More progress cards than the hand holds: give one back. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("c_dropProgress")
|
||||||
|
data class DropProgressChore(override val seat: Int) : Chore
|
||||||
|
|
||||||
|
/** Choose the city that will bear the [track] metropolis. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("c_metropolis")
|
||||||
|
data class MetropolisChore(override val seat: Int, val track: Track) : Chore
|
||||||
|
|
||||||
|
/** The aqueduct pays out: take any one resource. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("c_aqueduct")
|
||||||
|
data class AqueductChore(override val seat: Int) : Chore
|
||||||
|
|
||||||
|
/** A knight was driven from its place; move it, or lose it. [knight] stood on [Knight.vertex]. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("c_relocate")
|
||||||
|
data class RelocateChore(override val seat: Int, val knight: Knight) : Chore
|
||||||
|
|
||||||
|
/** A deserter was played against this seat: remove one of your knights. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("c_removeKnight")
|
||||||
|
data class RemoveKnightChore(override val seat: Int, val by: Int) : Chore
|
||||||
|
|
||||||
|
/** Place a knight of [level] on the board for free, active or not as given. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("c_placeKnight")
|
||||||
|
data class PlaceKnightChore(override val seat: Int, val level: Int, val active: Boolean) : Chore
|
||||||
|
|
||||||
|
/** Choose one of [target]'s progress cards to take. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("c_spy")
|
||||||
|
data class SpyChore(override val seat: Int, val target: Int) : Chore
|
||||||
|
|
||||||
|
/** Promote up to [left] more knights for free. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("c_smith")
|
||||||
|
data class SmithChore(override val seat: Int, val left: Int) : Chore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything Cities & Knights adds to the table.
|
||||||
|
*
|
||||||
|
* Counts that are public ([progressCounts], the knights, the improvement
|
||||||
|
* levels) are stored directly; the decks and the other seats' progress hands are
|
||||||
|
* here in full because the host owns the truth, and [CatanRules.viewFor] keeps
|
||||||
|
* them off the wire.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class CkState(
|
||||||
|
/** Commodity cards per seat. */
|
||||||
|
val commodities: List<Map<Commodity, Int>>,
|
||||||
|
/** Improvement level (0..5) per seat and track. */
|
||||||
|
val improvements: List<Map<Track, Int>>,
|
||||||
|
/** The metropolis of each track that has one. */
|
||||||
|
val metropolises: Map<Track, MetropolisAt> = emptyMap(),
|
||||||
|
val knights: List<Knight> = emptyList(),
|
||||||
|
/** Vertices whose city has a wall. */
|
||||||
|
val walls: List<Int> = emptyList(),
|
||||||
|
/** Steps left before the barbarians land, [BARBARIAN_STEPS] at the start. */
|
||||||
|
val barbarians: Int = BARBARIAN_STEPS,
|
||||||
|
/** Whether the barbarians have landed once: until then the robber sleeps. */
|
||||||
|
val attacked: Boolean = false,
|
||||||
|
val eventDie: EventFace? = null,
|
||||||
|
/** Progress decks, top first. Order is hidden. */
|
||||||
|
val decks: Map<Track, List<ProgressCard>>,
|
||||||
|
val progressHands: List<List<ProgressCard>>,
|
||||||
|
/** Public mirror of the progress-hand sizes. */
|
||||||
|
val progressCounts: List<Int>,
|
||||||
|
/** Victory points from Constitution and Printer, played face up at once. */
|
||||||
|
val progressVp: List<Int>,
|
||||||
|
/** Defender of Catan cards won, one point each. */
|
||||||
|
val defenderVp: List<Int>,
|
||||||
|
val merchant: MerchantAt? = null,
|
||||||
|
/** A crane was played this turn and its discount is unspent. */
|
||||||
|
val crane: Boolean = false,
|
||||||
|
/** A merchant fleet was played this turn for this good. */
|
||||||
|
val fleet: Good? = null,
|
||||||
|
/** Dice named by an alchemist, used by the next roll. */
|
||||||
|
val alchemist: List<Int>? = null,
|
||||||
|
/** The robber is being moved by a bishop, which robs everyone nearby. */
|
||||||
|
val bishop: Boolean = false,
|
||||||
|
val chores: List<Chore> = emptyList(),
|
||||||
|
val back: Back = Back.PLAYING,
|
||||||
|
val goodsOffer: GoodsOffer? = null,
|
||||||
|
/** Seats that have already drawn a defender or winners' card this attack, for the log only. */
|
||||||
|
val lastAttack: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Moves
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Buy the next level of improvement in [track]. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("buyImprovement")
|
||||||
|
data class BuyImprovement(val track: Track) : CatanMove
|
||||||
|
|
||||||
|
/** Hire a basic knight onto [vertex], an empty crossing at the end of one of your roads. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("hireKnight")
|
||||||
|
data class HireKnight(val vertex: Int) : CatanMove
|
||||||
|
|
||||||
|
/** Pay a grain to activate the knight on [vertex]. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("activateKnight")
|
||||||
|
data class ActivateKnight(val vertex: Int) : CatanMove
|
||||||
|
|
||||||
|
/** Promote the knight on [vertex] one level (paid, or free under a Smith). */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("promoteKnight")
|
||||||
|
data class PromoteKnight(val vertex: Int) : CatanMove
|
||||||
|
|
||||||
|
/** Move the active knight on [from] along your roads to [to], driving off a weaker knight there. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("moveKnight")
|
||||||
|
data class MoveKnight(val from: Int, val to: Int) : CatanMove
|
||||||
|
|
||||||
|
/** The active knight on [vertex] chases the robber; the robber move follows. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("chaseRobber")
|
||||||
|
data class ChaseRobber(val vertex: Int) : CatanMove
|
||||||
|
|
||||||
|
/** Raise a wall around the city on [vertex]. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("buildWall")
|
||||||
|
data class BuildWall(val vertex: Int) : CatanMove
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a progress card. Which of the optional arguments are used depends on
|
||||||
|
* the card (see [CatanCk]); the engine's legal list names every combination it
|
||||||
|
* accepts, so a client only ever chooses among them.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("progress")
|
||||||
|
data class PlayProgress(
|
||||||
|
val card: Int,
|
||||||
|
val target: Int? = null,
|
||||||
|
val vertex: Int? = null,
|
||||||
|
val edge: Int? = null,
|
||||||
|
val hexA: Int? = null,
|
||||||
|
val hexB: Int? = null,
|
||||||
|
val good: Good? = null,
|
||||||
|
val dice: List<Int>? = null,
|
||||||
|
val goods: List<Good>? = null,
|
||||||
|
) : CatanMove
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trade [give] to the bank for [get], at the best ratio this seat has for the
|
||||||
|
* card it gives. Replaces [MaritimeTrade] in Cities & Knights, where
|
||||||
|
* commodities trade too.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("goodsBank")
|
||||||
|
data class GoodsBankTrade(val give: Good, val get: Good) : CatanMove
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offer a trade in goods to seat [to], or to the whole table when [to] is null.
|
||||||
|
* Free-form like [OfferTrade], so it is checked structurally and is not in the
|
||||||
|
* enumerated move list.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("offerGoods")
|
||||||
|
data class OfferGoods(val to: Int?, val give: List<Good>, val get: List<Good>) : CatanMove
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answer the goods offer on the table with different terms. Structural, like
|
||||||
|
* [OfferGoods]; the answers to the counters are the shared [AcceptCounter] and
|
||||||
|
* [DismissCounters].
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("counterGoods")
|
||||||
|
data class CounterGoods(val give: List<Good>, val get: List<Good>) : CatanMove
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give up exactly the cards named, to a [DiscardChore]. Structural, not
|
||||||
|
* enumerated; the legal list carries one example so a seat is never without a move.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("discardGoods")
|
||||||
|
data class DiscardGoods(val cards: List<Good>) : CatanMove
|
||||||
|
|
||||||
|
/** Hand over the cards named to a [GiveChore]. Structural, like [DiscardGoods]. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("giveGoods")
|
||||||
|
data class GiveGoods(val cards: List<Good>) : CatanMove
|
||||||
|
|
||||||
|
/** Let the city on [vertex] fall to the barbarians. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("pillageCity")
|
||||||
|
data class PillageCity(val vertex: Int) : CatanMove
|
||||||
|
|
||||||
|
/** Draw from the deck of this track's colour. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("pickDeck")
|
||||||
|
data class PickDeck(val track: Track) : CatanMove
|
||||||
|
|
||||||
|
/** Give up one progress card from a hand that is over the limit. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("dropProgress")
|
||||||
|
data class DropProgress(val card: Int) : CatanMove
|
||||||
|
|
||||||
|
/** Put the metropolis on the city at [vertex]. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("placeMetropolis")
|
||||||
|
data class PlaceMetropolis(val vertex: Int) : CatanMove
|
||||||
|
|
||||||
|
/** Take one [resource] from the bank for an aqueduct. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("aqueduct")
|
||||||
|
data class TakeAqueduct(val resource: Resource) : CatanMove
|
||||||
|
|
||||||
|
/** Move the knight that was driven off to [to]. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("relocateKnight")
|
||||||
|
data class RelocateKnight(val to: Int) : CatanMove
|
||||||
|
|
||||||
|
/** Take the knight on [vertex] off the board, for a deserter. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("removeKnight")
|
||||||
|
data class RemoveKnight(val vertex: Int) : CatanMove
|
||||||
|
|
||||||
|
/** Place the free knight from a deserter on [vertex]. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("placeKnight")
|
||||||
|
data class PlaceKnight(val vertex: Int) : CatanMove
|
||||||
|
|
||||||
|
/** Take the spied progress card with this id. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("spyTake")
|
||||||
|
data class SpyTake(val card: Int) : CatanMove
|
||||||
|
|
||||||
|
/** Stop promoting knights under a Smith. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("endSmith")
|
||||||
|
data object EndSmith : CatanMove
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
package org.prolibertate.games.game.catan
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** Sentinel stored in [CatanState.roads] for an unclaimed road position. */
|
||||||
|
const val NO_PLAYER = -1
|
||||||
|
|
||||||
|
/** Road segments required to hold Longest Road. */
|
||||||
|
const val LONGEST_ROAD_MIN = 5
|
||||||
|
|
||||||
|
/** Knights required to take Largest Army. */
|
||||||
|
const val LARGEST_ARMY_MIN = 3
|
||||||
|
|
||||||
|
/** Pieces each player has: a player who has laid them all cannot build another. */
|
||||||
|
const val MAX_ROADS = 15
|
||||||
|
const val MAX_SETTLEMENTS = 5
|
||||||
|
const val MAX_CITIES = 4
|
||||||
|
|
||||||
|
/** Resource cards of each type the bank starts with. */
|
||||||
|
const val BANK_PER_RESOURCE = 19
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The five resources the island produces.
|
||||||
|
*
|
||||||
|
* Catan names two of them after the terrain rather than the commodity
|
||||||
|
* (wood becomes lumber, sheep become wool); those names travel with the
|
||||||
|
* trademark, so the engine speaks in resources only.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
enum class Resource { LUMBER, BRICK, WOOL, GRAIN, ORE }
|
||||||
|
|
||||||
|
/** One terrain type per hex; the desert produces nothing. */
|
||||||
|
@Serializable
|
||||||
|
enum class Terrain(val produces: Resource?) {
|
||||||
|
FOREST(Resource.LUMBER),
|
||||||
|
PASTURE(Resource.WOOL),
|
||||||
|
FIELDS(Resource.GRAIN),
|
||||||
|
HILLS(Resource.BRICK),
|
||||||
|
MOUNTAINS(Resource.ORE),
|
||||||
|
DESERT(null),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Axial coordinate of one hex on the board. See [CatanBoard]. */
|
||||||
|
@Serializable
|
||||||
|
data class HexCoord(val q: Int, val r: Int)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class BuildingType { SETTLEMENT, CITY }
|
||||||
|
|
||||||
|
/** What stands on one vertex: a settlement or city owned by one seat. */
|
||||||
|
@Serializable
|
||||||
|
data class CatanBuilding(val type: BuildingType, val owner: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One harbour. A port sits on a coastal edge: a player who owns a
|
||||||
|
* settlement or city on *either* endpoint vertex of [edge] may trade at
|
||||||
|
* [ratio] (2 or 3) instead of the bank's 4:1. A resource port trades at 2:1
|
||||||
|
* for its own resource only; [resource] is null for a generic 3:1 harbour.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class Port(val edge: Int, val resource: Resource?, val ratio: Int)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class DevCardType { KNIGHT, ROAD_BUILDING, MONOPOLY, YEAR_OF_PLENTY, VICTORY_POINT }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One development card. [id] is stable for the life of the game (the deck
|
||||||
|
* is built once from the seed), so moves can name a card by id even after
|
||||||
|
* it moves from the deck into a hand. [boughtTurn] records when it was
|
||||||
|
* drawn: a card cannot be played on the turn it was bought.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class DevCard(val id: Int, val type: DevCardType, val boughtTurn: Int = 0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A counter-proposal to a [TradeOffer]: seat [by] would [give] these cards to
|
||||||
|
* the offerer if the offerer would hand over [get].
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class TradeCounter(val by: Int, val give: List<Resource>, val get: List<Resource>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A domestic trade offer on the table. [by] is the offering seat. [to] is the
|
||||||
|
* one seat it is addressed to, or null for an open offer to the whole table.
|
||||||
|
* Seats are asked one at a time, in seat order after the offerer; [asking] is
|
||||||
|
* the seat that may answer now. Each may accept (the trade happens at once),
|
||||||
|
* decline, or counter; counters are kept in [counters] and, if nobody accepts,
|
||||||
|
* the offerer chooses among them in [CatanPhase.TRADE_COUNTERS].
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class TradeOffer(
|
||||||
|
val by: Int,
|
||||||
|
val to: Int?,
|
||||||
|
val give: List<Resource>,
|
||||||
|
val get: List<Resource>,
|
||||||
|
val asking: Int = to ?: -1,
|
||||||
|
val counters: List<TradeCounter> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the game stands. The phases that involve more than the player
|
||||||
|
* whose turn it is carry their own bookkeeping: [discardSeat] during
|
||||||
|
* [CatanPhase.DISCARDING], [pendingTrade] during [CatanPhase.TRADE_PENDING] and [CatanPhase.TRADE_COUNTERS].
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
enum class CatanPhase {
|
||||||
|
/** Snake order forward: settlement + road each, seats 0..n-1. */
|
||||||
|
SETUP_FIRST,
|
||||||
|
|
||||||
|
/** Snake order back: settlement + road each, seats n-1..0, plus income. */
|
||||||
|
SETUP_SECOND,
|
||||||
|
|
||||||
|
/** The player whose turn it is must roll the dice. */
|
||||||
|
AWAIT_ROLL,
|
||||||
|
|
||||||
|
/** Players holding more than 7 resource cards discard half. */
|
||||||
|
DISCARDING,
|
||||||
|
|
||||||
|
/** The roller must move the robber and steal. */
|
||||||
|
ROBBER,
|
||||||
|
|
||||||
|
/** Build, trade, buy and play development cards, or end the turn. */
|
||||||
|
PLAYING,
|
||||||
|
|
||||||
|
/** A played Road Building card: up to two roads remain to be placed. */
|
||||||
|
FREE_ROADS,
|
||||||
|
|
||||||
|
/** The [TradeOffer.asking] seat must accept, decline or counter. */
|
||||||
|
TRADE_PENDING,
|
||||||
|
|
||||||
|
/** Nobody accepted but some seats countered: the offerer picks one or none. */
|
||||||
|
TRADE_COUNTERS,
|
||||||
|
|
||||||
|
GAME_OVER,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cities & Knights only: the first seat in [CkState.chores] has something
|
||||||
|
* to decide (a discard, a city to give up, a card to draw…) and the game
|
||||||
|
* waits on it. Play resumes where [CkState.back] says.
|
||||||
|
*/
|
||||||
|
CHORE,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which rules a table plays. The expansion is a whole rule set, not a toggle. */
|
||||||
|
@Serializable
|
||||||
|
enum class CatanRuleSet {
|
||||||
|
/** The base game: development cards, Largest Army, a robber from the start. */
|
||||||
|
BASE,
|
||||||
|
|
||||||
|
/** Cities & Knights: commodities, knights, barbarians, city improvements. */
|
||||||
|
CITIES_AND_KNIGHTS,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CatanOptions(
|
||||||
|
val playerCount: Int = 3,
|
||||||
|
val pointsToWin: Int = 10,
|
||||||
|
val ruleSet: CatanRuleSet = CatanRuleSet.BASE,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(playerCount in 3..4) { "The island seats three or four players" }
|
||||||
|
require(pointsToWin >= 2) { "pointsToWin must be at least 2" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole game as one immutable value.
|
||||||
|
*
|
||||||
|
* Public information (terrain, numbers, ports, buildings, roads, resource
|
||||||
|
* counts, dev-card counts, knights played) is stored directly. Hidden
|
||||||
|
* information — the deck's order and every hand's development cards — is
|
||||||
|
* here in full because the host owns the truth; [CatanRules.viewFor] is
|
||||||
|
* what keeps it off the wire.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class CatanState(
|
||||||
|
val options: CatanOptions,
|
||||||
|
val seed: Long,
|
||||||
|
val phase: CatanPhase,
|
||||||
|
/**
|
||||||
|
* The seat whose turn it is — not always the seat expected to move:
|
||||||
|
* during [CatanPhase.DISCARDING] and [CatanPhase.TRADE_PENDING] another
|
||||||
|
* seat acts on the turn owner's behalf.
|
||||||
|
*/
|
||||||
|
val turn: Int,
|
||||||
|
/** How many setup placements are done, 0 until 2 * playerCount. */
|
||||||
|
val setupIndex: Int,
|
||||||
|
/** Monotonic turn counter, 0 during setup, 1 from the first real turn. */
|
||||||
|
val turnNumber: Int,
|
||||||
|
/** Terrain per hex, indexed like [CatanBoard.HEXES]. */
|
||||||
|
val terrains: List<Terrain>,
|
||||||
|
/** Number token per hex, 0 where there is none (the desert). */
|
||||||
|
val numbers: List<Int>,
|
||||||
|
/** Hex the robber currently occupies. */
|
||||||
|
val robber: Int,
|
||||||
|
val ports: List<Port>,
|
||||||
|
/** What stands on each vertex, or null. */
|
||||||
|
val buildings: List<CatanBuilding?>,
|
||||||
|
/** Owning seat per edge, or [NO_PLAYER]. */
|
||||||
|
val roads: List<Int>,
|
||||||
|
/** Resource cards per seat. Card counts are public in Catan. */
|
||||||
|
val resources: List<Map<Resource, Int>>,
|
||||||
|
/** The bank's development cards, top first. Order is hidden. */
|
||||||
|
val devDeck: List<DevCard>,
|
||||||
|
/** Development cards per seat. Hidden from everyone but the holder. */
|
||||||
|
val devHands: List<List<DevCard>>,
|
||||||
|
/** Public mirror of the dev-hand sizes, for views and scoreboards. */
|
||||||
|
val devHandCounts: List<Int>,
|
||||||
|
/** Knights played (not merely held) per seat. */
|
||||||
|
val knightsPlayed: List<Int>,
|
||||||
|
/** Whether the turn owner has already played a development card. */
|
||||||
|
val devCardPlayedThisTurn: Boolean,
|
||||||
|
val pendingTrade: TradeOffer?,
|
||||||
|
/** Roads still owed on a played Road Building card. */
|
||||||
|
val pendingFreeRoads: Int,
|
||||||
|
/** Seat due to discard during [CatanPhase.DISCARDING]. */
|
||||||
|
val discardSeat: Int?,
|
||||||
|
val longestRoadOwner: Int?,
|
||||||
|
val largestArmyOwner: Int?,
|
||||||
|
/** The dice as last rolled, for the table display. */
|
||||||
|
val lastDice: List<Int>,
|
||||||
|
/**
|
||||||
|
* How many dice rolls and steals have happened so far. Random events are
|
||||||
|
* derived from the seed and this counter, so the same game replays
|
||||||
|
* identically without storing a random generator in the state.
|
||||||
|
*/
|
||||||
|
val rngStep: Int,
|
||||||
|
val winner: Int?,
|
||||||
|
val log: List<String>,
|
||||||
|
/** Everything Cities & Knights adds; null in the base game. */
|
||||||
|
val ck: CkState? = null,
|
||||||
|
) {
|
||||||
|
val playerCount: Int get() = options.playerCount
|
||||||
|
|
||||||
|
/** Whether this table plays Cities & Knights. */
|
||||||
|
val isCk: Boolean get() = ck != null
|
||||||
|
|
||||||
|
/** Total resource cards held by [seat]. */
|
||||||
|
fun resourceCount(seat: Int): Int = resources[seat].values.sum()
|
||||||
|
|
||||||
|
/** [seat]'s resource cards as a flat list, one entry per card. */
|
||||||
|
fun handOf(seat: Int): List<Resource> =
|
||||||
|
Resource.entries.flatMap { res -> List(resources[seat][res] ?: 0) { res } }
|
||||||
|
|
||||||
|
/** Whether [seat] holds every card in [cards], counting duplicates. */
|
||||||
|
fun holdsAll(seat: Int, cards: List<Resource>): Boolean {
|
||||||
|
val need = cards.groupingBy { it }.eachCount()
|
||||||
|
return need.all { (res, n) -> (resources[seat][res] ?: 0) >= n }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed interface CatanMove
|
||||||
|
|
||||||
|
/** Setup placement: a settlement on [vertex] plus a road on [edge]. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("setup")
|
||||||
|
data class SetupPlace(val vertex: Int, val edge: Int) : CatanMove
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roll the dice.
|
||||||
|
*
|
||||||
|
* With [forced] null the roll is derived from the game seed, which is what
|
||||||
|
* the AI, the UI and the network always send. [forced] exists so tests and
|
||||||
|
* offline reviews can stage a specific number; it is not part of the
|
||||||
|
* enumerated move list, so the network driver's "is this move in the legal
|
||||||
|
* list" check rejects any forced roll a client tries to sneak through —
|
||||||
|
* only code holding the state directly (the host's own tests) can use it.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("roll")
|
||||||
|
data class RollDice(
|
||||||
|
val forced: List<Int>? = null,
|
||||||
|
/** Test-only, like [forced]: the event die of a Cities & Knights roll. */
|
||||||
|
val event: EventFace? = null,
|
||||||
|
) : CatanMove
|
||||||
|
|
||||||
|
/** Discard exactly these cards. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("discard")
|
||||||
|
data class Discard(val cards: List<Resource>) : CatanMove
|
||||||
|
|
||||||
|
/** Move the robber to [hex] and steal from [victim], if anyone can be robbed. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("robber")
|
||||||
|
data class MoveRobber(val hex: HexCoord, val victim: Int? = null) : CatanMove
|
||||||
|
|
||||||
|
/** Build a road on [edge] — paid normally, or free during FREE_ROADS. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("road")
|
||||||
|
data class BuildRoad(val edge: Int) : CatanMove
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("settlement")
|
||||||
|
data class BuildSettlement(val vertex: Int) : CatanMove
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("city")
|
||||||
|
data class BuildCity(val vertex: Int) : CatanMove
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("buyCard")
|
||||||
|
data object BuyDevCard : CatanMove
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a knight. The card is named by [card] id; the robber move follows as
|
||||||
|
* its own move once the phase has advanced.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("knight")
|
||||||
|
data class PlayKnight(val card: Int) : CatanMove
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("roadBuilding")
|
||||||
|
data class PlayRoadBuilding(val card: Int) : CatanMove
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("monopoly")
|
||||||
|
data class PlayMonopoly(val card: Int, val resource: Resource) : CatanMove
|
||||||
|
|
||||||
|
/** Take [take] (up to two cards) from the bank. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("plenty")
|
||||||
|
data class PlayYearOfPlenty(val card: Int, val take: List<Resource>) : CatanMove
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offer a domestic trade to [to] (null: everyone, asked in turn): [give] of
|
||||||
|
* ours for [get] of theirs.
|
||||||
|
*
|
||||||
|
* Like a forced dice roll this is a free-form intent rather than an
|
||||||
|
* enumerated move — the space of two card multisets is combinatorial — so
|
||||||
|
* it is validated structurally by [CatanRules.isLegalMove] instead of
|
||||||
|
* appearing in [CatanRules.legalMoves].
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("offer")
|
||||||
|
data class OfferTrade(val to: Int?, val give: List<Resource>, val get: List<Resource>) : CatanMove
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("accept")
|
||||||
|
data object AcceptTrade : CatanMove
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("decline")
|
||||||
|
data object DeclineTrade : CatanMove
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answer the offer on the table with different terms: we would [give] these
|
||||||
|
* cards if the offerer handed over [get]. Free-form like [OfferTrade], so
|
||||||
|
* validated by [CatanRules.isLegalMove] rather than listed.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("counter")
|
||||||
|
data class CounterTrade(val give: List<Resource>, val get: List<Resource>) : CatanMove
|
||||||
|
|
||||||
|
/** The offerer takes the counter-proposal made by [seat]. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("acceptCounter")
|
||||||
|
data class AcceptCounter(val seat: Int) : CatanMove
|
||||||
|
|
||||||
|
/** The offerer takes none of the counter-proposals. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("dismissCounters")
|
||||||
|
data object DismissCounters : CatanMove
|
||||||
|
|
||||||
|
/** Bank trade: [give] at the best ratio this seat can trade at for [get]. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("bank")
|
||||||
|
data class MaritimeTrade(val give: Resource, val get: Resource) : CatanMove
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("endTurn")
|
||||||
|
data object EndTurn : CatanMove
|
||||||
|
|
||||||
|
/** Forfeit any remaining free roads from a played Road Building card. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("endFreeRoads")
|
||||||
|
data object EndFreeRoads : CatanMove
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -64,6 +64,21 @@ interface GameRules<S : Any, M : Any> {
|
|||||||
|
|
||||||
fun legalMoves(state: S, seat: Int): List<M>
|
fun legalMoves(state: S, seat: Int): List<M>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [move] is currently legal for [seat].
|
||||||
|
*
|
||||||
|
* Defaults to membership in [legalMoves], which is everything a game
|
||||||
|
* with a small, enumerable move set needs. A game whose move space is
|
||||||
|
* combinatorial — Catan's trade offers name any two multisets of cards —
|
||||||
|
* overrides this and validates structurally instead, because enumerating
|
||||||
|
* every intent would explode the list the UI and the network driver work
|
||||||
|
* from. Gates that check a specific move should call this method rather
|
||||||
|
* than testing list membership directly, so both kinds of game behave the
|
||||||
|
* same way.
|
||||||
|
*/
|
||||||
|
fun isLegalMove(state: S, seat: Int, move: M): Boolean =
|
||||||
|
move in legalMoves(state, seat)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies [move] on behalf of [seat].
|
* Applies [move] on behalf of [seat].
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package org.prolibertate.games.game.engine
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A game in progress, kept so it can be picked up again after the app has been
|
||||||
|
* closed.
|
||||||
|
*
|
||||||
|
* The match is stored the way the host holds it: the [config] it was dealt from
|
||||||
|
* (seed, seats, options) and the whole authoritative [stateJson], hidden cards
|
||||||
|
* included, as the game's own rules encode it. Nothing here knows what game it
|
||||||
|
* is; [gameId] is only the name of the slot.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class SavedMatch(
|
||||||
|
val gameId: String,
|
||||||
|
val config: TableConfig,
|
||||||
|
/** The seat the person at this device plays. */
|
||||||
|
val localSeat: Int,
|
||||||
|
val stateJson: String,
|
||||||
|
/** Wall-clock milliseconds when the position was written. */
|
||||||
|
val savedAtMillis: Long,
|
||||||
|
/** A line saying how the game stands, from the rules' own summary. */
|
||||||
|
val summary: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One saved game per game, kept as a file each under [directory].
|
||||||
|
*
|
||||||
|
* One slot per game rather than a list: the player is mid-way through a game of
|
||||||
|
* Chess or they are not. Starting a new one replaces it, which is what the
|
||||||
|
* resume prompt on the setup screen says.
|
||||||
|
*
|
||||||
|
* Writes are atomic — the position goes to a temporary file that is then moved
|
||||||
|
* over the real one — so being killed half way through a save leaves the
|
||||||
|
* previous position, not half of the new one. Reads never throw: a file that
|
||||||
|
* will not parse is a game that cannot be resumed, so it is removed and reported
|
||||||
|
* as absent.
|
||||||
|
*/
|
||||||
|
class SavedGameStore(private val directory: File) {
|
||||||
|
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fileFor(gameId: String): File {
|
||||||
|
// The ids are catalogue constants, but they become file names, so
|
||||||
|
// nothing that could climb out of the directory is let through.
|
||||||
|
require(gameId.isNotBlank() && gameId.all { it.isLetterOrDigit() || it == '_' || it == '-' }) {
|
||||||
|
"Not a game id: $gameId"
|
||||||
|
}
|
||||||
|
return File(directory, "$gameId.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes [match] over whatever was saved for its game. */
|
||||||
|
@Synchronized
|
||||||
|
fun save(match: SavedMatch) {
|
||||||
|
directory.mkdirs()
|
||||||
|
val target = fileFor(match.gameId)
|
||||||
|
val temp = File(directory, "${match.gameId}.json.tmp")
|
||||||
|
temp.writeText(json.encodeToString(SavedMatch.serializer(), match))
|
||||||
|
if (!temp.renameTo(target)) {
|
||||||
|
// Some file systems will not rename over an existing file.
|
||||||
|
target.delete()
|
||||||
|
check(temp.renameTo(target)) { "Could not write the saved game" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The saved game for [gameId], or null if there is none or it cannot be read. */
|
||||||
|
@Synchronized
|
||||||
|
fun load(gameId: String): SavedMatch? {
|
||||||
|
val file = fileFor(gameId)
|
||||||
|
if (!file.isFile) return null
|
||||||
|
val match = runCatching {
|
||||||
|
json.decodeFromString(SavedMatch.serializer(), file.readText())
|
||||||
|
}.getOrNull()
|
||||||
|
if (match == null || match.gameId != gameId) {
|
||||||
|
file.delete()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forgets the saved game for [gameId]. A no-op if there is none. */
|
||||||
|
@Synchronized
|
||||||
|
fun clear(gameId: String) {
|
||||||
|
fileFor(gameId).delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every readable saved game, the most recently played first. */
|
||||||
|
@Synchronized
|
||||||
|
fun all(): List<SavedMatch> {
|
||||||
|
val files = directory.listFiles { f -> f.isFile && f.name.endsWith(".json") } ?: return emptyList()
|
||||||
|
return files
|
||||||
|
.mapNotNull { file -> runCatching { load(file.name.removeSuffix(".json")) }.getOrNull() }
|
||||||
|
.sortedByDescending { it.savedAtMillis }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,6 +96,27 @@ class MatchController<S : Any, M : Any>(
|
|||||||
|
|
||||||
/** Host only: the true state. Clients never see this. */
|
/** Host only: the true state. Clients never see this. */
|
||||||
private var authoritative: S? = null
|
private var authoritative: S? = null
|
||||||
|
|
||||||
|
/** A position to carry on from instead of dealing, set by [restore] before [startAsHost]. */
|
||||||
|
private var restored: S? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host only: told about every position the table reaches, so it can be kept
|
||||||
|
* for later. Called under the table's lock, so it must return quickly — the
|
||||||
|
* snapshot encodes its state only when asked, and the work of writing it
|
||||||
|
* belongs to whoever is listening.
|
||||||
|
*/
|
||||||
|
var onHostState: ((HostSnapshot) -> Unit)? = null
|
||||||
|
|
||||||
|
/** One position of the table, as handed to [onHostState]. */
|
||||||
|
class HostSnapshot(
|
||||||
|
val finished: Boolean,
|
||||||
|
val summary: String,
|
||||||
|
private val encode: () -> String,
|
||||||
|
) {
|
||||||
|
/** The whole true state, encoded by the game's own rules. */
|
||||||
|
fun stateJson(): String = encode()
|
||||||
|
}
|
||||||
private val lock = Mutex()
|
private val lock = Mutex()
|
||||||
private var sequence = 0
|
private var sequence = 0
|
||||||
|
|
||||||
@@ -160,7 +181,7 @@ class MatchController<S : Any, M : Any>(
|
|||||||
check(role == Role.HOST) { "Not hosting" }
|
check(role == Role.HOST) { "Not hosting" }
|
||||||
clients.forEach { connection -> listenToPeer(connection) }
|
clients.forEach { connection -> listenToPeer(connection) }
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val initial = runCatching { rules.initialState(config) }.getOrElse { error ->
|
val initial = restored ?: runCatching { rules.initialState(config) }.getOrElse { error ->
|
||||||
// A table the rules will not accept is a dead end, not a reason
|
// A table the rules will not accept is a dead end, not a reason
|
||||||
// to take the app down with it: this runs in the screen's own
|
// to take the app down with it: this runs in the screen's own
|
||||||
// scope, where nothing would catch it.
|
// scope, where nothing would catch it.
|
||||||
@@ -175,6 +196,20 @@ class MatchController<S : Any, M : Any>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host: carry on from a saved position instead of dealing a new game.
|
||||||
|
*
|
||||||
|
* Call before [startAsHost]. Returns false, and changes nothing, if
|
||||||
|
* [stateJson] cannot be read by this version of the game — a save made by an
|
||||||
|
* older build, say — and the caller should deal a fresh table instead.
|
||||||
|
*/
|
||||||
|
fun restore(stateJson: String): Boolean {
|
||||||
|
check(role == Role.HOST) { "Only a host can restore a game" }
|
||||||
|
val state = runCatching { rules.decodeState(stateJson) }.getOrNull() ?: return false
|
||||||
|
restored = state
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
private fun listenToPeer(connection: Connection) {
|
private fun listenToPeer(connection: Connection) {
|
||||||
peers[connection.peerId] = connection
|
peers[connection.peerId] = connection
|
||||||
peerJobs[connection.peerId] = scope.launch {
|
peerJobs[connection.peerId] = scope.launch {
|
||||||
@@ -349,7 +384,7 @@ class MatchController<S : Any, M : Any>(
|
|||||||
seatPeers[seat]?.let { peers[it]?.send(Rejected("It is not your turn.")) }
|
seatPeers[seat]?.let { peers[it]?.send(Rejected("It is not your turn.")) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (move !in rules.legalMoves(current, seat)) {
|
if (!rules.isLegalMove(current, seat, move)) {
|
||||||
seatPeers[seat]?.let { peers[it]?.send(Rejected("That move is not legal.")) }
|
seatPeers[seat]?.let { peers[it]?.send(Rejected("That move is not legal.")) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -424,7 +459,7 @@ class MatchController<S : Any, M : Any>(
|
|||||||
// throws on a move that is no longer legal, and there is nothing
|
// throws on a move that is no longer legal, and there is nothing
|
||||||
// above this to catch it.
|
// above this to catch it.
|
||||||
if (rules.currentSeat(live) != seat) return@withLock false
|
if (rules.currentSeat(live) != seat) return@withLock false
|
||||||
if (move !in rules.legalMoves(live, seat)) return@withLock false
|
if (!rules.isLegalMove(live, seat, move)) return@withLock false
|
||||||
authoritative = rules.applyMove(live, seat, move)
|
authoritative = rules.applyMove(live, seat, move)
|
||||||
publish()
|
publish()
|
||||||
true
|
true
|
||||||
@@ -449,6 +484,16 @@ class MatchController<S : Any, M : Any>(
|
|||||||
}
|
}
|
||||||
_finished.value = rules.isFinished(current)
|
_finished.value = rules.isFinished(current)
|
||||||
|
|
||||||
|
if (role == Role.HOST) {
|
||||||
|
onHostState?.invoke(
|
||||||
|
HostSnapshot(
|
||||||
|
finished = rules.isFinished(current),
|
||||||
|
summary = rules.summary(current),
|
||||||
|
encode = { rules.encodeState(current) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
for (slot in config.seats) {
|
for (slot in config.seats) {
|
||||||
val peerId = slot.peerId ?: continue
|
val peerId = slot.peerId ?: continue
|
||||||
val connection = peers[peerId] ?: continue
|
val connection = peers[peerId] ?: continue
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package org.prolibertate.games.net
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.prolibertate.games.game.engine.SavedGameStore
|
||||||
|
import org.prolibertate.games.game.engine.SavedMatch
|
||||||
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the position of one table on disk as it is played.
|
||||||
|
*
|
||||||
|
* It is fed every position the host reaches ([offer]) and writes the newest one
|
||||||
|
* off the main thread. Positions that arrive while a write is under way are
|
||||||
|
* dropped in favour of the latest — a computer's turn can be several moves in a
|
||||||
|
* second, and only where the game stands matters. A finished game is not kept:
|
||||||
|
* its slot is cleared, so a won game is not offered back as one to resume.
|
||||||
|
*
|
||||||
|
* [flush] writes whatever has not been written yet, on the caller's thread. It
|
||||||
|
* is what makes leaving the table, or the app going to the background, safe: the
|
||||||
|
* background writer may not have caught up, and the process may not be given the
|
||||||
|
* chance to let it.
|
||||||
|
*/
|
||||||
|
class MatchSaver(
|
||||||
|
private val store: SavedGameStore,
|
||||||
|
private val gameId: String,
|
||||||
|
private val config: TableConfig,
|
||||||
|
private val localSeat: Int,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val clock: () -> Long = System::currentTimeMillis,
|
||||||
|
) {
|
||||||
|
private val latest = MutableStateFlow<MatchController.HostSnapshot?>(null)
|
||||||
|
private var worker: Job? = null
|
||||||
|
private var written: MatchController.HostSnapshot? = null
|
||||||
|
private var discarded = false
|
||||||
|
|
||||||
|
/** Starts the background writer. */
|
||||||
|
fun start() {
|
||||||
|
worker = scope.launch(Dispatchers.IO) {
|
||||||
|
latest.filterNotNull().collectLatest { write(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hands over the newest position. Cheap: the encoding happens on the writer. */
|
||||||
|
fun offer(snapshot: MatchController.HostSnapshot) {
|
||||||
|
latest.value = snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes the newest position now if the writer has not already. */
|
||||||
|
fun flush() {
|
||||||
|
latest.value?.let { write(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The game was ended on purpose: forget the saved position and write no more. */
|
||||||
|
@Synchronized
|
||||||
|
fun discard() {
|
||||||
|
discarded = true
|
||||||
|
worker?.cancel()
|
||||||
|
worker = null
|
||||||
|
runCatching { store.clear(gameId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
worker?.cancel()
|
||||||
|
worker = null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun write(snapshot: MatchController.HostSnapshot) {
|
||||||
|
if (discarded || snapshot === written) return
|
||||||
|
written = snapshot
|
||||||
|
// Saving is a courtesy to the player: a full disk must not take the game
|
||||||
|
// down with it.
|
||||||
|
runCatching {
|
||||||
|
if (snapshot.finished) {
|
||||||
|
store.clear(gameId)
|
||||||
|
} else {
|
||||||
|
store.save(
|
||||||
|
SavedMatch(
|
||||||
|
gameId = gameId,
|
||||||
|
config = config,
|
||||||
|
localSeat = localSeat,
|
||||||
|
stateJson = snapshot.stateJson(),
|
||||||
|
savedAtMillis = clock(),
|
||||||
|
summary = snapshot.summary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
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
|
||||||
@@ -50,6 +52,10 @@ import org.prolibertate.games.settings.SettingsRepository
|
|||||||
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
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.prolibertate.games.game.engine.SavedGameStore
|
||||||
|
import org.prolibertate.games.game.engine.SavedMatch
|
||||||
|
|
||||||
/** Everything the screens need, assembled once by the activity. */
|
/** Everything the screens need, assembled once by the activity. */
|
||||||
class AppEnv(
|
class AppEnv(
|
||||||
@@ -60,6 +66,8 @@ class AppEnv(
|
|||||||
/** The shared score sheet, for the Scorekeeper to host or join. */
|
/** The shared score sheet, for the Scorekeeper to host or join. */
|
||||||
val scoreSession: ScorekeeperSession,
|
val scoreSession: ScorekeeperSession,
|
||||||
val peerId: String,
|
val peerId: String,
|
||||||
|
/** Games in progress, kept between sessions. */
|
||||||
|
val saves: SavedGameStore,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,6 +91,8 @@ sealed interface Route {
|
|||||||
val config: TableConfig,
|
val config: TableConfig,
|
||||||
val hosting: Boolean,
|
val hosting: Boolean,
|
||||||
val localSeat: Int,
|
val localSeat: Int,
|
||||||
|
/** A saved position to carry on from, as the game's rules encoded it; null deals a new game. */
|
||||||
|
val resumeState: String? = null,
|
||||||
) : Route
|
) : Route
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +177,10 @@ private fun AppContent(
|
|||||||
// Dev builds show the games that are not finished yet; production
|
// Dev builds show the games that are not finished yet; production
|
||||||
// releases list only what can actually be played.
|
// releases list only what can actually be played.
|
||||||
showComingSoon = showComingSoon,
|
showComingSoon = showComingSoon,
|
||||||
|
saved = savedGames(env, stack),
|
||||||
|
onResume = { saved ->
|
||||||
|
push(Route.Play(saved.gameId, saved.config, true, saved.localSeat, saved.stateJson))
|
||||||
|
},
|
||||||
onPickSection = { push(Route.Section(it)) },
|
onPickSection = { push(Route.Section(it)) },
|
||||||
onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) },
|
onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) },
|
||||||
onScorekeeper = { push(Route.Scorekeeper) },
|
onScorekeeper = { push(Route.Scorekeeper) },
|
||||||
@@ -194,7 +208,13 @@ private fun AppContent(
|
|||||||
onBack = { pop() },
|
onBack = { pop() },
|
||||||
)
|
)
|
||||||
|
|
||||||
is Route.Setup -> GameSetupScreen(
|
is Route.Setup -> {
|
||||||
|
// Bumped when the saved game is thrown away, so the banner goes with it.
|
||||||
|
var discards by remember(route) { mutableIntStateOf(0) }
|
||||||
|
val saved by produceState<SavedMatch?>(null, route, discards) {
|
||||||
|
value = withContext(Dispatchers.IO) { env.saves.load(route.gameId) }
|
||||||
|
}
|
||||||
|
GameSetupScreen(
|
||||||
descriptor = GameCatalog.byId(route.gameId)!!,
|
descriptor = GameCatalog.byId(route.gameId)!!,
|
||||||
playerName = settings.displayName,
|
playerName = settings.displayName,
|
||||||
onBack = { pop() },
|
onBack = { pop() },
|
||||||
@@ -202,7 +222,16 @@ private fun AppContent(
|
|||||||
onHostOnline = { optionsJson ->
|
onHostOnline = { optionsJson ->
|
||||||
push(Route.Lobby(route.gameId, optionsJson, hosting = true))
|
push(Route.Lobby(route.gameId, optionsJson, hosting = true))
|
||||||
},
|
},
|
||||||
|
saved = saved,
|
||||||
|
onResume = { match ->
|
||||||
|
push(Route.Play(match.gameId, match.config, true, match.localSeat, match.stateJson))
|
||||||
|
},
|
||||||
|
onDiscardSaved = {
|
||||||
|
env.saves.clear(route.gameId)
|
||||||
|
discards++
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
is Route.Lobby -> LobbyScreen(
|
is Route.Lobby -> LobbyScreen(
|
||||||
env = env,
|
env = env,
|
||||||
@@ -231,6 +260,8 @@ private fun AppContent(
|
|||||||
@Composable
|
@Composable
|
||||||
fun MainMenuScreen(
|
fun MainMenuScreen(
|
||||||
showComingSoon: Boolean,
|
showComingSoon: Boolean,
|
||||||
|
saved: List<SavedMatch>,
|
||||||
|
onResume: (SavedMatch) -> Unit,
|
||||||
onPickSection: (GameMenu) -> Unit,
|
onPickSection: (GameMenu) -> Unit,
|
||||||
onJoinGame: () -> Unit,
|
onJoinGame: () -> Unit,
|
||||||
onScorekeeper: () -> Unit,
|
onScorekeeper: () -> Unit,
|
||||||
@@ -280,6 +311,23 @@ fun MainMenuScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Games left half-played, most recent first, above everything
|
||||||
|
// else: coming back to the app is mostly coming back to one.
|
||||||
|
if (saved.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
"Continue",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
saved.forEach { match ->
|
||||||
|
val title = GameCatalog.byId(match.gameId)?.title ?: match.gameId
|
||||||
|
Button(onClick = { onResume(match) }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(savedLine(title, match, now))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Below the games, and full width like them, so the column
|
// Below the games, and full width like them, so the column
|
||||||
// stays one column all the way down.
|
// stays one column all the way down.
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
@@ -364,7 +412,11 @@ fun SectionScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
items(games) { game ->
|
items(games) { game ->
|
||||||
GameTile(game = game, onClick = { onPickGame(game) })
|
GameTile(
|
||||||
|
game = game,
|
||||||
|
playable = game.playableIn(devBuild = showComingSoon),
|
||||||
|
onClick = { onPickGame(game) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -403,12 +455,12 @@ private fun SectionTile(menu: GameMenu, count: Int, onClick: () -> Unit) {
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun GameTile(game: GameDescriptor, onClick: () -> Unit) {
|
private fun GameTile(game: GameDescriptor, playable: Boolean, onClick: () -> Unit) {
|
||||||
Card(
|
Card(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
enabled = game.available,
|
enabled = playable,
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = if (game.available) {
|
containerColor = if (playable) {
|
||||||
MaterialTheme.colorScheme.surfaceVariant
|
MaterialTheme.colorScheme.surfaceVariant
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.surface
|
MaterialTheme.colorScheme.surface
|
||||||
@@ -427,13 +479,18 @@ private fun GameTile(game: GameDescriptor, onClick: () -> Unit) {
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
modifier = Modifier.padding(top = 4.dp),
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
)
|
)
|
||||||
Text(
|
val players = when {
|
||||||
text = when {
|
|
||||||
!game.available -> "Coming soon"
|
|
||||||
// A puzzle for one, rather than "1 players".
|
// A puzzle for one, rather than "1 players".
|
||||||
game.maxPlayers == 1 -> "On your own"
|
game.maxPlayers == 1 -> "On your own"
|
||||||
game.minPlayers == game.maxPlayers -> "${game.minPlayers} players"
|
game.minPlayers == game.maxPlayers -> "${game.minPlayers} players"
|
||||||
else -> "${game.minPlayers}–${game.maxPlayers} players"
|
else -> "${game.minPlayers}–${game.maxPlayers} players"
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = when {
|
||||||
|
!playable -> "Coming soon"
|
||||||
|
// Playable on this dev build only; production hides it.
|
||||||
|
!game.available -> "Preview · $players"
|
||||||
|
else -> players
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
modifier = Modifier.padding(top = 8.dp),
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
@@ -481,3 +538,17 @@ fun PrimaryAction(text: String, enabled: Boolean = true, onClick: () -> Unit) {
|
|||||||
Text(text)
|
Text(text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The saved games, read off the main thread each time the menu is the screen
|
||||||
|
* showing — which is when a game has just been left and its last position
|
||||||
|
* written, so the list is not read until that has happened.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun savedGames(env: AppEnv, stack: List<Route>): List<SavedMatch> {
|
||||||
|
val onMenu = stack.last() is Route.Menu
|
||||||
|
val saved by produceState(emptyList<SavedMatch>(), onMenu, stack.size) {
|
||||||
|
value = if (onMenu) withContext(Dispatchers.IO) { env.saves.all() } else emptyList()
|
||||||
|
}
|
||||||
|
return saved
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package org.prolibertate.games.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a screen's "End game" button tells the table to forget the saved game.
|
||||||
|
*
|
||||||
|
* Leaving a table keeps its position so it can be resumed; ending one on purpose
|
||||||
|
* does not. The two arrive at the screen as the same "go back", so the
|
||||||
|
* difference is carried here instead: [PlayScreen] provides one handle, the match
|
||||||
|
* it builds says what ending means for it by setting [onEnd], and each End-game
|
||||||
|
* dialog calls [end] before it leaves.
|
||||||
|
*/
|
||||||
|
class EndGameHandle {
|
||||||
|
/** Set by the table once it knows how to forget its own saved position. */
|
||||||
|
var onEnd: () -> Unit = {}
|
||||||
|
|
||||||
|
fun end() = onEnd()
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalEndGameHandle = staticCompositionLocalOf { EndGameHandle() }
|
||||||
@@ -9,11 +9,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.Divider
|
import androidx.compose.material3.Divider
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -29,6 +32,7 @@ import org.prolibertate.games.game.GameCatalog
|
|||||||
import org.prolibertate.games.game.GameDescriptor
|
import org.prolibertate.games.game.GameDescriptor
|
||||||
import org.prolibertate.games.game.engine.PlayerKind
|
import org.prolibertate.games.game.engine.PlayerKind
|
||||||
import org.prolibertate.games.game.engine.PlayerSlot
|
import org.prolibertate.games.game.engine.PlayerSlot
|
||||||
|
import org.prolibertate.games.game.engine.SavedMatch
|
||||||
import org.prolibertate.games.game.engine.TableConfig
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
import org.prolibertate.games.game.backgammon.BackgammonLevel
|
import org.prolibertate.games.game.backgammon.BackgammonLevel
|
||||||
import org.prolibertate.games.game.backgammon.BackgammonOptions
|
import org.prolibertate.games.game.backgammon.BackgammonOptions
|
||||||
@@ -57,6 +61,8 @@ import org.prolibertate.games.game.morris.MorrisOptions
|
|||||||
import org.prolibertate.games.game.pirates.PiratesLevel
|
import org.prolibertate.games.game.pirates.PiratesLevel
|
||||||
import org.prolibertate.games.game.pirates.PiratesOptions
|
import org.prolibertate.games.game.pirates.PiratesOptions
|
||||||
import org.prolibertate.games.game.president.PresidentOptions
|
import org.prolibertate.games.game.president.PresidentOptions
|
||||||
|
import org.prolibertate.games.game.catan.CatanOptions
|
||||||
|
import org.prolibertate.games.game.catan.CatanRuleSet
|
||||||
import org.prolibertate.games.game.sequence.SequenceOptions
|
import org.prolibertate.games.game.sequence.SequenceOptions
|
||||||
import org.prolibertate.games.game.tayu.TayuLevel
|
import org.prolibertate.games.game.tayu.TayuLevel
|
||||||
import org.prolibertate.games.game.tayu.TayuOptions
|
import org.prolibertate.games.game.tayu.TayuOptions
|
||||||
@@ -81,6 +87,10 @@ fun GameSetupScreen(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onPlayOffline: (TableConfig, Int) -> Unit,
|
onPlayOffline: (TableConfig, Int) -> Unit,
|
||||||
onHostOnline: (String) -> Unit,
|
onHostOnline: (String) -> Unit,
|
||||||
|
/** A game of this kind left half-played, if there is one. */
|
||||||
|
saved: SavedMatch? = null,
|
||||||
|
onResume: (SavedMatch) -> Unit = {},
|
||||||
|
onDiscardSaved: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
var euchre by remember { mutableStateOf(EuchreOptions()) }
|
var euchre by remember { mutableStateOf(EuchreOptions()) }
|
||||||
var sequence by remember { mutableStateOf(SequenceOptions()) }
|
var sequence by remember { mutableStateOf(SequenceOptions()) }
|
||||||
@@ -104,6 +114,7 @@ fun GameSetupScreen(
|
|||||||
var freecell by remember { mutableStateOf(FreeCellOptions()) }
|
var freecell by remember { mutableStateOf(FreeCellOptions()) }
|
||||||
var spider by remember { mutableStateOf(SpiderOptions()) }
|
var spider by remember { mutableStateOf(SpiderOptions()) }
|
||||||
var pyramid by remember { mutableStateOf(PyramidOptions()) }
|
var pyramid by remember { mutableStateOf(PyramidOptions()) }
|
||||||
|
var catan by remember { mutableStateOf(CatanOptions()) }
|
||||||
// Not an engine option: which seat the local player takes. Seat 0 is
|
// Not an engine option: which seat the local player takes. Seat 0 is
|
||||||
// always White, so choosing Black means sitting in seat 1.
|
// always White, so choosing Black means sitting in seat 1.
|
||||||
var playWhite by remember { mutableStateOf(true) }
|
var playWhite by remember { mutableStateOf(true) }
|
||||||
@@ -134,6 +145,7 @@ fun GameSetupScreen(
|
|||||||
GameCatalog.FREECELL -> setupJson.encodeToString(freecell)
|
GameCatalog.FREECELL -> setupJson.encodeToString(freecell)
|
||||||
GameCatalog.SPIDER -> setupJson.encodeToString(spider)
|
GameCatalog.SPIDER -> setupJson.encodeToString(spider)
|
||||||
GameCatalog.PYRAMID -> setupJson.encodeToString(pyramid)
|
GameCatalog.PYRAMID -> setupJson.encodeToString(pyramid)
|
||||||
|
GameCatalog.CATAN -> setupJson.encodeToString(catan)
|
||||||
else -> "{}"
|
else -> "{}"
|
||||||
}
|
}
|
||||||
val seatCount = seatCountFor(descriptor.id, optionsJson)
|
val seatCount = seatCountFor(descriptor.id, optionsJson)
|
||||||
@@ -145,6 +157,10 @@ fun GameSetupScreen(
|
|||||||
) {
|
) {
|
||||||
Text(descriptor.blurb, style = MaterialTheme.typography.bodyMedium)
|
Text(descriptor.blurb, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
|
||||||
|
if (saved != null) {
|
||||||
|
SavedGameBanner(saved, onResume = { onResume(saved) }, onDiscard = onDiscardSaved)
|
||||||
|
}
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
Text("House rules", fontWeight = FontWeight.Bold)
|
Text("House rules", fontWeight = FontWeight.Bold)
|
||||||
|
|
||||||
@@ -199,6 +215,8 @@ fun GameSetupScreen(
|
|||||||
|
|
||||||
GameCatalog.PYRAMID -> PyramidOptionsEditor(pyramid) { pyramid = it }
|
GameCatalog.PYRAMID -> PyramidOptionsEditor(pyramid) { pyramid = it }
|
||||||
|
|
||||||
|
GameCatalog.CATAN -> CatanOptionsEditor(catan) { catan = it }
|
||||||
|
|
||||||
else -> Text("No options yet for this game.")
|
else -> Text("No options yet for this game.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +245,12 @@ fun GameSetupScreen(
|
|||||||
descriptor.id == GameCatalog.PIRATES && playPirates -> 1
|
descriptor.id == GameCatalog.PIRATES && playPirates -> 1
|
||||||
else -> 0
|
else -> 0
|
||||||
}
|
}
|
||||||
|
if (saved != null) {
|
||||||
|
Text(
|
||||||
|
"Starting a new game replaces the one in progress.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
PrimaryAction(text = if (solo) "Play" else "Play against the computer") {
|
PrimaryAction(text = if (solo) "Play" else "Play against the computer") {
|
||||||
onPlayOffline(
|
onPlayOffline(
|
||||||
offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat),
|
offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat),
|
||||||
@@ -303,6 +327,9 @@ fun seatCountFor(gameId: String, optionsJson: String): Int {
|
|||||||
GameCatalog.TAYU ->
|
GameCatalog.TAYU ->
|
||||||
setupJson.decodeFromString<TayuOptions>(optionsJson).playerCount
|
setupJson.decodeFromString<TayuOptions>(optionsJson).playerCount
|
||||||
|
|
||||||
|
GameCatalog.CATAN ->
|
||||||
|
setupJson.decodeFromString<CatanOptions>(optionsJson).playerCount
|
||||||
|
|
||||||
else -> fallback
|
else -> fallback
|
||||||
}
|
}
|
||||||
}.getOrDefault(fallback)
|
}.getOrDefault(fallback)
|
||||||
@@ -326,6 +353,72 @@ fun teamForSeat(gameId: String, seat: Int, seatCount: Int): Int = when (gameId)
|
|||||||
else -> seat
|
else -> seat
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The targets the setup screen offers for a Katan rule set. */
|
||||||
|
internal fun catanPointsChoices(ruleSet: CatanRuleSet): List<Int> = when (ruleSet) {
|
||||||
|
// Five is a short game for trying things out; ten is the published target.
|
||||||
|
CatanRuleSet.BASE -> listOf(5, 8, 10, 12)
|
||||||
|
// Cities & Knights is played to thirteen, which is also why it runs longer.
|
||||||
|
CatanRuleSet.CITIES_AND_KNIGHTS -> listOf(8, 10, 13, 15)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The published target for a rule set. */
|
||||||
|
internal fun catanDefaultPoints(ruleSet: CatanRuleSet): Int = when (ruleSet) {
|
||||||
|
CatanRuleSet.BASE -> 10
|
||||||
|
CatanRuleSet.CITIES_AND_KNIGHTS -> 13
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [options] switched to [ruleSet]. The target moves to the new rule set's own
|
||||||
|
* default unless the one already chosen is on its menu too.
|
||||||
|
*/
|
||||||
|
internal fun catanSwitchRules(options: CatanOptions, ruleSet: CatanRuleSet): CatanOptions =
|
||||||
|
options.copy(
|
||||||
|
ruleSet = ruleSet,
|
||||||
|
pointsToWin = options.pointsToWin.takeIf { it in catanPointsChoices(ruleSet) && it != catanDefaultPoints(options.ruleSet) }
|
||||||
|
?: catanDefaultPoints(ruleSet),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun catanRuleSetName(ruleSet: CatanRuleSet): String = when (ruleSet) {
|
||||||
|
CatanRuleSet.BASE -> "Base game"
|
||||||
|
CatanRuleSet.CITIES_AND_KNIGHTS -> "Cities & Knights"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun catanRuleSetBlurb(ruleSet: CatanRuleSet): String = when (ruleSet) {
|
||||||
|
CatanRuleSet.BASE ->
|
||||||
|
"Settle the island, trade, and play development cards. Longest Road and Largest Army score."
|
||||||
|
CatanRuleSet.CITIES_AND_KNIGHTS ->
|
||||||
|
"Cities make commodities, knights hold off the barbarians, and improvements earn progress " +
|
||||||
|
"cards and metropolises. No development cards; the robber sleeps until the first attack."
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) -> Unit) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
ChipRow(
|
||||||
|
label = "Rules",
|
||||||
|
values = CatanRuleSet.entries,
|
||||||
|
selected = options.ruleSet,
|
||||||
|
display = ::catanRuleSetName,
|
||||||
|
onSelect = { onChange(catanSwitchRules(options, it)) },
|
||||||
|
)
|
||||||
|
Text(catanRuleSetBlurb(options.ruleSet), style = MaterialTheme.typography.bodySmall)
|
||||||
|
ChipRow(
|
||||||
|
label = "Players",
|
||||||
|
values = listOf(3, 4),
|
||||||
|
selected = options.playerCount,
|
||||||
|
display = { "$it players" },
|
||||||
|
onSelect = { onChange(options.copy(playerCount = it)) },
|
||||||
|
)
|
||||||
|
ChipRow(
|
||||||
|
label = "Game to",
|
||||||
|
values = catanPointsChoices(options.ruleSet),
|
||||||
|
selected = options.pointsToWin,
|
||||||
|
display = { "$it points" },
|
||||||
|
onSelect = { onChange(options.copy(pointsToWin = it)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EuchreOptionsEditor(options: EuchreOptions, onChange: (EuchreOptions) -> Unit) {
|
private fun EuchreOptionsEditor(options: EuchreOptions, onChange: (EuchreOptions) -> Unit) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
@@ -1141,3 +1234,30 @@ private fun PyramidOptionsEditor(options: PyramidOptions, onChange: (PyramidOpti
|
|||||||
onSelect = { onChange(options.copy(redeals = it)) },
|
onSelect = { onChange(options.copy(redeals = it)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The game that was left unfinished, with the two things to do about it. Shown
|
||||||
|
* above the options so it is the first thing seen on coming back to a game.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SavedGameBanner(saved: SavedMatch, onResume: () -> Unit, onDiscard: () -> Unit) {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
Text("Game in progress", fontWeight = FontWeight.Bold)
|
||||||
|
Text(
|
||||||
|
buildString {
|
||||||
|
append("Saved ${agoText(System.currentTimeMillis(), saved.savedAtMillis)}")
|
||||||
|
if (saved.summary.isNotBlank()) append(" · ${saved.summary}")
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(onClick = onResume) { Text("Resume") }
|
||||||
|
TextButton(onClick = onDiscard) { Text("Discard") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ package org.prolibertate.games.ui
|
|||||||
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.DisposableEffect
|
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.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
@@ -13,6 +15,10 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
|
|||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import org.prolibertate.games.game.GameCatalog
|
import org.prolibertate.games.game.GameCatalog
|
||||||
|
import org.prolibertate.games.game.catan.CatanAi
|
||||||
|
import org.prolibertate.games.game.catan.CatanMove
|
||||||
|
import org.prolibertate.games.game.catan.CatanRules
|
||||||
|
import org.prolibertate.games.game.catan.CatanState
|
||||||
import org.prolibertate.games.game.chess.ChessAi
|
import org.prolibertate.games.game.chess.ChessAi
|
||||||
import org.prolibertate.games.game.chess.ChessMove
|
import org.prolibertate.games.game.chess.ChessMove
|
||||||
import org.prolibertate.games.game.chess.ChessRules
|
import org.prolibertate.games.game.chess.ChessRules
|
||||||
@@ -108,8 +114,11 @@ import org.prolibertate.games.game.wizard.WizardPhase
|
|||||||
import org.prolibertate.games.game.wizard.WizardRules
|
import org.prolibertate.games.game.wizard.WizardRules
|
||||||
import org.prolibertate.games.game.wizard.WizardState
|
import org.prolibertate.games.game.wizard.WizardState
|
||||||
import org.prolibertate.games.net.MatchController
|
import org.prolibertate.games.net.MatchController
|
||||||
|
import org.prolibertate.games.net.MatchSaver
|
||||||
|
import org.prolibertate.games.game.engine.PlayerKind
|
||||||
import org.prolibertate.games.settings.Settings
|
import org.prolibertate.games.settings.Settings
|
||||||
import org.prolibertate.games.ui.game.BackgammonScreen
|
import org.prolibertate.games.ui.game.BackgammonScreen
|
||||||
|
import org.prolibertate.games.ui.game.catan.CatanScreen
|
||||||
import org.prolibertate.games.ui.game.CheckersScreen
|
import org.prolibertate.games.ui.game.CheckersScreen
|
||||||
import org.prolibertate.games.ui.game.ChessScreen
|
import org.prolibertate.games.ui.game.ChessScreen
|
||||||
import org.prolibertate.games.ui.game.CrazyEightsScreen
|
import org.prolibertate.games.ui.game.CrazyEightsScreen
|
||||||
@@ -150,6 +159,10 @@ fun PlayScreen(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val role = if (route.hosting) MatchController.Role.HOST else MatchController.Role.CLIENT
|
val role = if (route.hosting) MatchController.Role.HOST else MatchController.Role.CLIENT
|
||||||
|
|
||||||
|
// The same handle for every table this screen builds, so an End-game dialog
|
||||||
|
// anywhere below can reach the table that is showing.
|
||||||
|
val endHandle = remember(route) { EndGameHandle() }
|
||||||
|
CompositionLocalProvider(LocalEndGameHandle provides endHandle) {
|
||||||
when (route.gameId) {
|
when (route.gameId) {
|
||||||
GameCatalog.EUCHRE -> {
|
GameCatalog.EUCHRE -> {
|
||||||
val controller = remember(route) {
|
val controller = remember(route) {
|
||||||
@@ -668,9 +681,34 @@ fun PlayScreen(
|
|||||||
ChessScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
ChessScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GameCatalog.CATAN -> {
|
||||||
|
val controller = remember(route) {
|
||||||
|
MatchController<CatanState, CatanMove>(
|
||||||
|
rules = CatanRules,
|
||||||
|
ai = CatanAi(),
|
||||||
|
config = route.config,
|
||||||
|
scope = scope,
|
||||||
|
role = role,
|
||||||
|
localSeats = setOf(route.localSeat),
|
||||||
|
primarySeat = route.localSeat,
|
||||||
|
// A computer's turn is several small moves (roll, build,
|
||||||
|
// trade, end), so the pause is per move, not per turn.
|
||||||
|
aiThinkingMillis = { settings.scaled(450L) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
StartMatch(env, controller, route)
|
||||||
|
CatanScreen(
|
||||||
|
controller = controller,
|
||||||
|
localSeat = route.localSeat,
|
||||||
|
seatNames = route.config.seats.sortedBy { it.seat }.map { it.name },
|
||||||
|
onExit = onExit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
else -> Text("That game isn't playable yet.")
|
else -> Text("That game isn't playable yet.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun <S : Any, M : Any> StartMatch(
|
private fun <S : Any, M : Any> StartMatch(
|
||||||
@@ -678,8 +716,38 @@ private fun <S : Any, M : Any> StartMatch(
|
|||||||
controller: MatchController<S, M>,
|
controller: MatchController<S, M>,
|
||||||
route: Route.Play,
|
route: Route.Play,
|
||||||
) {
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val endHandle = LocalEndGameHandle.current
|
||||||
|
|
||||||
|
// A game against the computer (or on one device) is kept as it is played, so
|
||||||
|
// it can be picked up after the app has been closed. A table with another
|
||||||
|
// device at it is not: the other player's seat cannot be held for them.
|
||||||
|
val saveable = route.hosting && route.config.seats.none { it.kind == PlayerKind.HUMAN_REMOTE }
|
||||||
|
val saver = remember(controller) {
|
||||||
|
if (saveable) {
|
||||||
|
MatchSaver(env.saves, route.gameId, route.config, route.localSeat, scope)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The position to carry on from belongs to the first table only. Dealing
|
||||||
|
// again (solitaire's "new deal") builds a fresh controller under the same
|
||||||
|
// route, and that one must not be put back to where the last one stood.
|
||||||
|
val resume = remember(route) { mutableStateOf(route.resumeState) }
|
||||||
|
|
||||||
LaunchedEffect(controller) {
|
LaunchedEffect(controller) {
|
||||||
if (route.hosting) {
|
if (route.hosting) {
|
||||||
|
resume.value?.let { saved ->
|
||||||
|
resume.value = null
|
||||||
|
// A save this version cannot read — one from an older build — is
|
||||||
|
// not worth keeping; the table is dealt afresh instead.
|
||||||
|
if (!controller.restore(saved)) env.saves.clear(route.gameId)
|
||||||
|
}
|
||||||
|
if (saver != null) {
|
||||||
|
saver.start()
|
||||||
|
controller.onHostState = saver::offer
|
||||||
|
endHandle.onEnd = saver::discard
|
||||||
|
}
|
||||||
controller.startAsHost(env.lobby.hostConnections)
|
controller.startAsHost(env.lobby.hostConnections)
|
||||||
} else {
|
} else {
|
||||||
env.lobby.clientConnection?.let { controller.startAsClient(it) }
|
env.lobby.clientConnection?.let { controller.startAsClient(it) }
|
||||||
@@ -692,6 +760,10 @@ private fun <S : Any, M : Any> StartMatch(
|
|||||||
env.lobby.rebinder = controller
|
env.lobby.rebinder = controller
|
||||||
onDispose {
|
onDispose {
|
||||||
env.lobby.rebinder = null
|
env.lobby.rebinder = null
|
||||||
|
// Whatever the writer has not caught up with is written before the
|
||||||
|
// table goes, so leaving mid-move does not lose the last move.
|
||||||
|
saver?.flush()
|
||||||
|
saver?.stop()
|
||||||
controller.close()
|
controller.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -705,11 +777,14 @@ private fun <S : Any, M : Any> StartMatch(
|
|||||||
// sent. So the table is asked for again on the way back in, over a fresh
|
// sent. So the table is asked for again on the way back in, over a fresh
|
||||||
// link if the old one did not survive.
|
// link if the old one did not survive.
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
DisposableEffect(lifecycleOwner, route.hosting) {
|
DisposableEffect(lifecycleOwner, route.hosting, saver) {
|
||||||
val observer = LifecycleEventObserver { _, event ->
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
if (event == Lifecycle.Event.ON_START && !route.hosting) {
|
if (event == Lifecycle.Event.ON_START && !route.hosting) {
|
||||||
env.lobby.restoreLink()
|
env.lobby.restoreLink()
|
||||||
}
|
}
|
||||||
|
// The app going to the background may be the last the process hears:
|
||||||
|
// Android can end it without a word, so the position is written now.
|
||||||
|
if (event == Lifecycle.Event.ON_STOP) saver?.flush()
|
||||||
}
|
}
|
||||||
lifecycleOwner.lifecycle.addObserver(observer)
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package org.prolibertate.games.ui
|
||||||
|
|
||||||
|
import org.prolibertate.games.game.engine.SavedMatch
|
||||||
|
|
||||||
|
/** How long ago something happened, in the words a person would use. */
|
||||||
|
fun agoText(nowMillis: Long, thenMillis: Long): String {
|
||||||
|
val seconds = ((nowMillis - thenMillis) / 1000).coerceAtLeast(0)
|
||||||
|
val minutes = seconds / 60
|
||||||
|
val hours = minutes / 60
|
||||||
|
val days = hours / 24
|
||||||
|
return when {
|
||||||
|
seconds < 45 -> "just now"
|
||||||
|
minutes < 2 -> "a minute ago"
|
||||||
|
minutes < 60 -> "$minutes minutes ago"
|
||||||
|
hours < 2 -> "an hour ago"
|
||||||
|
hours < 24 -> "$hours hours ago"
|
||||||
|
days < 2 -> "yesterday"
|
||||||
|
else -> "$days days ago"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Chess · saved 5 minutes ago" for a menu line. */
|
||||||
|
fun savedLine(title: String, match: SavedMatch, nowMillis: Long): String =
|
||||||
|
"$title · saved ${agoText(nowMillis, match.savedAtMillis)}"
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.prolibertate.games.ui.game
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -378,7 +379,12 @@ private fun LeaveDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
|||||||
"at the table is told you have left."
|
"at the table is told you have left."
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
confirmButton = {
|
||||||
|
// Ending a game on purpose also forgets the saved position: only
|
||||||
|
// leaving the table keeps it to be picked up again.
|
||||||
|
val handle = LocalEndGameHandle.current
|
||||||
|
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||||
|
},
|
||||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.prolibertate.games.ui.game
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
@@ -79,7 +80,12 @@ fun PatienceEndDialog(what: String, onEnd: () -> Unit, onKeepPlaying: () -> Unit
|
|||||||
onDismissRequest = onKeepPlaying,
|
onDismissRequest = onKeepPlaying,
|
||||||
title = { Text("End this game?") },
|
title = { Text("End this game?") },
|
||||||
text = { Text(what) },
|
text = { Text(what) },
|
||||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
confirmButton = {
|
||||||
|
// Ending a game on purpose also forgets the saved position: only
|
||||||
|
// leaving the table keeps it to be picked up again.
|
||||||
|
val handle = LocalEndGameHandle.current
|
||||||
|
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||||
|
},
|
||||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.prolibertate.games.ui.game
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -314,7 +315,12 @@ private fun EndGameDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
|||||||
"at the table is told you have left."
|
"at the table is told you have left."
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
confirmButton = {
|
||||||
|
// Ending a game on purpose also forgets the saved position: only
|
||||||
|
// leaving the table keeps it to be picked up again.
|
||||||
|
val handle = LocalEndGameHandle.current
|
||||||
|
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||||
|
},
|
||||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.prolibertate.games.ui.game
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -215,7 +216,12 @@ private fun ConfirmEndDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
|||||||
"at the table is told you have left."
|
"at the table is told you have left."
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
confirmButton = {
|
||||||
|
// Ending a game on purpose also forgets the saved position: only
|
||||||
|
// leaving the table keeps it to be picked up again.
|
||||||
|
val handle = LocalEndGameHandle.current
|
||||||
|
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||||
|
},
|
||||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.prolibertate.games.ui.game
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||||
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.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -268,7 +269,12 @@ private fun EndHeartsDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
|||||||
onDismissRequest = onKeepPlaying,
|
onDismissRequest = onKeepPlaying,
|
||||||
title = { Text("End this game?") },
|
title = { Text("End this game?") },
|
||||||
text = { Text("The game ends here. Anyone else at the table is told you have left.") },
|
text = { Text("The game ends here. Anyone else at the table is told you have left.") },
|
||||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
confirmButton = {
|
||||||
|
// Ending a game on purpose also forgets the saved position: only
|
||||||
|
// leaving the table keeps it to be picked up again.
|
||||||
|
val handle = LocalEndGameHandle.current
|
||||||
|
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||||
|
},
|
||||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.prolibertate.games.ui.game
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
@@ -342,7 +343,12 @@ private fun EndTableDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
|||||||
onDismissRequest = onKeepPlaying,
|
onDismissRequest = onKeepPlaying,
|
||||||
title = { Text("End this game?") },
|
title = { Text("End this game?") },
|
||||||
text = { Text("The deal is left as it stands.") },
|
text = { Text("The deal is left as it stands.") },
|
||||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
confirmButton = {
|
||||||
|
// Ending a game on purpose also forgets the saved position: only
|
||||||
|
// leaving the table keeps it to be picked up again.
|
||||||
|
val handle = LocalEndGameHandle.current
|
||||||
|
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||||
|
},
|
||||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.prolibertate.games.ui.game
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -356,7 +357,12 @@ private fun EndDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
|||||||
onDismissRequest = onKeepPlaying,
|
onDismissRequest = onKeepPlaying,
|
||||||
title = { Text("End this game?") },
|
title = { Text("End this game?") },
|
||||||
text = { Text("The game ends here. Anyone else at the table is told you have left.") },
|
text = { Text("The game ends here. Anyone else at the table is told you have left.") },
|
||||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
confirmButton = {
|
||||||
|
// Ending a game on purpose also forgets the saved position: only
|
||||||
|
// leaving the table keeps it to be picked up again.
|
||||||
|
val handle = LocalEndGameHandle.current
|
||||||
|
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||||
|
},
|
||||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.prolibertate.games.ui.game
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
@@ -483,7 +484,12 @@ private fun LeaveBoardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
|||||||
"at the table is told you have left."
|
"at the table is told you have left."
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
confirmButton = {
|
||||||
|
// Ending a game on purpose also forgets the saved position: only
|
||||||
|
// leaving the table keeps it to be picked up again.
|
||||||
|
val handle = LocalEndGameHandle.current
|
||||||
|
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||||
|
},
|
||||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.prolibertate.games.ui.game
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
@@ -242,7 +243,12 @@ private fun EndBoardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
|||||||
onDismissRequest = onKeepPlaying,
|
onDismissRequest = onKeepPlaying,
|
||||||
title = { Text("End this game?") },
|
title = { Text("End this game?") },
|
||||||
text = { Text("The board is left as it stands.") },
|
text = { Text("The board is left as it stands.") },
|
||||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
confirmButton = {
|
||||||
|
// Ending a game on purpose also forgets the saved position: only
|
||||||
|
// leaving the table keeps it to be picked up again.
|
||||||
|
val handle = LocalEndGameHandle.current
|
||||||
|
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||||
|
},
|
||||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.prolibertate.games.ui.game
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
@@ -371,7 +372,12 @@ private fun LeaveFieldDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
|||||||
"at the table is told you have left."
|
"at the table is told you have left."
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
confirmButton = {
|
||||||
|
// Ending a game on purpose also forgets the saved position: only
|
||||||
|
// leaving the table keeps it to be picked up again.
|
||||||
|
val handle = LocalEndGameHandle.current
|
||||||
|
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||||
|
},
|
||||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.prolibertate.games.ui.game
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -668,7 +669,12 @@ private fun TayuConfirmEndDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
|||||||
"table is told you have left."
|
"table is told you have left."
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
confirmButton = {
|
||||||
|
// Ending a game on purpose also forgets the saved position: only
|
||||||
|
// leaving the table keeps it to be picked up again.
|
||||||
|
val handle = LocalEndGameHandle.current
|
||||||
|
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||||
|
},
|
||||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.prolibertate.games.ui.game
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -295,7 +296,12 @@ private fun EndCardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
|||||||
onDismissRequest = onKeepPlaying,
|
onDismissRequest = onKeepPlaying,
|
||||||
title = { Text("End this game?") },
|
title = { Text("End this game?") },
|
||||||
text = { Text("The game ends here. Anyone else at the table is told you have left.") },
|
text = { Text("The game ends here. Anyone else at the table is told you have left.") },
|
||||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
confirmButton = {
|
||||||
|
// Ending a game on purpose also forgets the saved position: only
|
||||||
|
// leaving the table keeps it to be picked up again.
|
||||||
|
val handle = LocalEndGameHandle.current
|
||||||
|
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||||
|
},
|
||||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,644 @@
|
|||||||
|
package org.prolibertate.games.ui.game.catan
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.PathEffect
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.TextMeasurer
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.drawText
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import org.prolibertate.games.game.catan.BuildingType
|
||||||
|
import org.prolibertate.games.game.catan.CatanBoard
|
||||||
|
import org.prolibertate.games.game.catan.CatanState
|
||||||
|
import org.prolibertate.games.game.catan.NO_PLAYER
|
||||||
|
import org.prolibertate.games.game.catan.Resource
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The island, drawn and tappable.
|
||||||
|
*
|
||||||
|
* A stateless view: it is handed the state, the parts of the board that are
|
||||||
|
* currently tappable ([targets]) and the one setup settlement that is waiting
|
||||||
|
* for its road ([selectedVertex]), and it reports taps. It knows no rules; what
|
||||||
|
* a tap *means* is decided by [CatanInteraction]. Where things are is decided by
|
||||||
|
* [BoardLayout], and what they look like by [CatanStyle], so this file is only
|
||||||
|
* the glue between the three.
|
||||||
|
*
|
||||||
|
* To change how a piece is drawn, edit the matching `draw…` function below. To
|
||||||
|
* change a colour or a proportion, edit [CatanStyle] instead.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CatanBoardView(
|
||||||
|
state: CatanState,
|
||||||
|
targets: BoardTargets,
|
||||||
|
selectedVertex: Int?,
|
||||||
|
onVertexTap: (Int) -> Unit,
|
||||||
|
onEdgeTap: (Int) -> Unit,
|
||||||
|
onHexTap: (Int) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
style: CatanStyle = LocalCatanStyle.current,
|
||||||
|
/** Hexes to light up as having produced on the latest roll. */
|
||||||
|
rolledHexes: Set<Int> = emptySet(),
|
||||||
|
/** Cities & Knights: the knight whose menu is open or that is about to move. */
|
||||||
|
selectedKnight: Int? = null,
|
||||||
|
) {
|
||||||
|
val measurer = rememberTextMeasurer()
|
||||||
|
var size by remember { mutableStateOf(IntSize.Zero) }
|
||||||
|
val layout = remember(size) {
|
||||||
|
if (size.width > 0 && size.height > 0) {
|
||||||
|
CatanGeometry.fit(size.width.toFloat(), size.height.toFloat())
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Held in State so the gesture detector, which is only restarted when the
|
||||||
|
// layout or targets change, always calls the latest callbacks.
|
||||||
|
val vertexTap by rememberUpdatedState(onVertexTap)
|
||||||
|
val edgeTap by rememberUpdatedState(onEdgeTap)
|
||||||
|
val hexTap by rememberUpdatedState(onHexTap)
|
||||||
|
|
||||||
|
Canvas(
|
||||||
|
modifier = modifier
|
||||||
|
.aspectRatio(CatanGeometry.ASPECT)
|
||||||
|
.onSizeChanged { size = it }
|
||||||
|
.semantics { contentDescription = "Katan island board" }
|
||||||
|
.pointerInput(layout, targets, style) {
|
||||||
|
detectTapGestures { tap ->
|
||||||
|
val board = layout ?: return@detectTapGestures
|
||||||
|
val at = Pt(tap.x, tap.y)
|
||||||
|
val reach = board.radius * style.tapReach
|
||||||
|
when {
|
||||||
|
targets.tappableVertices.isNotEmpty() ->
|
||||||
|
board.nearestVertex(at, reach, targets.tappableVertices)?.let { vertexTap(it) }
|
||||||
|
|
||||||
|
targets.edges.isNotEmpty() ->
|
||||||
|
board.nearestEdge(at, reach, targets.edges)?.let { edgeTap(it) }
|
||||||
|
|
||||||
|
targets.hexes.isNotEmpty() ->
|
||||||
|
// A hex is big: accept a tap anywhere near its middle.
|
||||||
|
board.nearestHex(at, board.radius * 0.95f, targets.hexes)?.let { hexTap(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
val board = layout ?: return@Canvas
|
||||||
|
drawRect(color = style.sea)
|
||||||
|
drawPorts(state, board, style, measurer)
|
||||||
|
drawHexes(state, board, style, measurer)
|
||||||
|
drawRolled(rolledHexes, board, style)
|
||||||
|
drawRobber(state, board, style)
|
||||||
|
drawMerchant(state, board, style, measurer)
|
||||||
|
drawRoads(state, board, style)
|
||||||
|
drawEdgeTargets(targets, board, style)
|
||||||
|
drawBuildings(state, board, style)
|
||||||
|
drawWalls(state, board, style)
|
||||||
|
drawMetropolises(state, board, style)
|
||||||
|
drawKnights(state, board, style)
|
||||||
|
drawKnightTargets(targets, board, style)
|
||||||
|
drawVertexTargets(targets, selectedVertex ?: selectedKnight, board, style)
|
||||||
|
drawHexTargets(targets, board, style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Terrain and tokens
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun DrawScope.drawHexes(
|
||||||
|
state: CatanState,
|
||||||
|
board: BoardLayout,
|
||||||
|
style: CatanStyle,
|
||||||
|
measurer: TextMeasurer,
|
||||||
|
) {
|
||||||
|
for (hex in 0 until CatanBoard.HEX_COUNT) {
|
||||||
|
val centre = board.hexCenter(hex)
|
||||||
|
val corners = board.hexCorners(hex).map { insetToward(it, centre, style.hexInset) }
|
||||||
|
val path = polygon(corners)
|
||||||
|
drawPath(path, color = style.terrain.getValue(state.terrains[hex]))
|
||||||
|
drawPath(path, color = style.hexEdge, style = Stroke(width = board.radius * 0.03f))
|
||||||
|
|
||||||
|
val number = state.numbers[hex]
|
||||||
|
if (number != 0) drawToken(number, centre, board, style, measurer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bright wash and rim on each hex that paid out, drawn over the terrain and under the pieces. */
|
||||||
|
private fun DrawScope.drawRolled(hexes: Set<Int>, board: BoardLayout, style: CatanStyle) {
|
||||||
|
for (hex in hexes) {
|
||||||
|
val centre = board.hexCenter(hex)
|
||||||
|
val path = polygon(board.hexCorners(hex).map { insetToward(it, centre, style.hexInset + 0.04f) })
|
||||||
|
drawPath(path, color = style.rolled.copy(alpha = 0.28f))
|
||||||
|
drawPath(path, color = style.pieceOutline, style = Stroke(width = board.radius * 0.13f))
|
||||||
|
drawPath(path, color = style.rolled, style = Stroke(width = board.radius * 0.075f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.drawToken(
|
||||||
|
number: Int,
|
||||||
|
at: Pt,
|
||||||
|
board: BoardLayout,
|
||||||
|
style: CatanStyle,
|
||||||
|
measurer: TextMeasurer,
|
||||||
|
) {
|
||||||
|
val radius = board.radius * style.tokenRadius
|
||||||
|
drawCircle(color = style.token, radius = radius, center = at.offset())
|
||||||
|
drawCircle(
|
||||||
|
color = style.hexEdge,
|
||||||
|
radius = radius,
|
||||||
|
center = at.offset(),
|
||||||
|
style = Stroke(width = board.radius * 0.02f),
|
||||||
|
)
|
||||||
|
val hot = number == 6 || number == 8
|
||||||
|
drawCentered(
|
||||||
|
measurer = measurer,
|
||||||
|
text = number.toString(),
|
||||||
|
at = Pt(at.x, at.y - radius * 0.12f),
|
||||||
|
fontPx = board.radius * style.tokenText,
|
||||||
|
color = if (hot) style.tokenHotInk else style.tokenInk,
|
||||||
|
weight = if (hot) FontWeight.ExtraBold else FontWeight.Bold,
|
||||||
|
)
|
||||||
|
// One pip per thirty-sixth of the odds, so the likelier numbers read at a glance.
|
||||||
|
val pips = CatanBoard.numberOdds(number)
|
||||||
|
val pipRadius = radius * 0.075f
|
||||||
|
val gap = pipRadius * 2.6f
|
||||||
|
val startX = at.x - gap * (pips - 1) / 2f
|
||||||
|
for (i in 0 until pips) {
|
||||||
|
drawCircle(
|
||||||
|
color = if (hot) style.tokenHotInk else style.tokenInk,
|
||||||
|
radius = pipRadius,
|
||||||
|
center = Offset(startX + i * gap, at.y + radius * 0.52f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.drawRobber(state: CatanState, board: BoardLayout, style: CatanStyle) {
|
||||||
|
// In Cities & Knights the robber sleeps, drawn faintly, until the barbarians first land.
|
||||||
|
val asleep = state.ck?.attacked == false
|
||||||
|
val tint = if (asleep) style.robber.copy(alpha = 0.3f) else style.robber
|
||||||
|
val rim = Color.White.copy(alpha = if (asleep) 0.25f else 0.7f)
|
||||||
|
val hex = board.hexCenter(state.robber)
|
||||||
|
// Beside the number token rather than on it, so the number stays readable.
|
||||||
|
val x = hex.x + board.radius * 0.5f
|
||||||
|
val y = hex.y + board.radius * 0.3f
|
||||||
|
val s = board.radius * style.robberSize
|
||||||
|
val pawn = Path().apply {
|
||||||
|
moveTo(x - s * 0.55f, y + s * 0.9f)
|
||||||
|
lineTo(x - s * 0.25f, y - s * 0.15f)
|
||||||
|
lineTo(x + s * 0.25f, y - s * 0.15f)
|
||||||
|
lineTo(x + s * 0.55f, y + s * 0.9f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
drawPath(pawn, color = tint)
|
||||||
|
drawCircle(color = tint, radius = s * 0.38f, center = Offset(x, y - s * 0.42f))
|
||||||
|
drawPath(pawn, color = rim, style = Stroke(width = s * 0.07f))
|
||||||
|
drawCircle(
|
||||||
|
color = rim,
|
||||||
|
radius = s * 0.38f,
|
||||||
|
center = Offset(x, y - s * 0.42f),
|
||||||
|
style = Stroke(width = s * 0.07f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Harbours
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun DrawScope.drawPorts(
|
||||||
|
state: CatanState,
|
||||||
|
board: BoardLayout,
|
||||||
|
style: CatanStyle,
|
||||||
|
measurer: TextMeasurer,
|
||||||
|
) {
|
||||||
|
for (port in state.ports) {
|
||||||
|
val anchor = board.portAnchor(port.edge)
|
||||||
|
val (a, b) = board.edgeEnds(port.edge)
|
||||||
|
val tint = port.resource?.let { style.resource.getValue(it) } ?: style.portInk
|
||||||
|
// Two piers from the marker to the vertices that can use the harbour.
|
||||||
|
for (end in listOf(a, b)) {
|
||||||
|
drawLine(
|
||||||
|
color = style.portMarker.copy(alpha = 0.9f),
|
||||||
|
start = anchor.offset(),
|
||||||
|
end = end.offset(),
|
||||||
|
strokeWidth = board.radius * 0.07f,
|
||||||
|
cap = StrokeCap.Round,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val radius = board.radius * style.portRadius
|
||||||
|
drawCircle(color = style.portMarker, radius = radius, center = anchor.offset())
|
||||||
|
drawCircle(
|
||||||
|
color = tint,
|
||||||
|
radius = radius,
|
||||||
|
center = anchor.offset(),
|
||||||
|
style = Stroke(width = board.radius * 0.07f),
|
||||||
|
)
|
||||||
|
// What the harbour trades: a picture of the resource, or "?" for any.
|
||||||
|
val iconAt = Pt(anchor.x, anchor.y - radius * 0.27f)
|
||||||
|
val iconSize = radius * 0.5f
|
||||||
|
val resource = port.resource
|
||||||
|
if (resource != null) {
|
||||||
|
drawResourceIcon(resource, iconAt, iconSize, tint, style.pieceOutline)
|
||||||
|
} else {
|
||||||
|
drawCentered(
|
||||||
|
measurer = measurer,
|
||||||
|
text = "?",
|
||||||
|
at = iconAt,
|
||||||
|
fontPx = board.radius * style.portText * 1.5f,
|
||||||
|
color = style.portInk,
|
||||||
|
weight = FontWeight.ExtraBold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
drawCentered(
|
||||||
|
measurer = measurer,
|
||||||
|
text = "${port.ratio}:1",
|
||||||
|
at = Pt(anchor.x, anchor.y + radius * 0.5f),
|
||||||
|
fontPx = board.radius * style.portText,
|
||||||
|
color = style.portInk,
|
||||||
|
weight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small pictogram of [resource] centred on [at], about 2·[s] across: a tree,
|
||||||
|
* bricks, a sheep, an ear of wheat or a rock. Drawn with paths so there are no
|
||||||
|
* image assets to ship or scale.
|
||||||
|
*/
|
||||||
|
private fun DrawScope.drawResourceIcon(resource: Resource, at: Pt, s: Float, fill: Color, line: Color) {
|
||||||
|
val outline = Stroke(width = s * 0.12f)
|
||||||
|
when (resource) {
|
||||||
|
Resource.LUMBER -> {
|
||||||
|
val crown = Path().apply {
|
||||||
|
moveTo(at.x, at.y - s)
|
||||||
|
lineTo(at.x + s * 0.8f, at.y + s * 0.3f)
|
||||||
|
lineTo(at.x - s * 0.8f, at.y + s * 0.3f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
drawRect(color = line, topLeft = Offset(at.x - s * 0.14f, at.y + s * 0.3f), size = Size(s * 0.28f, s * 0.6f))
|
||||||
|
drawPath(crown, color = fill)
|
||||||
|
drawPath(crown, color = line, style = outline)
|
||||||
|
}
|
||||||
|
|
||||||
|
Resource.BRICK -> {
|
||||||
|
val w = s * 0.9f
|
||||||
|
val h = s * 0.42f
|
||||||
|
for ((row, shift) in listOf(0f to 0f, 1f to w * 0.5f)) {
|
||||||
|
val top = at.y - h + row * h
|
||||||
|
val left = at.x - w + shift - w * 0.25f
|
||||||
|
for (col in 0..1) {
|
||||||
|
val x = left + col * w
|
||||||
|
drawRect(color = fill, topLeft = Offset(x, top), size = Size(w, h))
|
||||||
|
drawRect(color = line, topLeft = Offset(x, top), size = Size(w, h), style = outline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Resource.WOOL -> {
|
||||||
|
for ((dx, dy) in listOf(-0.45f to 0f, 0.1f to -0.25f, 0.5f to 0.05f, 0f to 0.2f)) {
|
||||||
|
drawCircle(color = fill, radius = s * 0.42f, center = Offset(at.x + dx * s, at.y + dy * s))
|
||||||
|
}
|
||||||
|
drawCircle(color = line, radius = s * 0.24f, center = Offset(at.x - s * 0.85f, at.y + s * 0.15f))
|
||||||
|
drawRect(color = line, topLeft = Offset(at.x - s * 0.5f, at.y + s * 0.5f), size = Size(s * 0.14f, s * 0.45f))
|
||||||
|
drawRect(color = line, topLeft = Offset(at.x + s * 0.3f, at.y + s * 0.5f), size = Size(s * 0.14f, s * 0.45f))
|
||||||
|
}
|
||||||
|
|
||||||
|
Resource.GRAIN -> {
|
||||||
|
drawLine(color = line, start = Offset(at.x, at.y + s), end = Offset(at.x, at.y - s * 0.7f), strokeWidth = s * 0.14f)
|
||||||
|
for (i in 0..2) {
|
||||||
|
val y = at.y - s * 0.7f + i * s * 0.5f
|
||||||
|
for (side in listOf(-1f, 1f)) {
|
||||||
|
val kernel = Path().apply {
|
||||||
|
moveTo(at.x, y + s * 0.3f)
|
||||||
|
lineTo(at.x + side * s * 0.6f, y - s * 0.05f)
|
||||||
|
lineTo(at.x + side * s * 0.15f, y - s * 0.2f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
drawPath(kernel, color = fill)
|
||||||
|
drawPath(kernel, color = line, style = Stroke(width = s * 0.08f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Resource.ORE -> {
|
||||||
|
val peak = Path().apply {
|
||||||
|
moveTo(at.x - s, at.y + s * 0.8f)
|
||||||
|
lineTo(at.x - s * 0.3f, at.y - s * 0.6f)
|
||||||
|
lineTo(at.x + s * 0.1f, at.y + s * 0.05f)
|
||||||
|
lineTo(at.x + s * 0.45f, at.y - s * 0.3f)
|
||||||
|
lineTo(at.x + s, at.y + s * 0.8f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
drawPath(peak, color = fill)
|
||||||
|
drawPath(peak, color = line, style = outline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pieces
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun DrawScope.drawRoads(state: CatanState, board: BoardLayout, style: CatanStyle) {
|
||||||
|
for (edge in 0 until CatanBoard.EDGE_COUNT) {
|
||||||
|
val owner = state.roads[edge]
|
||||||
|
if (owner == NO_PLAYER) continue
|
||||||
|
val (a, b) = board.edgeEnds(edge)
|
||||||
|
// Pulled in from the ends so adjacent roads do not fuse into a blob.
|
||||||
|
val from = lerp(a, b, 0.12f)
|
||||||
|
val to = lerp(a, b, 0.88f)
|
||||||
|
val width = board.radius * style.roadWidth
|
||||||
|
drawLine(
|
||||||
|
color = style.pieceOutline,
|
||||||
|
start = from.offset(),
|
||||||
|
end = to.offset(),
|
||||||
|
strokeWidth = width * 1.45f,
|
||||||
|
cap = StrokeCap.Round,
|
||||||
|
)
|
||||||
|
drawLine(
|
||||||
|
color = style.seats[owner % style.seats.size],
|
||||||
|
start = from.offset(),
|
||||||
|
end = to.offset(),
|
||||||
|
strokeWidth = width,
|
||||||
|
cap = StrokeCap.Round,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.drawBuildings(state: CatanState, board: BoardLayout, style: CatanStyle) {
|
||||||
|
for (vertex in 0 until CatanBoard.VERTEX_COUNT) {
|
||||||
|
val building = state.buildings[vertex] ?: continue
|
||||||
|
val at = board.vertex(vertex)
|
||||||
|
val fill = style.seats[building.owner % style.seats.size]
|
||||||
|
val path = when (building.type) {
|
||||||
|
BuildingType.SETTLEMENT -> housePath(at, board.radius * style.settlementSize)
|
||||||
|
BuildingType.CITY -> cityPath(at, board.radius * style.citySize * 0.8f)
|
||||||
|
}
|
||||||
|
drawPath(path, color = fill)
|
||||||
|
drawPath(path, color = style.pieceOutline, style = Stroke(width = board.radius * 0.045f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One gable: a settlement. [s] is its half-width. */
|
||||||
|
private fun housePath(at: Pt, s: Float): Path = Path().apply {
|
||||||
|
moveTo(at.x - s, at.y + s * 0.7f)
|
||||||
|
lineTo(at.x - s, at.y - s * 0.1f)
|
||||||
|
lineTo(at.x, at.y - s)
|
||||||
|
lineTo(at.x + s, at.y - s * 0.1f)
|
||||||
|
lineTo(at.x + s, at.y + s * 0.7f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two gables, the taller on the left: a city. [s] is the unit its outline is scaled by. */
|
||||||
|
private fun cityPath(at: Pt, s: Float): Path = Path().apply {
|
||||||
|
moveTo(at.x - s, at.y + s * 0.7f)
|
||||||
|
lineTo(at.x - s, at.y - s * 0.5f)
|
||||||
|
lineTo(at.x - s * 0.45f, at.y - s * 1.1f)
|
||||||
|
lineTo(at.x + s * 0.1f, at.y - s * 0.5f)
|
||||||
|
lineTo(at.x + s * 0.1f, at.y - s * 0.1f)
|
||||||
|
lineTo(at.x + s * 0.55f, at.y - s * 0.55f)
|
||||||
|
lineTo(at.x + s * 1.1f, at.y - s * 0.1f)
|
||||||
|
lineTo(at.x + s * 1.1f, at.y + s * 0.7f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cities & Knights pieces
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A dashed wall round each walled city, crenellated by the dashes. */
|
||||||
|
private fun DrawScope.drawWalls(state: CatanState, board: BoardLayout, style: CatanStyle) {
|
||||||
|
val walls = state.ck?.walls ?: return
|
||||||
|
for (vertex in walls) {
|
||||||
|
val at = board.vertex(vertex).offset()
|
||||||
|
val radius = board.radius * style.citySize * 1.25f
|
||||||
|
val dash = PathEffect.dashPathEffect(floatArrayOf(radius * 0.38f, radius * 0.22f))
|
||||||
|
drawCircle(
|
||||||
|
color = style.pieceOutline,
|
||||||
|
radius = radius,
|
||||||
|
center = at,
|
||||||
|
style = Stroke(width = board.radius * 0.13f, pathEffect = dash),
|
||||||
|
)
|
||||||
|
drawCircle(
|
||||||
|
color = style.wall,
|
||||||
|
radius = radius,
|
||||||
|
center = at,
|
||||||
|
style = Stroke(width = board.radius * 0.09f, pathEffect = dash),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pennant in the track's colour flying over a city that carries its metropolis. */
|
||||||
|
private fun DrawScope.drawMetropolises(state: CatanState, board: BoardLayout, style: CatanStyle) {
|
||||||
|
val metropolises = state.ck?.metropolises ?: return
|
||||||
|
for ((track, at) in metropolises) {
|
||||||
|
val base = board.vertex(at.vertex)
|
||||||
|
val unit = board.radius * style.citySize
|
||||||
|
val poleTop = Pt(base.x + unit * 0.9f, base.y - unit * 2.0f)
|
||||||
|
val poleBottom = Pt(base.x + unit * 0.9f, base.y - unit * 0.4f)
|
||||||
|
drawLine(
|
||||||
|
color = style.pieceOutline,
|
||||||
|
start = poleBottom.offset(),
|
||||||
|
end = poleTop.offset(),
|
||||||
|
strokeWidth = board.radius * 0.05f,
|
||||||
|
)
|
||||||
|
val flag = Path().apply {
|
||||||
|
moveTo(poleTop.x, poleTop.y)
|
||||||
|
lineTo(poleTop.x + unit * 1.1f, poleTop.y + unit * 0.35f)
|
||||||
|
lineTo(poleTop.x, poleTop.y + unit * 0.7f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
drawPath(flag, color = style.track.getValue(track))
|
||||||
|
drawPath(flag, color = style.pieceOutline, style = Stroke(width = board.radius * 0.035f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Each knight is a shield in its owner's colour, marked with one, two or three
|
||||||
|
* dots for its strength. A sleeping knight is drawn faded; an awake one has a
|
||||||
|
* bright rim.
|
||||||
|
*/
|
||||||
|
private fun DrawScope.drawKnights(state: CatanState, board: BoardLayout, style: CatanStyle) {
|
||||||
|
val knights = state.ck?.knights ?: return
|
||||||
|
for (knight in knights) {
|
||||||
|
val at = board.vertex(knight.vertex)
|
||||||
|
val s = board.radius * style.knightSize
|
||||||
|
val seat = style.seats[knight.owner % style.seats.size]
|
||||||
|
val path = shieldPath(at, s)
|
||||||
|
if (knight.active) {
|
||||||
|
drawPath(path, color = style.knightReady, style = Stroke(width = s * 0.55f))
|
||||||
|
}
|
||||||
|
drawPath(path, color = if (knight.active) seat else seat.copy(alpha = 0.55f))
|
||||||
|
drawPath(path, color = style.pieceOutline, style = Stroke(width = s * 0.16f))
|
||||||
|
val pip = s * 0.14f
|
||||||
|
val gap = pip * 2.8f
|
||||||
|
val startX = at.x - gap * (knight.level - 1) / 2f
|
||||||
|
for (i in 0 until knight.level) {
|
||||||
|
drawCircle(
|
||||||
|
color = style.pieceOutline,
|
||||||
|
radius = pip,
|
||||||
|
center = Offset(startX + i * gap, at.y - s * 0.05f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shieldPath(at: Pt, s: Float): Path = Path().apply {
|
||||||
|
moveTo(at.x - s * 0.8f, at.y - s)
|
||||||
|
lineTo(at.x + s * 0.8f, at.y - s)
|
||||||
|
lineTo(at.x + s * 0.8f, at.y + s * 0.1f)
|
||||||
|
quadraticBezierTo(at.x + s * 0.8f, at.y + s * 0.9f, at.x, at.y + s * 1.25f)
|
||||||
|
quadraticBezierTo(at.x - s * 0.8f, at.y + s * 0.9f, at.x - s * 0.8f, at.y + s * 0.1f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A ring round each of the player's knights that can do something now. */
|
||||||
|
private fun DrawScope.drawKnightTargets(targets: BoardTargets, board: BoardLayout, style: CatanStyle) {
|
||||||
|
for (vertex in targets.knights) {
|
||||||
|
drawCircle(
|
||||||
|
color = style.target,
|
||||||
|
radius = board.radius * style.knightSize * 1.9f,
|
||||||
|
center = board.vertex(vertex).offset(),
|
||||||
|
style = Stroke(width = board.radius * 0.07f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The merchant: a coin in its controller's colour, beside the hex it sits on. */
|
||||||
|
private fun DrawScope.drawMerchant(
|
||||||
|
state: CatanState,
|
||||||
|
board: BoardLayout,
|
||||||
|
style: CatanStyle,
|
||||||
|
measurer: TextMeasurer,
|
||||||
|
) {
|
||||||
|
val merchant = state.ck?.merchant ?: return
|
||||||
|
val hex = board.hexCenter(merchant.hex)
|
||||||
|
val at = Pt(hex.x - board.radius * 0.5f, hex.y + board.radius * 0.3f)
|
||||||
|
val r = board.radius * 0.2f
|
||||||
|
drawCircle(color = style.seats[merchant.seat % style.seats.size], radius = r, center = at.offset())
|
||||||
|
drawCircle(color = style.pieceOutline, radius = r, center = at.offset(), style = Stroke(width = r * 0.3f))
|
||||||
|
drawCentered(
|
||||||
|
measurer = measurer,
|
||||||
|
text = "M",
|
||||||
|
at = at,
|
||||||
|
fontPx = r * 1.3f,
|
||||||
|
color = style.pieceOutline,
|
||||||
|
weight = FontWeight.ExtraBold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tap targets
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun DrawScope.drawVertexTargets(
|
||||||
|
targets: BoardTargets,
|
||||||
|
selected: Int?,
|
||||||
|
board: BoardLayout,
|
||||||
|
style: CatanStyle,
|
||||||
|
) {
|
||||||
|
val radius = board.radius * style.targetRadius
|
||||||
|
for (vertex in targets.vertices) {
|
||||||
|
val at = board.vertex(vertex).offset()
|
||||||
|
drawCircle(color = style.target.copy(alpha = 0.85f), radius = radius, center = at)
|
||||||
|
drawCircle(
|
||||||
|
color = style.pieceOutline,
|
||||||
|
radius = radius,
|
||||||
|
center = at,
|
||||||
|
style = Stroke(width = board.radius * 0.04f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (selected != null) {
|
||||||
|
val at = board.vertex(selected).offset()
|
||||||
|
drawCircle(
|
||||||
|
color = style.targetSelected,
|
||||||
|
radius = radius * 1.6f,
|
||||||
|
center = at,
|
||||||
|
style = Stroke(width = board.radius * 0.09f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.drawEdgeTargets(targets: BoardTargets, board: BoardLayout, style: CatanStyle) {
|
||||||
|
for (edge in targets.edges) {
|
||||||
|
val (a, b) = board.edgeEnds(edge)
|
||||||
|
val from = lerp(a, b, 0.15f)
|
||||||
|
val to = lerp(a, b, 0.85f)
|
||||||
|
drawLine(
|
||||||
|
color = style.pieceOutline,
|
||||||
|
start = from.offset(),
|
||||||
|
end = to.offset(),
|
||||||
|
strokeWidth = board.radius * style.roadWidth * 1.5f,
|
||||||
|
cap = StrokeCap.Round,
|
||||||
|
)
|
||||||
|
drawLine(
|
||||||
|
color = style.target.copy(alpha = 0.9f),
|
||||||
|
start = from.offset(),
|
||||||
|
end = to.offset(),
|
||||||
|
strokeWidth = board.radius * style.roadWidth,
|
||||||
|
cap = StrokeCap.Round,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.drawHexTargets(targets: BoardTargets, board: BoardLayout, style: CatanStyle) {
|
||||||
|
for (hex in targets.hexes) {
|
||||||
|
val centre = board.hexCenter(hex)
|
||||||
|
val path = polygon(board.hexCorners(hex).map { insetToward(it, centre, style.hexInset + 0.08f) })
|
||||||
|
drawPath(path, color = style.target.copy(alpha = 0.38f))
|
||||||
|
drawPath(path, color = style.target, style = Stroke(width = board.radius * 0.07f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Drawing helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun Pt.offset(): Offset = Offset(x, y)
|
||||||
|
|
||||||
|
private fun lerp(a: Pt, b: Pt, t: Float): Pt = Pt(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t)
|
||||||
|
|
||||||
|
/** [p] moved [fraction] of the way toward [centre]. */
|
||||||
|
private fun insetToward(p: Pt, centre: Pt, fraction: Float): Pt = lerp(p, centre, fraction)
|
||||||
|
|
||||||
|
private fun polygon(points: List<Pt>): Path = Path().apply {
|
||||||
|
points.forEachIndexed { i, p -> if (i == 0) moveTo(p.x, p.y) else lineTo(p.x, p.y) }
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.drawCentered(
|
||||||
|
measurer: TextMeasurer,
|
||||||
|
text: String,
|
||||||
|
at: Pt,
|
||||||
|
fontPx: Float,
|
||||||
|
color: Color,
|
||||||
|
weight: FontWeight,
|
||||||
|
) {
|
||||||
|
val layout = measurer.measure(
|
||||||
|
text = text,
|
||||||
|
style = TextStyle(fontSize = fontPx.toSp(), fontWeight = weight, color = color),
|
||||||
|
)
|
||||||
|
drawText(
|
||||||
|
textLayoutResult = layout,
|
||||||
|
topLeft = Offset(at.x - layout.size.width / 2f, at.y - layout.size.height / 2f),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,813 @@
|
|||||||
|
package org.prolibertate.games.ui.game.catan
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.prolibertate.games.game.catan.AcceptCounter
|
||||||
|
import org.prolibertate.games.game.catan.ActivateKnight
|
||||||
|
import org.prolibertate.games.game.catan.BuyImprovement
|
||||||
|
import org.prolibertate.games.game.catan.CatanCk
|
||||||
|
import org.prolibertate.games.game.catan.CatanMove
|
||||||
|
import org.prolibertate.games.game.catan.CatanState
|
||||||
|
import org.prolibertate.games.game.catan.ChaseRobber
|
||||||
|
import org.prolibertate.games.game.catan.CounterGoods
|
||||||
|
import org.prolibertate.games.game.catan.DropProgress
|
||||||
|
import org.prolibertate.games.game.catan.GoodsBankTrade
|
||||||
|
import org.prolibertate.games.game.catan.Good
|
||||||
|
import org.prolibertate.games.game.catan.GoodsCounter
|
||||||
|
import org.prolibertate.games.game.catan.GoodsOffer
|
||||||
|
import org.prolibertate.games.game.catan.IMPROVEMENT_MAX
|
||||||
|
import org.prolibertate.games.game.catan.MoveKnight
|
||||||
|
import org.prolibertate.games.game.catan.OfferGoods
|
||||||
|
import org.prolibertate.games.game.catan.PickDeck
|
||||||
|
import org.prolibertate.games.game.catan.PlayProgress
|
||||||
|
import org.prolibertate.games.game.catan.ProgressCard
|
||||||
|
import org.prolibertate.games.game.catan.ProgressType
|
||||||
|
import org.prolibertate.games.game.catan.PromoteKnight
|
||||||
|
import org.prolibertate.games.game.catan.SpyTake
|
||||||
|
import org.prolibertate.games.game.catan.TakeAqueduct
|
||||||
|
import org.prolibertate.games.game.catan.Track
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The Cities & Knights dialogs. Like the base game's, each is a stateless
|
||||||
|
* composable apart from the choice it is helping the player make, and what is
|
||||||
|
* allowed is always asked of the engine's legal list through CatanInteraction.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared pieces
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A card tile with a minus and a plus, for choosing how many of a good. */
|
||||||
|
@Composable
|
||||||
|
private fun GoodCounter(good: Good, count: Int, max: Int, onChange: (Int) -> Unit) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
GoodTile(good, count, enabled = max > 0 || count > 0)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onChange(count - 1) },
|
||||||
|
enabled = count > 0,
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
modifier = Modifier.width(34.dp).height(32.dp),
|
||||||
|
) { Text("−") }
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onChange(count + 1) },
|
||||||
|
enabled = count < max,
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
modifier = Modifier.width(34.dp).height(32.dp),
|
||||||
|
) { Text("+") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Map<Good, Int>.asGoods(): List<Good> = Good.entries.flatMap { g -> List(this[g] ?: 0) { g } }
|
||||||
|
|
||||||
|
/** A row of the three track colours, used for decks and improvements. */
|
||||||
|
@Composable
|
||||||
|
private fun TrackChip(track: Track, style: CatanStyle = LocalCatanStyle.current) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(14.dp)
|
||||||
|
.clip(RoundedCornerShape(3.dp))
|
||||||
|
.background(style.track.getValue(track)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Choosing cards to give up
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick exactly [quota] cards out of [held] — to discard on a seven or a saboteur,
|
||||||
|
* or to hand over for a wedding or a commercial harbour. Not dismissable: the
|
||||||
|
* game is waiting on it. [allowed] is the engine's structural check, so the
|
||||||
|
* confirm button only lights when the move would be accepted.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun CatanGoodsPickDialog(
|
||||||
|
title: String,
|
||||||
|
intro: String,
|
||||||
|
held: Map<Good, Int>,
|
||||||
|
quota: Int,
|
||||||
|
confirmLabel: String,
|
||||||
|
selectable: (Good) -> Boolean,
|
||||||
|
allowed: (List<Good>) -> Boolean,
|
||||||
|
onConfirm: (List<Good>) -> Unit,
|
||||||
|
) {
|
||||||
|
var picked by remember { mutableStateOf(emptyMap<Good, Int>()) }
|
||||||
|
val selection = picked.asGoods()
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {},
|
||||||
|
title = { Text(title) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text("$intro (${selection.size} of $quota chosen).")
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Good.entries.forEach { good ->
|
||||||
|
val have = held[good] ?: 0
|
||||||
|
if (have > 0 && selectable(good)) {
|
||||||
|
GoodCounter(
|
||||||
|
good = good,
|
||||||
|
count = picked[good] ?: 0,
|
||||||
|
max = if (selection.size < quota) have else (picked[good] ?: 0),
|
||||||
|
onChange = { n -> picked = picked + (good to n.coerceIn(0, have)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(onClick = { onConfirm(selection) }, enabled = allowed(selection)) { Text(confirmLabel) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// City improvements
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** The three improvement tracks: where the player stands, what the next level costs, and what it earns. */
|
||||||
|
@Composable
|
||||||
|
fun CatanImproveDialog(
|
||||||
|
state: CatanState,
|
||||||
|
seat: Int,
|
||||||
|
legal: List<CatanMove>,
|
||||||
|
onBuy: (BuyImprovement) -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
) {
|
||||||
|
val ck = state.ck ?: return
|
||||||
|
val buyable = CatanInteraction.improvable(legal)
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onCancel,
|
||||||
|
title = { Text(CatanText.IMPROVE) },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
Track.entries.forEach { track ->
|
||||||
|
val level = CatanCk.improvement(state, seat, track)
|
||||||
|
val have = ck.commodities[seat][track.commodity] ?: 0
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
TrackChip(track)
|
||||||
|
Text(CatanText.track(track), fontWeight = FontWeight.Bold)
|
||||||
|
Text("level $level of $IMPROVEMENT_MAX", style = MaterialTheme.typography.labelMedium)
|
||||||
|
}
|
||||||
|
Text(CatanText.trackPower(track), style = MaterialTheme.typography.bodySmall)
|
||||||
|
if (level >= IMPROVEMENT_MAX) {
|
||||||
|
Text("Fully built.", style = MaterialTheme.typography.bodySmall)
|
||||||
|
} else {
|
||||||
|
val cost = CatanCk.improvementCost(state, seat, track)
|
||||||
|
Button(onClick = { onBuy(BuyImprovement(track)) }, enabled = track in buyable) {
|
||||||
|
Text(
|
||||||
|
"${CatanCk.improvementName(track, level + 1)}: $cost " +
|
||||||
|
"${CatanText.good(Good.of(track.commodity))} (you hold $have)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (level + 1 == org.prolibertate.games.game.catan.METROPOLIS_LEVEL ||
|
||||||
|
level + 1 == IMPROVEMENT_MAX
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (level + 1 == IMPROVEMENT_MAX) {
|
||||||
|
"Level five takes a metropolis from a seat still on four."
|
||||||
|
} else {
|
||||||
|
"Level four earns the first metropolis in this track: two more points."
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
dismissButton = { TextButton(onClick = onCancel) { Text("Close") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Progress cards
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player's progress cards. A card with nothing to decide plays at once; one
|
||||||
|
* that names a seat, a card or dice asks for it in a second step; one that
|
||||||
|
* needs a tap on the board closes the dialog and arms the board (see [ArmedCard]).
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun CatanProgressDialog(
|
||||||
|
hand: List<ProgressInHand>,
|
||||||
|
seats: CatanSeats,
|
||||||
|
onPlay: (CatanMove) -> Unit,
|
||||||
|
onArm: (ArmedCard) -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
) {
|
||||||
|
var chosen by remember { mutableStateOf<ProgressInHand?>(null) }
|
||||||
|
var target by remember { mutableStateOf<Int?>(null) }
|
||||||
|
var yellow by remember { mutableStateOf(1) }
|
||||||
|
var red by remember { mutableStateOf(1) }
|
||||||
|
val entry = chosen
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onCancel,
|
||||||
|
title = { Text(entry?.let { CatanText.progress(it.card.kind) } ?: "Progress cards") },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.heightIn(max = 440.dp).verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
if (entry == null) {
|
||||||
|
if (hand.isEmpty()) Text("You hold no progress cards.")
|
||||||
|
hand.forEach { card ->
|
||||||
|
ProgressRow(card) { picked ->
|
||||||
|
val kind = picked.card.kind
|
||||||
|
when {
|
||||||
|
kind in CatanInteraction.BOARD_CARDS -> onArm(ArmedCard(kind, picked.plays))
|
||||||
|
picked.plays.size == 1 && picked.plays[0].isBare() -> onPlay(picked.plays[0])
|
||||||
|
else -> {
|
||||||
|
chosen = picked
|
||||||
|
target = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(CatanText.progressHelp(entry.card.kind), style = MaterialTheme.typography.bodySmall)
|
||||||
|
val kind = entry.card.kind
|
||||||
|
val plays = entry.plays
|
||||||
|
when {
|
||||||
|
kind == ProgressType.ALCHEMIST -> {
|
||||||
|
Text("Production die (yellow)", fontWeight = FontWeight.Bold)
|
||||||
|
DiePicker(yellow) { yellow = it }
|
||||||
|
Text("Progress die (red)", fontWeight = FontWeight.Bold)
|
||||||
|
DiePicker(red) { red = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
kind == ProgressType.DESERTER || kind == ProgressType.SPY -> {
|
||||||
|
Text("Choose a player:", fontWeight = FontWeight.Bold)
|
||||||
|
plays.mapNotNull { it.target }.distinct().forEach { seat ->
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { plays.firstOrNull { it.target == seat }?.let(onPlay) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text(seats.label(seat)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kind == ProgressType.MASTER_MERCHANT -> {
|
||||||
|
Text("Choose a player with more points:", fontWeight = FontWeight.Bold)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
plays.mapNotNull { it.target }.distinct().forEach { seat ->
|
||||||
|
if (target == seat) {
|
||||||
|
FilledTonalButton(onClick = { target = seat }) { Text(seats.label(seat)) }
|
||||||
|
} else {
|
||||||
|
OutlinedButton(onClick = { target = seat }) { Text(seats.label(seat)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val seat = target
|
||||||
|
if (seat != null) {
|
||||||
|
Text("Take:", fontWeight = FontWeight.Bold)
|
||||||
|
plays.filter { it.target == seat }.forEach { play ->
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onPlay(play) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text(CatanText.goods(play.goods.orEmpty())) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
val label = when (kind) {
|
||||||
|
ProgressType.COMMERCIAL_HARBOR -> "Offer which resource?"
|
||||||
|
ProgressType.MERCHANT_FLEET -> "Trade which card two for one?"
|
||||||
|
ProgressType.RESOURCE_MONOPOLY -> "Name a resource:"
|
||||||
|
else -> "Name a commodity:"
|
||||||
|
}
|
||||||
|
Text(label, fontWeight = FontWeight.Bold)
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
plays.mapNotNull { it.good }.distinct().forEach { good ->
|
||||||
|
GoodTile(
|
||||||
|
good = good,
|
||||||
|
count = 0,
|
||||||
|
onClick = { plays.firstOrNull { it.good == good }?.let(onPlay) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
if (entry != null && entry.card.kind == ProgressType.ALCHEMIST) {
|
||||||
|
val play = entry.plays.firstOrNull { it.dice == listOf(yellow, red) }
|
||||||
|
Button(onClick = { play?.let(onPlay) }, enabled = play != null) { Text("Play") }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
if (entry == null) {
|
||||||
|
onCancel()
|
||||||
|
} else {
|
||||||
|
chosen = null
|
||||||
|
target = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { Text(if (entry == null) CatanText.CANCEL else "Back") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PlayProgress.isBare(): Boolean =
|
||||||
|
target == null && vertex == null && edge == null && hexA == null && hexB == null &&
|
||||||
|
good == null && dice == null && goods == null
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DiePicker(selected: Int, onPick: (Int) -> Unit) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
(1..6).forEach { n ->
|
||||||
|
if (n == selected) {
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { onPick(n) },
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
modifier = Modifier.size(40.dp),
|
||||||
|
) { Text("$n") }
|
||||||
|
} else {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onPick(n) },
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
modifier = Modifier.size(40.dp),
|
||||||
|
) { Text("$n") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProgressRow(entry: ProgressInHand, onChoose: (ProgressInHand) -> Unit) {
|
||||||
|
val kind = entry.card.kind
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
TrackChip(kind.track)
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(CatanText.progress(kind), fontWeight = FontWeight.Bold)
|
||||||
|
Text(CatanText.progressHelp(kind), style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
if (entry.playable) {
|
||||||
|
Button(onClick = { onChoose(entry) }) { Text("Play") }
|
||||||
|
} else {
|
||||||
|
Text("Not now", style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Knights
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** What the knight on one crossing can do: wake, promote, move, chase the robber. */
|
||||||
|
@Composable
|
||||||
|
fun CatanKnightDialog(
|
||||||
|
state: CatanState,
|
||||||
|
vertex: Int,
|
||||||
|
actions: List<CatanMove>,
|
||||||
|
onDo: (CatanMove) -> Unit,
|
||||||
|
onMove: (Int) -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
) {
|
||||||
|
val knight = state.ck?.knights?.firstOrNull { it.vertex == vertex }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onCancel,
|
||||||
|
title = { Text(knight?.let { CatanText.knightName(it.level) } ?: "Knight") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
if (knight != null) {
|
||||||
|
val mood = when {
|
||||||
|
!knight.active -> "Asleep. Waking it costs ${CatanText.ACTIVATE_COST}."
|
||||||
|
knight.activatedTurn == state.turnNumber -> "Awake, but it woke this turn and cannot act yet."
|
||||||
|
else -> "Awake and ready. It counts against the barbarians."
|
||||||
|
}
|
||||||
|
Text(mood, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
actions.forEach { move ->
|
||||||
|
when (move) {
|
||||||
|
is ActivateKnight ->
|
||||||
|
Button(onClick = { onDo(move) }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Wake (${CatanText.ACTIVATE_COST})")
|
||||||
|
}
|
||||||
|
|
||||||
|
is PromoteKnight ->
|
||||||
|
Button(onClick = { onDo(move) }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Promote (${CatanText.KNIGHT_COST})")
|
||||||
|
}
|
||||||
|
|
||||||
|
is MoveKnight ->
|
||||||
|
OutlinedButton(onClick = { onMove(move.from) }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Move along your roads")
|
||||||
|
}
|
||||||
|
|
||||||
|
is ChaseRobber ->
|
||||||
|
OutlinedButton(onClick = { onDo(move) }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Chase away the robber")
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (actions.isEmpty()) Text("Nothing to do with it right now.")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
dismissButton = { TextButton(onClick = onCancel) { Text("Close") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Chores that are answered in a dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Choose a colour of progress deck to draw from. */
|
||||||
|
@Composable
|
||||||
|
fun CatanPickDeckDialog(legal: List<CatanMove>, onPick: (PickDeck) -> Unit) {
|
||||||
|
val open = legal.filterIsInstance<PickDeck>()
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {},
|
||||||
|
title = { Text("Draw a progress card") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("Catan held. You tied for the strongest defence, so choose any deck.")
|
||||||
|
open.forEach { pick ->
|
||||||
|
Button(onClick = { onPick(pick) }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
TrackChip(pick.track)
|
||||||
|
Text(" ${CatanText.track(pick.track)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The hand is over its limit: give one progress card back. */
|
||||||
|
@Composable
|
||||||
|
fun CatanDropProgressDialog(hand: List<ProgressCard>, onDrop: (DropProgress) -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {},
|
||||||
|
title = { Text("Too many progress cards") },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.heightIn(max = 380.dp).verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text("You may keep four. Choose one to give back.")
|
||||||
|
hand.forEach { card ->
|
||||||
|
OutlinedButton(onClick = { onDrop(DropProgress(card.id)) }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
TrackChip(card.kind.track)
|
||||||
|
Text(" ${CatanText.progress(card.kind)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The aqueduct pays: take any one resource. */
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun CatanAqueductDialog(legal: List<CatanMove>, onTake: (TakeAqueduct) -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {},
|
||||||
|
title = { Text("Aqueduct") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text("The dice paid you nothing. Take any resource.")
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
legal.filterIsInstance<TakeAqueduct>().forEach { take ->
|
||||||
|
ResourceTile(resource = take.resource, count = 1, onClick = { onTake(take) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A spy: these are the progress cards the other player holds; take one. */
|
||||||
|
@Composable
|
||||||
|
fun CatanSpyDialog(cards: List<ProgressCard>, legal: List<CatanMove>, onTake: (SpyTake) -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {},
|
||||||
|
title = { Text("Spy") },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.heightIn(max = 380.dp).verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text("Choose a card to take.")
|
||||||
|
cards.forEach { card ->
|
||||||
|
val take = SpyTake(card.id)
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onTake(take) },
|
||||||
|
enabled = take in legal,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
TrackChip(card.kind.track)
|
||||||
|
Text(" ${CatanText.progress(card.kind)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Trading in goods
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private enum class GoodsTab { BANK, PLAYER }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trade with the bank at the best ratio each card has, or offer cards —
|
||||||
|
* commodities included — to one other player.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun CatanGoodsTradeDialog(
|
||||||
|
state: CatanState,
|
||||||
|
seat: Int,
|
||||||
|
seats: CatanSeats,
|
||||||
|
legal: List<CatanMove>,
|
||||||
|
onBank: (GoodsBankTrade) -> Unit,
|
||||||
|
onOffer: (OfferGoods) -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
) {
|
||||||
|
var tab by remember { mutableStateOf(GoodsTab.BANK) }
|
||||||
|
var bankGive by remember { mutableStateOf<Good?>(null) }
|
||||||
|
var everyone by remember { mutableStateOf(true) }
|
||||||
|
var partner by remember { mutableStateOf<Int?>(null) }
|
||||||
|
var give by remember { mutableStateOf(emptyMap<Good, Int>()) }
|
||||||
|
var get by remember { mutableStateOf(emptyMap<Good, Int>()) }
|
||||||
|
val held = CatanCk.goodsOf(state, seat)
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onCancel,
|
||||||
|
title = { Text(CatanText.TRADE) },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
GoodsTabButton(CatanText.TRADE_WITH_BANK, tab == GoodsTab.BANK) { tab = GoodsTab.BANK }
|
||||||
|
GoodsTabButton(CatanText.TRADE_WITH_PLAYER, tab == GoodsTab.PLAYER) { tab = GoodsTab.PLAYER }
|
||||||
|
}
|
||||||
|
if (tab == GoodsTab.BANK) {
|
||||||
|
val trades = CatanInteraction.goodsBankTrades(legal)
|
||||||
|
if (trades.isEmpty()) {
|
||||||
|
Text("You do not hold enough of any one card to trade with the bank.")
|
||||||
|
} else {
|
||||||
|
Text("Give:", fontWeight = FontWeight.Bold)
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
trades.keys.forEach { good ->
|
||||||
|
val ratio = CatanCk.bankRatio(state, seat, good)
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
GoodTile(good, ratio, selected = bankGive == good, onClick = { bankGive = good })
|
||||||
|
Text("$ratio : 1", style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val chosen = bankGive
|
||||||
|
if (chosen != null && chosen in trades) {
|
||||||
|
Text("Get one:", fontWeight = FontWeight.Bold)
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
trades.getValue(chosen).forEach { good ->
|
||||||
|
GoodTile(good, 1, onClick = { onBank(GoodsBankTrade(chosen, good)) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Offer to:", fontWeight = FontWeight.Bold)
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
GoodsTabButton(CatanText.EVERYONE, everyone) {
|
||||||
|
everyone = true
|
||||||
|
partner = null
|
||||||
|
}
|
||||||
|
(0 until state.playerCount).filter { it != seat }.forEach { other ->
|
||||||
|
GoodsTabButton(seats.label(other), !everyone && partner == other) {
|
||||||
|
everyone = false
|
||||||
|
partner = other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(CatanText.YOU_GIVE, fontWeight = FontWeight.Bold)
|
||||||
|
GoodCounters(give, { held[it] ?: 0 }) { give = it }
|
||||||
|
Text(CatanText.YOU_GET, fontWeight = FontWeight.Bold)
|
||||||
|
GoodCounters(get, { 9 }) { get = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
if (tab == GoodsTab.PLAYER) {
|
||||||
|
val to = if (everyone) null else partner
|
||||||
|
val giveCards = give.asGoods()
|
||||||
|
val getCards = get.asGoods()
|
||||||
|
val allowed = (everyone || partner != null) &&
|
||||||
|
CatanInteraction.goodsOfferAllowed(state, seat, to, giveCards, getCards)
|
||||||
|
Button(
|
||||||
|
onClick = { onOffer(OfferGoods(to, giveCards, getCards)) },
|
||||||
|
enabled = allowed,
|
||||||
|
) { Text(CatanText.SEND_OFFER) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = onCancel) { Text(CatanText.CANCEL) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun GoodCounters(counts: Map<Good, Int>, limit: (Good) -> Int, onChange: (Map<Good, Int>) -> Unit) {
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Good.entries.forEach { good ->
|
||||||
|
GoodCounter(
|
||||||
|
good = good,
|
||||||
|
count = counts[good] ?: 0,
|
||||||
|
max = limit(good),
|
||||||
|
onChange = { n -> onChange(counts + (good to n.coerceIn(0, limit(good)))) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GoodsTabButton(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||||
|
if (selected) {
|
||||||
|
FilledTonalButton(onClick = onClick) { Text(label) }
|
||||||
|
} else {
|
||||||
|
OutlinedButton(onClick = onClick) { Text(label) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A goods offer has reached the local player: accept it, decline it, or answer
|
||||||
|
* with different terms. The counter's two sides start as the offer turned around.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun CatanGoodsTradeResponseDialog(
|
||||||
|
state: CatanState,
|
||||||
|
seat: Int,
|
||||||
|
offer: GoodsOffer,
|
||||||
|
seats: CatanSeats,
|
||||||
|
canAccept: Boolean,
|
||||||
|
onAccept: () -> Unit,
|
||||||
|
onDecline: () -> Unit,
|
||||||
|
onCounter: (CounterGoods) -> Unit,
|
||||||
|
) {
|
||||||
|
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()) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {},
|
||||||
|
title = { Text(if (countering) "Counter-offer" else "Trade offer") },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"${seats.label(offer.by)} offers ${CatanText.goods(offer.give)} to " +
|
||||||
|
"${if (offer.to == null) "the table" else "you"} for ${CatanText.goods(offer.get)}",
|
||||||
|
)
|
||||||
|
if (countering) {
|
||||||
|
Text(CatanText.YOU_GIVE, fontWeight = FontWeight.Bold)
|
||||||
|
GoodCounters(give, { CatanCk.goodsOf(state, seat)[it] ?: 0 }) { give = it }
|
||||||
|
Text(CatanText.YOU_GET, fontWeight = FontWeight.Bold)
|
||||||
|
GoodCounters(get, { CatanCk.goodsOf(state, offer.by)[it] ?: 0 }) { get = it }
|
||||||
|
} else if (!canAccept) {
|
||||||
|
Text(
|
||||||
|
"You cannot accept this: one side no longer holds the cards.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
if (countering) {
|
||||||
|
val move = CounterGoods(give.asGoods(), get.asGoods())
|
||||||
|
Button(
|
||||||
|
onClick = { onCounter(move) },
|
||||||
|
enabled = CatanInteraction.goodsCounterAllowed(state, seat, move.give, move.get),
|
||||||
|
) { Text(CatanText.SEND_COUNTER) }
|
||||||
|
} else {
|
||||||
|
Button(onClick = onAccept, enabled = canAccept) { Text(CatanText.ACCEPT) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
if (countering) {
|
||||||
|
TextButton(onClick = { countering = false }) { Text(CatanText.CANCEL) }
|
||||||
|
} else {
|
||||||
|
TextButton(onClick = { countering = true }) { Text(CatanText.COUNTER) }
|
||||||
|
}
|
||||||
|
TextButton(onClick = onDecline) { Text(CatanText.DECLINE) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The offerer's choice once nobody accepted a goods offer: take a counter-offer, or none. */
|
||||||
|
@Composable
|
||||||
|
fun CatanGoodsCounterChoiceDialog(
|
||||||
|
counters: List<GoodsCounter>,
|
||||||
|
seats: CatanSeats,
|
||||||
|
onTake: (AcceptCounter) -> Unit,
|
||||||
|
onNoDeal: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {},
|
||||||
|
title = { Text("Counter-offers") },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
if (counters.isEmpty()) Text("None of the counter-offers can be met any more.")
|
||||||
|
counters.forEach { counter ->
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(
|
||||||
|
"${seats.label(counter.by)} would give ${CatanText.goods(counter.give)} " +
|
||||||
|
"for ${CatanText.goods(counter.get)}",
|
||||||
|
)
|
||||||
|
Button(onClick = { onTake(AcceptCounter(counter.by)) }) { Text(CatanText.ACCEPT) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = onNoDeal) { Text(CatanText.NO_DEAL) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,626 @@
|
|||||||
|
package org.prolibertate.games.ui.game.catan
|
||||||
|
|
||||||
|
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.prolibertate.games.game.catan.CatanMove
|
||||||
|
import org.prolibertate.games.game.catan.CatanRules
|
||||||
|
import org.prolibertate.games.game.catan.CatanState
|
||||||
|
import org.prolibertate.games.game.catan.DevCardType
|
||||||
|
import org.prolibertate.games.game.catan.Discard
|
||||||
|
import org.prolibertate.games.game.catan.MaritimeTrade
|
||||||
|
import org.prolibertate.games.game.catan.OfferTrade
|
||||||
|
import org.prolibertate.games.game.catan.TradeCounter
|
||||||
|
import org.prolibertate.games.game.catan.CounterTrade
|
||||||
|
import org.prolibertate.games.game.catan.AcceptCounter
|
||||||
|
import org.prolibertate.games.game.catan.PlayKnight
|
||||||
|
import org.prolibertate.games.game.catan.PlayRoadBuilding
|
||||||
|
import org.prolibertate.games.game.catan.Resource
|
||||||
|
import org.prolibertate.games.game.catan.TradeOffer
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Every Katan dialog. Each is a stateless composable apart from the selection it
|
||||||
|
* is helping the player make (cards picked, a partner chosen); the move it ends
|
||||||
|
* in is reported through a lambda and sent by the screen. What is *allowed* is
|
||||||
|
* always asked of [CatanInteraction], never decided here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared pieces
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A resource tile with a minus and a plus, for choosing how many cards. */
|
||||||
|
@Composable
|
||||||
|
private fun ResourceCounter(
|
||||||
|
resource: Resource,
|
||||||
|
count: Int,
|
||||||
|
max: Int,
|
||||||
|
onChange: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
ResourceTile(resource, count, enabled = max > 0 || count > 0)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onChange(count - 1) },
|
||||||
|
enabled = count > 0,
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
modifier = Modifier.width(34.dp).height(32.dp),
|
||||||
|
) { Text("−") }
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onChange(count + 1) },
|
||||||
|
enabled = count < max,
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
modifier = Modifier.width(34.dp).height(32.dp),
|
||||||
|
) { Text("+") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Map<Resource, Int>.asCards(): List<Resource> =
|
||||||
|
Resource.entries.flatMap { r -> List(this[r] ?: 0) { r } }
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Discarding on a seven
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A seven was rolled and this player holds more than seven cards: choose which
|
||||||
|
* half to give up. Not dismissable — the game is waiting on it.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun CatanDiscardDialog(
|
||||||
|
held: Map<Resource, Int>,
|
||||||
|
quota: Int,
|
||||||
|
legal: List<CatanMove>,
|
||||||
|
onSubmit: (Discard) -> Unit,
|
||||||
|
) {
|
||||||
|
var picked by remember { mutableStateOf(emptyMap<Resource, Int>()) }
|
||||||
|
val selection = picked.asCards()
|
||||||
|
val move = CatanInteraction.discardMove(legal, selection)
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {},
|
||||||
|
title = { Text("Discard $quota cards") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(
|
||||||
|
"A seven was rolled. Choose $quota cards to give up " +
|
||||||
|
"(${selection.size} of $quota chosen).",
|
||||||
|
)
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Resource.entries.forEach { resource ->
|
||||||
|
val have = held[resource] ?: 0
|
||||||
|
if (have > 0) {
|
||||||
|
ResourceCounter(
|
||||||
|
resource = resource,
|
||||||
|
count = picked[resource] ?: 0,
|
||||||
|
max = if (selection.size < quota) have else (picked[resource] ?: 0),
|
||||||
|
onChange = { n -> picked = picked + (resource to n.coerceIn(0, have)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(onClick = { move?.let(onSubmit) }, enabled = move != null) { Text("Discard") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The robber
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** More than one player can be robbed from this hex: choose whom. */
|
||||||
|
@Composable
|
||||||
|
fun CatanVictimDialog(
|
||||||
|
victims: List<Int>,
|
||||||
|
seats: CatanSeats,
|
||||||
|
onPick: (Int) -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onCancel,
|
||||||
|
title = { Text("Steal from…") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
victims.forEach { seat ->
|
||||||
|
OutlinedButton(onClick = { onPick(seat) }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(seats.label(seat))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
dismissButton = { TextButton(onClick = onCancel) { Text(CatanText.CANCEL) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Development cards
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player's development cards. A Knight or Road Building plays at once; a
|
||||||
|
* Monopoly or Year of Plenty asks for its resource choice in a second step.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun CatanPlayCardDialog(
|
||||||
|
hand: List<DevCardInHand>,
|
||||||
|
onPlay: (CatanMove) -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
) {
|
||||||
|
var chosen by remember { mutableStateOf<DevCardInHand?>(null) }
|
||||||
|
var picked by remember { mutableStateOf(emptyList<Resource>()) }
|
||||||
|
val card = chosen
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onCancel,
|
||||||
|
title = { Text(card?.let { CatanText.devCard(it.card.type) } ?: "Development cards") },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
card == null -> {
|
||||||
|
if (hand.isEmpty()) Text("You have no development cards.")
|
||||||
|
hand.forEach { entry -> DevCardRow(entry, onPlay) { chosen = it } }
|
||||||
|
}
|
||||||
|
|
||||||
|
card.card.type == DevCardType.MONOPOLY -> {
|
||||||
|
Text("Name a resource. Every other player hands over all of it.")
|
||||||
|
val choices = CatanInteraction.monopolyChoices(card.plays)
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Resource.entries.forEach { resource ->
|
||||||
|
val move = choices[resource]
|
||||||
|
ResourceTile(
|
||||||
|
resource = resource,
|
||||||
|
count = 0,
|
||||||
|
enabled = move != null,
|
||||||
|
onClick = { move?.let(onPlay) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
card.card.type == DevCardType.YEAR_OF_PLENTY -> {
|
||||||
|
Text("Take two resource cards from the bank (${picked.size} of 2 chosen).")
|
||||||
|
val available = CatanInteraction.plentyAvailable(card.plays, picked)
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Resource.entries.forEach { resource ->
|
||||||
|
ResourceTile(
|
||||||
|
resource = resource,
|
||||||
|
count = picked.count { it == resource },
|
||||||
|
selected = resource in picked,
|
||||||
|
enabled = resource in available,
|
||||||
|
onClick = { picked = picked + resource },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (picked.isNotEmpty()) {
|
||||||
|
TextButton(onClick = { picked = emptyList() }) { Text("Clear") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
if (card != null && card.card.type == DevCardType.YEAR_OF_PLENTY) {
|
||||||
|
val move = CatanInteraction.plentyMove(card.plays, picked)
|
||||||
|
Button(onClick = { move?.let(onPlay) }, enabled = move != null) { Text("Take") }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
if (card == null) {
|
||||||
|
onCancel()
|
||||||
|
} else {
|
||||||
|
chosen = null
|
||||||
|
picked = emptyList()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { Text(if (card == null) CatanText.CANCEL else "Back") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DevCardRow(
|
||||||
|
entry: DevCardInHand,
|
||||||
|
onPlay: (CatanMove) -> Unit,
|
||||||
|
onChoose: (DevCardInHand) -> Unit,
|
||||||
|
) {
|
||||||
|
val type = entry.card.type
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(CatanText.devCard(type), fontWeight = FontWeight.Bold)
|
||||||
|
Text(CatanText.devCardHelp(type), style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
type == DevCardType.VICTORY_POINT -> Text("Hidden", style = MaterialTheme.typography.labelSmall)
|
||||||
|
!entry.playable -> Text("Not now", style = MaterialTheme.typography.labelSmall)
|
||||||
|
entry.plays.size == 1 && (entry.plays[0] is PlayKnight || entry.plays[0] is PlayRoadBuilding) ->
|
||||||
|
Button(onClick = { onPlay(entry.plays[0]) }) { Text("Play") }
|
||||||
|
|
||||||
|
else -> Button(onClick = { onChoose(entry) }) { Text("Play…") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Trading
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private enum class TradeTab { BANK, PLAYER }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trade with the bank at the best ratio the player has, or offer cards to one
|
||||||
|
* other player. Offers are free-form, so they are checked with the engine's
|
||||||
|
* structural rule ([CatanInteraction.offerAllowed]) rather than looked up in
|
||||||
|
* the legal list.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun CatanTradeDialog(
|
||||||
|
state: CatanState,
|
||||||
|
seat: Int,
|
||||||
|
seats: CatanSeats,
|
||||||
|
legal: List<CatanMove>,
|
||||||
|
onBank: (MaritimeTrade) -> Unit,
|
||||||
|
onOffer: (OfferTrade) -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
) {
|
||||||
|
var tab by remember { mutableStateOf(TradeTab.BANK) }
|
||||||
|
var bankGive by remember { mutableStateOf<Resource?>(null) }
|
||||||
|
var everyone by remember { mutableStateOf(true) }
|
||||||
|
var partner by remember { mutableStateOf<Int?>(null) }
|
||||||
|
var give by remember { mutableStateOf(emptyMap<Resource, Int>()) }
|
||||||
|
var get by remember { mutableStateOf(emptyMap<Resource, Int>()) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onCancel,
|
||||||
|
title = { Text(CatanText.TRADE) },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
TabButton(CatanText.TRADE_WITH_BANK, tab == TradeTab.BANK) { tab = TradeTab.BANK }
|
||||||
|
TabButton(CatanText.TRADE_WITH_PLAYER, tab == TradeTab.PLAYER) { tab = TradeTab.PLAYER }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tab == TradeTab.BANK) {
|
||||||
|
val trades = CatanInteraction.bankTrades(legal)
|
||||||
|
if (trades.isEmpty()) {
|
||||||
|
Text("You do not hold enough of any one resource to trade with the bank.")
|
||||||
|
} else {
|
||||||
|
Text("Give:", fontWeight = FontWeight.Bold)
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
trades.keys.forEach { resource ->
|
||||||
|
val ratio = CatanInteraction.bankRatio(state, seat, resource)
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
ResourceTile(
|
||||||
|
resource = resource,
|
||||||
|
count = ratio,
|
||||||
|
selected = bankGive == resource,
|
||||||
|
onClick = { bankGive = resource },
|
||||||
|
)
|
||||||
|
Text("$ratio : 1", style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val chosen = bankGive
|
||||||
|
if (chosen != null && chosen in trades) {
|
||||||
|
Text("Get one:", fontWeight = FontWeight.Bold)
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
trades.getValue(chosen).forEach { resource ->
|
||||||
|
ResourceTile(
|
||||||
|
resource = resource,
|
||||||
|
count = 1,
|
||||||
|
onClick = { onBank(MaritimeTrade(chosen, resource)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Offer to:", fontWeight = FontWeight.Bold)
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
TabButton(CatanText.EVERYONE, everyone) { everyone = true; partner = null }
|
||||||
|
(0 until state.playerCount).filter { it != seat }.forEach { other ->
|
||||||
|
TabButton(seats.label(other), !everyone && partner == other) {
|
||||||
|
everyone = false
|
||||||
|
partner = other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(CatanText.YOU_GIVE, fontWeight = FontWeight.Bold)
|
||||||
|
CounterRow(
|
||||||
|
counts = give,
|
||||||
|
limit = { state.resources[seat][it] ?: 0 },
|
||||||
|
onChange = { give = it },
|
||||||
|
)
|
||||||
|
Text(CatanText.YOU_GET, fontWeight = FontWeight.Bold)
|
||||||
|
CounterRow(counts = get, limit = { 9 }, onChange = { get = it })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
if (tab == TradeTab.PLAYER) {
|
||||||
|
val to = if (everyone) null else partner
|
||||||
|
val giveCards = give.asCards()
|
||||||
|
val getCards = get.asCards()
|
||||||
|
val allowed = (everyone || partner != null) &&
|
||||||
|
CatanInteraction.offerAllowed(state, seat, to, giveCards, getCards)
|
||||||
|
Button(
|
||||||
|
onClick = { onOffer(OfferTrade(to, giveCards, getCards)) },
|
||||||
|
enabled = allowed,
|
||||||
|
) { Text(CatanText.SEND_OFFER) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = onCancel) { Text(CatanText.CANCEL) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun CounterRow(
|
||||||
|
counts: Map<Resource, Int>,
|
||||||
|
limit: (Resource) -> Int,
|
||||||
|
onChange: (Map<Resource, Int>) -> Unit,
|
||||||
|
) {
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Resource.entries.forEach { resource ->
|
||||||
|
ResourceCounter(
|
||||||
|
resource = resource,
|
||||||
|
count = counts[resource] ?: 0,
|
||||||
|
max = limit(resource),
|
||||||
|
onChange = { n -> onChange(counts + (resource to n.coerceIn(0, limit(resource)))) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TabButton(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||||
|
if (selected) {
|
||||||
|
FilledTonalButton(onClick = onClick) { Text(label) }
|
||||||
|
} else {
|
||||||
|
OutlinedButton(onClick = onClick) { Text(label) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Another player has put an offer to the local player (alone, or as one of the
|
||||||
|
* table). Accept, decline, or — via a second step — answer with different terms.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun CatanTradeResponseDialog(
|
||||||
|
state: CatanState,
|
||||||
|
seat: Int,
|
||||||
|
offer: TradeOffer,
|
||||||
|
seats: CatanSeats,
|
||||||
|
canAccept: Boolean,
|
||||||
|
onAccept: () -> Unit,
|
||||||
|
onDecline: () -> Unit,
|
||||||
|
onCounter: (CounterTrade) -> Unit,
|
||||||
|
) {
|
||||||
|
var countering by remember(offer) { mutableStateOf(false) }
|
||||||
|
// What we would give the offerer, and what we would ask of them.
|
||||||
|
var give by remember(offer) { mutableStateOf(offer.get.groupingBy { it }.eachCount()) }
|
||||||
|
var get by remember(offer) { mutableStateOf(offer.give.groupingBy { it }.eachCount()) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {},
|
||||||
|
title = { Text(if (countering) "Counter-offer" else "Trade offer") },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
CatanText.tradeSummary(
|
||||||
|
by = seats.label(offer.by),
|
||||||
|
to = if (offer.to == null) "the table" else "you",
|
||||||
|
give = offer.give,
|
||||||
|
get = offer.get,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (countering) {
|
||||||
|
Text(CatanText.YOU_GIVE, fontWeight = FontWeight.Bold)
|
||||||
|
CounterRow(
|
||||||
|
counts = give,
|
||||||
|
limit = { state.resources[seat][it] ?: 0 },
|
||||||
|
onChange = { give = it },
|
||||||
|
)
|
||||||
|
Text(CatanText.YOU_GET, fontWeight = FontWeight.Bold)
|
||||||
|
CounterRow(
|
||||||
|
counts = get,
|
||||||
|
limit = { state.resources[offer.by][it] ?: 0 },
|
||||||
|
onChange = { get = it },
|
||||||
|
)
|
||||||
|
} else if (!canAccept) {
|
||||||
|
Text(
|
||||||
|
"You cannot accept this: one side no longer holds the cards.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
if (countering) {
|
||||||
|
val move = CounterTrade(give.asCards(), get.asCards())
|
||||||
|
Button(
|
||||||
|
onClick = { onCounter(move) },
|
||||||
|
enabled = CatanInteraction.counterAllowed(state, seat, move.give, move.get),
|
||||||
|
) { Text(CatanText.SEND_COUNTER) }
|
||||||
|
} else {
|
||||||
|
Button(onClick = onAccept, enabled = canAccept) { Text(CatanText.ACCEPT) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
if (countering) {
|
||||||
|
TextButton(onClick = { countering = false }) { Text(CatanText.CANCEL) }
|
||||||
|
} else {
|
||||||
|
TextButton(onClick = { countering = true }) { Text(CatanText.COUNTER) }
|
||||||
|
}
|
||||||
|
TextButton(onClick = onDecline) { Text(CatanText.DECLINE) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The offerer's choice once nobody accepted: take a counter-offer, or none. */
|
||||||
|
@Composable
|
||||||
|
fun CatanCounterChoiceDialog(
|
||||||
|
counters: List<TradeCounter>,
|
||||||
|
seats: CatanSeats,
|
||||||
|
onTake: (AcceptCounter) -> Unit,
|
||||||
|
onNoDeal: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {},
|
||||||
|
title = { Text("Counter-offers") },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
if (counters.isEmpty()) {
|
||||||
|
Text("None of the counter-offers can be met any more.")
|
||||||
|
}
|
||||||
|
counters.forEach { counter ->
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(CatanText.counterSummary(seats.label(counter.by), counter.give, counter.get))
|
||||||
|
Button(onClick = { onTake(AcceptCounter(counter.by)) }) { Text(CatanText.ACCEPT) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = onNoDeal) { Text(CatanText.NO_DEAL) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Ending
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** The game is over: who won, and what everyone finished with. */
|
||||||
|
@Composable
|
||||||
|
fun CatanGameOverDialog(
|
||||||
|
state: CatanState,
|
||||||
|
seats: CatanSeats,
|
||||||
|
onExit: () -> Unit,
|
||||||
|
onLook: () -> Unit,
|
||||||
|
) {
|
||||||
|
val points = (0 until state.playerCount).map { seat ->
|
||||||
|
// The winner's hidden victory-point cards are revealed by winning; the
|
||||||
|
// host's copy of the state has them, a client's has only its own.
|
||||||
|
seat to if (seat == seats.me) CatanRules.victoryPoints(state, seat)
|
||||||
|
else CatanRules.publicVictoryPoints(state)[seat]
|
||||||
|
}
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onLook,
|
||||||
|
title = { Text(CatanText.winnerHeadline(state, seats)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
points.sortedByDescending { it.second }.forEach { (seat, vp) ->
|
||||||
|
Text("${seats.label(seat)}: $vp points")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = onExit) { Text(CatanText.LEAVE_GAME) } },
|
||||||
|
dismissButton = { TextButton(onClick = onLook) { Text(CatanText.LOOK_AT_BOARD) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CatanEndGameDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onKeepPlaying,
|
||||||
|
title = { Text("End this game?") },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"This game ends here and the position is not saved. Anyone else " +
|
||||||
|
"at the table is told you have left.",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
// Ending a game on purpose also forgets the saved position: only
|
||||||
|
// leaving the table keeps it to be picked up again.
|
||||||
|
val handle = LocalEndGameHandle.current
|
||||||
|
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CatanAbandonedDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onStay,
|
||||||
|
title = { Text("The game has ended") },
|
||||||
|
text = { Text("$notice There are no more moves to play.") },
|
||||||
|
confirmButton = { TextButton(onClick = onExit) { Text(CatanText.LEAVE_GAME) } },
|
||||||
|
dismissButton = { TextButton(onClick = onStay) { Text(CatanText.LOOK_AT_BOARD) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package org.prolibertate.games.ui.game.catan
|
||||||
|
|
||||||
|
import org.prolibertate.games.game.catan.CatanBoard
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/** A point in board pixels. Plain floats, so this file needs no Compose types. */
|
||||||
|
data class Pt(val x: Float, val y: Float)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the island sits on screen: the one place the engine's integer lattice
|
||||||
|
* (see [CatanBoard]) becomes pixels, and pixels become "which vertex / edge /
|
||||||
|
* hex did the player tap".
|
||||||
|
*
|
||||||
|
* Pure and Android-free, so it is unit-tested (`CatanGeometryTest`). The drawing
|
||||||
|
* code ([CatanBoardView]) and the tap handling both go through this, which is
|
||||||
|
* what keeps what is drawn and what is tappable from ever disagreeing.
|
||||||
|
*
|
||||||
|
* Lattice to pixel: a hex of circumradius `R` is pointy-top, so one lattice x-unit
|
||||||
|
* is `R·√3/2` and one lattice y-unit is `R/2`, and lattice +Y points up the board
|
||||||
|
* while pixel +y points down.
|
||||||
|
*/
|
||||||
|
class BoardLayout(
|
||||||
|
val width: Float,
|
||||||
|
val height: Float,
|
||||||
|
/** Circumradius of one hex, in pixels. */
|
||||||
|
val radius: Float,
|
||||||
|
) {
|
||||||
|
private val centreX = width / 2f
|
||||||
|
private val centreY = height / 2f
|
||||||
|
private val unitX = radius * SQRT3 / 2f
|
||||||
|
private val unitY = radius / 2f
|
||||||
|
|
||||||
|
fun point(latticeX: Int, latticeY: Int): Pt =
|
||||||
|
Pt(centreX + latticeX * unitX, centreY - latticeY * unitY)
|
||||||
|
|
||||||
|
fun hexCenter(hex: Int): Pt {
|
||||||
|
val (lx, ly) = CatanBoard.hexCenter(hex)
|
||||||
|
return point(lx, ly)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun vertex(vertex: Int): Pt {
|
||||||
|
val (lx, ly) = CatanBoard.VERTEX_POS[vertex]
|
||||||
|
return point(lx, ly)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hexCorners(hex: Int): List<Pt> = CatanBoard.HEX_CORNERS[hex].map { vertex(it) }
|
||||||
|
|
||||||
|
fun edgeEnds(edge: Int): Pair<Pt, Pt> {
|
||||||
|
val (a, b) = CatanBoard.EDGE_VERTICES[edge]
|
||||||
|
return vertex(a) to vertex(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun edgeMidpoint(edge: Int): Pt {
|
||||||
|
val (a, b) = edgeEnds(edge)
|
||||||
|
return Pt((a.x + b.x) / 2f, (a.y + b.y) / 2f)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a harbour's marker is drawn: just off the coast, straight out from
|
||||||
|
* the middle of its edge. The marker is joined to the edge's two vertices by
|
||||||
|
* the board view, which is what shows which settlements can use it.
|
||||||
|
*/
|
||||||
|
fun portAnchor(edge: Int): Pt {
|
||||||
|
val mid = edgeMidpoint(edge)
|
||||||
|
val dx = mid.x - centreX
|
||||||
|
val dy = mid.y - centreY
|
||||||
|
val length = sqrt(dx * dx + dy * dy).coerceAtLeast(1f)
|
||||||
|
return Pt(mid.x + dx / length * radius * PORT_OFFSET, mid.y + dy / length * radius * PORT_OFFSET)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The nearest of [among] within [reach] pixels of [at], or null. */
|
||||||
|
fun nearestVertex(at: Pt, reach: Float, among: Collection<Int>): Int? =
|
||||||
|
nearest(at, reach, among) { vertex(it) }
|
||||||
|
|
||||||
|
fun nearestEdge(at: Pt, reach: Float, among: Collection<Int>): Int? =
|
||||||
|
nearest(at, reach, among) { edgeMidpoint(it) }
|
||||||
|
|
||||||
|
fun nearestHex(at: Pt, reach: Float, among: Collection<Int>): Int? =
|
||||||
|
nearest(at, reach, among) { hexCenter(it) }
|
||||||
|
|
||||||
|
private fun nearest(at: Pt, reach: Float, among: Collection<Int>, position: (Int) -> Pt): Int? {
|
||||||
|
var best: Int? = null
|
||||||
|
var bestDistance = reach * reach
|
||||||
|
for (candidate in among) {
|
||||||
|
val p = position(candidate)
|
||||||
|
val dx = p.x - at.x
|
||||||
|
val dy = p.y - at.y
|
||||||
|
val d = dx * dx + dy * dy
|
||||||
|
if (d <= bestDistance) {
|
||||||
|
best = candidate
|
||||||
|
bestDistance = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object CatanGeometry {
|
||||||
|
|
||||||
|
/** Lattice extents of the island: x spans -5..5, y spans -8..8. */
|
||||||
|
private const val LATTICE_X_UNITS = 10
|
||||||
|
private const val LATTICE_Y_UNITS = 16
|
||||||
|
|
||||||
|
/** Empty space round the island, in hex radii, for the sea and the harbours. */
|
||||||
|
const val MARGIN = 1.75f
|
||||||
|
|
||||||
|
private val boardWidthInRadii = LATTICE_X_UNITS * SQRT3 / 2f + 2 * MARGIN
|
||||||
|
private val boardHeightInRadii = LATTICE_Y_UNITS / 2f + 2 * MARGIN
|
||||||
|
|
||||||
|
/** Width ÷ height of the board including its margin, for sizing its box. */
|
||||||
|
val ASPECT: Float = boardWidthInRadii / boardHeightInRadii
|
||||||
|
|
||||||
|
/** The largest layout that fits [width] × [height] pixels. */
|
||||||
|
fun fit(width: Float, height: Float): BoardLayout {
|
||||||
|
val radius = minOf(width / boardWidthInRadii, height / boardHeightInRadii)
|
||||||
|
return BoardLayout(width, height, radius)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val SQRT3 = 1.7320508f
|
||||||
|
|
||||||
|
/** How far past the coast a harbour marker sits, in hex radii. Far enough that its piers are clearly visible. */
|
||||||
|
internal const val PORT_OFFSET = 1.0f
|
||||||
@@ -0,0 +1,578 @@
|
|||||||
|
package org.prolibertate.games.ui.game.catan
|
||||||
|
|
||||||
|
import org.prolibertate.games.game.catan.AcceptCounter
|
||||||
|
import org.prolibertate.games.game.catan.AcceptTrade
|
||||||
|
import org.prolibertate.games.game.catan.ActivateKnight
|
||||||
|
import org.prolibertate.games.game.catan.BuildCity
|
||||||
|
import org.prolibertate.games.game.catan.BuildRoad
|
||||||
|
import org.prolibertate.games.game.catan.BuildSettlement
|
||||||
|
import org.prolibertate.games.game.catan.BuildWall
|
||||||
|
import org.prolibertate.games.game.catan.BuyDevCard
|
||||||
|
import org.prolibertate.games.game.catan.BuyImprovement
|
||||||
|
import org.prolibertate.games.game.catan.CatanBoard
|
||||||
|
import org.prolibertate.games.game.catan.CatanMove
|
||||||
|
import org.prolibertate.games.game.catan.CatanPhase
|
||||||
|
import org.prolibertate.games.game.catan.CatanRules
|
||||||
|
import org.prolibertate.games.game.catan.CatanState
|
||||||
|
import org.prolibertate.games.game.catan.ChaseRobber
|
||||||
|
import org.prolibertate.games.game.catan.Chore
|
||||||
|
import org.prolibertate.games.game.catan.CounterTrade
|
||||||
|
import org.prolibertate.games.game.catan.DeclineTrade
|
||||||
|
import org.prolibertate.games.game.catan.DevCard
|
||||||
|
import org.prolibertate.games.game.catan.DevCardType
|
||||||
|
import org.prolibertate.games.game.catan.Discard
|
||||||
|
import org.prolibertate.games.game.catan.EndFreeRoads
|
||||||
|
import org.prolibertate.games.game.catan.EndSmith
|
||||||
|
import org.prolibertate.games.game.catan.EndTurn
|
||||||
|
import org.prolibertate.games.game.catan.CounterGoods
|
||||||
|
import org.prolibertate.games.game.catan.GoodsBankTrade
|
||||||
|
import org.prolibertate.games.game.catan.GoodsCounter
|
||||||
|
import org.prolibertate.games.game.catan.Good
|
||||||
|
import org.prolibertate.games.game.catan.HexCoord
|
||||||
|
import org.prolibertate.games.game.catan.HireKnight
|
||||||
|
import org.prolibertate.games.game.catan.MaritimeTrade
|
||||||
|
import org.prolibertate.games.game.catan.MoveKnight
|
||||||
|
import org.prolibertate.games.game.catan.MoveRobber
|
||||||
|
import org.prolibertate.games.game.catan.OfferGoods
|
||||||
|
import org.prolibertate.games.game.catan.OfferTrade
|
||||||
|
import org.prolibertate.games.game.catan.PillageCity
|
||||||
|
import org.prolibertate.games.game.catan.PlaceKnight
|
||||||
|
import org.prolibertate.games.game.catan.PlaceMetropolis
|
||||||
|
import org.prolibertate.games.game.catan.TradeCounter
|
||||||
|
import org.prolibertate.games.game.catan.PlayKnight
|
||||||
|
import org.prolibertate.games.game.catan.PlayMonopoly
|
||||||
|
import org.prolibertate.games.game.catan.PlayProgress
|
||||||
|
import org.prolibertate.games.game.catan.PlayRoadBuilding
|
||||||
|
import org.prolibertate.games.game.catan.PlayYearOfPlenty
|
||||||
|
import org.prolibertate.games.game.catan.ProgressCard
|
||||||
|
import org.prolibertate.games.game.catan.ProgressType
|
||||||
|
import org.prolibertate.games.game.catan.PromoteKnight
|
||||||
|
import org.prolibertate.games.game.catan.RelocateKnight
|
||||||
|
import org.prolibertate.games.game.catan.RemoveKnight
|
||||||
|
import org.prolibertate.games.game.catan.Resource
|
||||||
|
import org.prolibertate.games.game.catan.RollDice
|
||||||
|
import org.prolibertate.games.game.catan.SetupPlace
|
||||||
|
import org.prolibertate.games.game.catan.Track
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a player can ask to build by tapping the board. Wall and Knight exist
|
||||||
|
* only in Cities & Knights; the base game's build menu lists the first three.
|
||||||
|
*/
|
||||||
|
enum class BuildKind {
|
||||||
|
ROAD, SETTLEMENT, CITY, WALL, KNIGHT;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val BASE: List<BuildKind> = listOf(ROAD, SETTLEMENT, CITY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The parts of the board the player may tap right now. Everything else is inert.
|
||||||
|
* [knights] are crossings holding one of the player's own knights that can do
|
||||||
|
* something this turn: tapping one opens its menu.
|
||||||
|
*/
|
||||||
|
data class BoardTargets(
|
||||||
|
val vertices: Set<Int> = emptySet(),
|
||||||
|
val edges: Set<Int> = emptySet(),
|
||||||
|
val hexes: Set<Int> = emptySet(),
|
||||||
|
val knights: Set<Int> = emptySet(),
|
||||||
|
/** The player's other knights: tappable for their menu, but with nothing to do, so not ringed. */
|
||||||
|
val idleKnights: Set<Int> = emptySet(),
|
||||||
|
) {
|
||||||
|
val isEmpty: Boolean
|
||||||
|
get() = vertices.isEmpty() && edges.isEmpty() && hexes.isEmpty() && knights.isEmpty() && idleKnights.isEmpty()
|
||||||
|
|
||||||
|
/** Every crossing a tap can land on. */
|
||||||
|
val tappableVertices: Set<Int> get() = vertices + knights + idleKnights
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A progress card that is waiting for a tap on the board: [plays] are every play
|
||||||
|
* of it the engine accepts, and the player's tap picks one. [firstHex] is the
|
||||||
|
* first of the two hexes an Inventor swaps, once tapped.
|
||||||
|
*/
|
||||||
|
data class ArmedCard(val kind: ProgressType, val plays: List<PlayProgress>, val firstHex: Int? = null)
|
||||||
|
|
||||||
|
/** One progress card in the local player's hand, and the plays the engine allows for it. */
|
||||||
|
data class ProgressInHand(val card: ProgressCard, val plays: List<PlayProgress>) {
|
||||||
|
val playable: Boolean get() = plays.isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a tap on the board amounts to. */
|
||||||
|
sealed interface TapOutcome {
|
||||||
|
/** Nothing: the tap landed somewhere that is not a legal target. */
|
||||||
|
data object Ignored : TapOutcome
|
||||||
|
|
||||||
|
/** Send this move to the table. */
|
||||||
|
data class Submit(val move: CatanMove) : TapOutcome
|
||||||
|
|
||||||
|
/** Setup: remember this settlement; the road that goes with it is tapped next. */
|
||||||
|
data class PickSetupVertex(val vertex: Int) : TapOutcome
|
||||||
|
|
||||||
|
/** The robber's hex has more than one possible victim: ask which. */
|
||||||
|
data class PickRobberVictim(val hex: HexCoord, val victims: List<Int>) : TapOutcome
|
||||||
|
|
||||||
|
/** One of the player's own knights was tapped: open its menu. */
|
||||||
|
data class PickKnight(val vertex: Int) : TapOutcome
|
||||||
|
|
||||||
|
/** An Inventor's first hex was tapped; the second completes the swap. */
|
||||||
|
data class PickFirstHex(val hex: Int) : TapOutcome
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One development card in the local player's hand, and what can be done with it
|
||||||
|
* right now. [plays] is empty when it cannot be played this turn (bought this
|
||||||
|
* turn, a card has already been played, a victory-point card, or it is not the
|
||||||
|
* player's turn).
|
||||||
|
*/
|
||||||
|
data class DevCardInHand(val card: DevCard, val plays: List<CatanMove>) {
|
||||||
|
val playable: Boolean get() = plays.isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rules of *tapping*: given the game state, the moves the engine says are
|
||||||
|
* legal, and what the player has selected, what can be tapped and what a tap does.
|
||||||
|
*
|
||||||
|
* Pure functions with no Android and no Compose, so the whole interaction model is
|
||||||
|
* unit-tested (`CatanInteractionTest`). The screen holds the two bits of UI state
|
||||||
|
* ([BuildKind] and the chosen setup settlement) and passes them in; it contains no
|
||||||
|
* game rules of its own. **Anything that decides whether a tap is allowed belongs
|
||||||
|
* here, never in a composable** — the engine's [legal] list is the single source
|
||||||
|
* of truth, so the UI can never offer a move the rules would refuse.
|
||||||
|
*/
|
||||||
|
object CatanInteraction {
|
||||||
|
|
||||||
|
// --- Board ---------------------------------------------------------------
|
||||||
|
|
||||||
|
fun targets(
|
||||||
|
state: CatanState,
|
||||||
|
legal: List<CatanMove>,
|
||||||
|
building: BuildKind?,
|
||||||
|
setupVertex: Int?,
|
||||||
|
movingKnight: Int? = null,
|
||||||
|
armed: ArmedCard? = null,
|
||||||
|
/** The local seat, so its sleeping knights can be tapped for their menu too. */
|
||||||
|
localSeat: Int? = null,
|
||||||
|
): BoardTargets {
|
||||||
|
val setup = legal.filterIsInstance<SetupPlace>()
|
||||||
|
if (setup.isNotEmpty()) {
|
||||||
|
return if (setupVertex == null) {
|
||||||
|
BoardTargets(vertices = setup.map { it.vertex }.toSet())
|
||||||
|
} else {
|
||||||
|
BoardTargets(edges = setup.filter { it.vertex == setupVertex }.map { it.edge }.toSet())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val robber = legal.filterIsInstance<MoveRobber>()
|
||||||
|
if (robber.isNotEmpty()) {
|
||||||
|
return BoardTargets(hexes = robber.mapNotNull { CatanBoard.hexIndex[it.hex] }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cities & Knights: answers to a queued chore are taps on the board.
|
||||||
|
val chore = choreVertices(legal)
|
||||||
|
if (chore.isNotEmpty()) return BoardTargets(vertices = chore)
|
||||||
|
|
||||||
|
val roads = legal.filterIsInstance<BuildRoad>().map { it.edge }.toSet()
|
||||||
|
if (state.phase == CatanPhase.FREE_ROADS) return BoardTargets(edges = roads)
|
||||||
|
|
||||||
|
if (armed != null) return armedTargets(armed)
|
||||||
|
|
||||||
|
if (movingKnight != null) {
|
||||||
|
return BoardTargets(
|
||||||
|
vertices = legal.filterIsInstance<MoveKnight>().filter { it.from == movingKnight }.map { it.to }.toSet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (building) {
|
||||||
|
BuildKind.ROAD -> BoardTargets(edges = roads)
|
||||||
|
BuildKind.SETTLEMENT ->
|
||||||
|
BoardTargets(vertices = legal.filterIsInstance<BuildSettlement>().map { it.vertex }.toSet())
|
||||||
|
|
||||||
|
BuildKind.CITY ->
|
||||||
|
BoardTargets(vertices = legal.filterIsInstance<BuildCity>().map { it.vertex }.toSet())
|
||||||
|
|
||||||
|
BuildKind.WALL ->
|
||||||
|
BoardTargets(vertices = legal.filterIsInstance<BuildWall>().map { it.vertex }.toSet())
|
||||||
|
|
||||||
|
BuildKind.KNIGHT ->
|
||||||
|
BoardTargets(vertices = legal.filterIsInstance<HireKnight>().map { it.vertex }.toSet())
|
||||||
|
|
||||||
|
null -> {
|
||||||
|
val ready = knightsWithActions(legal)
|
||||||
|
val mine = if (localSeat != null && legal.isNotEmpty()) {
|
||||||
|
state.ck?.knights?.filter { it.owner == localSeat }?.map { it.vertex }?.toSet().orEmpty()
|
||||||
|
} else {
|
||||||
|
emptySet()
|
||||||
|
}
|
||||||
|
BoardTargets(knights = ready, idleKnights = mine - ready)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The crossings a queued chore is asking the player to tap, from the legal list. */
|
||||||
|
private fun choreVertices(legal: List<CatanMove>): Set<Int> = buildSet {
|
||||||
|
legal.forEach { move ->
|
||||||
|
when (move) {
|
||||||
|
is PillageCity -> add(move.vertex)
|
||||||
|
is PlaceMetropolis -> add(move.vertex)
|
||||||
|
is RelocateKnight -> add(move.to)
|
||||||
|
is RemoveKnight -> add(move.vertex)
|
||||||
|
is PlaceKnight -> add(move.vertex)
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A Smith's promotions are knights to tap, while the chore is open.
|
||||||
|
if (legal.any { it is EndSmith }) {
|
||||||
|
legal.filterIsInstance<PromoteKnight>().forEach { add(it.vertex) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun choreMove(legal: List<CatanMove>, vertex: Int): CatanMove? = legal.firstOrNull { move ->
|
||||||
|
when (move) {
|
||||||
|
is PillageCity -> move.vertex == vertex
|
||||||
|
is PlaceMetropolis -> move.vertex == vertex
|
||||||
|
is RelocateKnight -> move.to == vertex
|
||||||
|
is RemoveKnight -> move.vertex == vertex
|
||||||
|
is PlaceKnight -> move.vertex == vertex
|
||||||
|
is PromoteKnight -> legal.any { it is EndSmith } && move.vertex == vertex
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cities & Knights: knights ---------------------------------------------
|
||||||
|
|
||||||
|
/** Crossings of the player's knights that have an action available. */
|
||||||
|
fun knightsWithActions(legal: List<CatanMove>): Set<Int> = buildSet {
|
||||||
|
legal.forEach { move ->
|
||||||
|
when (move) {
|
||||||
|
is ActivateKnight -> add(move.vertex)
|
||||||
|
is MoveKnight -> add(move.from)
|
||||||
|
is ChaseRobber -> add(move.vertex)
|
||||||
|
is PromoteKnight -> if (legal.none { it is EndSmith }) add(move.vertex)
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the knight on [vertex] can do right now, in the order its menu lists them. */
|
||||||
|
fun knightActions(legal: List<CatanMove>, vertex: Int): List<CatanMove> =
|
||||||
|
legal.filter { move ->
|
||||||
|
when (move) {
|
||||||
|
is ActivateKnight -> move.vertex == vertex
|
||||||
|
is PromoteKnight -> move.vertex == vertex && legal.none { it is EndSmith }
|
||||||
|
is ChaseRobber -> move.vertex == vertex
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
} + listOfNotNull(legal.firstOrNull { it is MoveKnight && it.from == vertex })
|
||||||
|
|
||||||
|
fun canEndSmith(legal: List<CatanMove>): Boolean = legal.any { it is EndSmith }
|
||||||
|
|
||||||
|
// --- Cities & Knights: progress cards --------------------------------------
|
||||||
|
|
||||||
|
/** [seat]'s progress cards, each with the plays the engine currently allows. */
|
||||||
|
fun progressHand(state: CatanState, seat: Int, legal: List<CatanMove>): List<ProgressInHand> =
|
||||||
|
state.ck?.progressHands?.getOrNull(seat).orEmpty().map { card ->
|
||||||
|
ProgressInHand(card, legal.filterIsInstance<PlayProgress>().filter { it.card == card.id })
|
||||||
|
}.sortedWith(compareBy({ it.card.kind.track.ordinal }, { it.card.kind.ordinal }, { it.card.id }))
|
||||||
|
|
||||||
|
/** The cards that are played by tapping the board, not by choosing in a dialog. */
|
||||||
|
val BOARD_CARDS: Set<ProgressType> = setOf(
|
||||||
|
ProgressType.ENGINEER, ProgressType.MEDICINE, ProgressType.MERCHANT,
|
||||||
|
ProgressType.DIPLOMAT, ProgressType.INTRIGUE, ProgressType.INVENTOR,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun armedTargets(armed: ArmedCard): BoardTargets = when (armed.kind) {
|
||||||
|
ProgressType.ENGINEER, ProgressType.MEDICINE, ProgressType.INTRIGUE ->
|
||||||
|
BoardTargets(vertices = armed.plays.mapNotNull { it.vertex }.toSet())
|
||||||
|
|
||||||
|
ProgressType.DIPLOMAT -> BoardTargets(edges = armed.plays.mapNotNull { it.edge }.toSet())
|
||||||
|
|
||||||
|
ProgressType.MERCHANT -> BoardTargets(hexes = armed.plays.mapNotNull { it.hexA }.toSet())
|
||||||
|
|
||||||
|
ProgressType.INVENTOR -> {
|
||||||
|
val first = armed.firstHex
|
||||||
|
val all = armed.plays.flatMap { listOfNotNull(it.hexA, it.hexB) }.toSet()
|
||||||
|
if (first == null) {
|
||||||
|
BoardTargets(hexes = all)
|
||||||
|
} else {
|
||||||
|
BoardTargets(
|
||||||
|
hexes = armed.plays
|
||||||
|
.filter { it.hexA == first || it.hexB == first }
|
||||||
|
.flatMap { listOfNotNull(it.hexA, it.hexB) }
|
||||||
|
.filter { it != first }
|
||||||
|
.toSet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> BoardTargets()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The improvement tracks the player can buy a level of right now. */
|
||||||
|
fun improvable(legal: List<CatanMove>): Set<Track> =
|
||||||
|
legal.filterIsInstance<BuyImprovement>().map { it.track }.toSet()
|
||||||
|
|
||||||
|
// --- Cities & Knights: goods ----------------------------------------------
|
||||||
|
|
||||||
|
/** Bank trades on offer in Cities & Knights: what can be given, and for what. */
|
||||||
|
fun goodsBankTrades(legal: List<CatanMove>): Map<Good, List<Good>> =
|
||||||
|
legal.filterIsInstance<GoodsBankTrade>()
|
||||||
|
.groupBy({ it.give }, { it.get })
|
||||||
|
.mapValues { (_, gets) -> gets.sortedBy { it.ordinal } }
|
||||||
|
.toSortedMap(compareBy<Good> { it.ordinal })
|
||||||
|
|
||||||
|
fun goodsOfferAllowed(
|
||||||
|
state: CatanState,
|
||||||
|
seat: Int,
|
||||||
|
to: Int?,
|
||||||
|
give: List<Good>,
|
||||||
|
get: List<Good>,
|
||||||
|
): Boolean = give.isNotEmpty() && get.isNotEmpty() &&
|
||||||
|
CatanRules.isLegalMove(state, seat, OfferGoods(to, give, get))
|
||||||
|
|
||||||
|
/** Whether [seat] may answer the goods offer on the table with [give] for [get]. */
|
||||||
|
fun goodsCounterAllowed(state: CatanState, seat: Int, give: List<Good>, get: List<Good>): Boolean =
|
||||||
|
give.isNotEmpty() && get.isNotEmpty() &&
|
||||||
|
CatanRules.isLegalMove(state, seat, CounterGoods(give, get))
|
||||||
|
|
||||||
|
/** The goods counter-offers the offerer may take right now, in the order they came in. */
|
||||||
|
fun goodsCounterChoices(state: CatanState, legal: List<CatanMove>): List<GoodsCounter> {
|
||||||
|
val takeable = legal.filterIsInstance<AcceptCounter>().map { it.seat }.toSet()
|
||||||
|
return state.ck?.goodsOffer?.counters.orEmpty().filter { it.by in takeable }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The chore at the front of the queue, if the table is waiting on one. */
|
||||||
|
fun chore(state: CatanState): Chore? =
|
||||||
|
if (state.phase == CatanPhase.CHORE) state.ck?.chores?.firstOrNull() else null
|
||||||
|
|
||||||
|
fun onVertexTap(
|
||||||
|
vertex: Int,
|
||||||
|
legal: List<CatanMove>,
|
||||||
|
building: BuildKind?,
|
||||||
|
setupVertex: Int?,
|
||||||
|
movingKnight: Int? = null,
|
||||||
|
armed: ArmedCard? = null,
|
||||||
|
/** The local player's knights, which can be tapped for their menu even with nothing to do. */
|
||||||
|
ownKnights: Set<Int> = emptySet(),
|
||||||
|
): TapOutcome {
|
||||||
|
val setup = legal.filterIsInstance<SetupPlace>()
|
||||||
|
if (setup.isNotEmpty()) {
|
||||||
|
// Re-picking is allowed until the road is chosen, so a misplaced
|
||||||
|
// tap does not commit the settlement.
|
||||||
|
return if (setup.any { it.vertex == vertex }) {
|
||||||
|
TapOutcome.PickSetupVertex(vertex)
|
||||||
|
} else {
|
||||||
|
TapOutcome.Ignored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
choreMove(legal, vertex)?.let { return TapOutcome.Submit(it) }
|
||||||
|
|
||||||
|
if (armed != null) {
|
||||||
|
val play = armed.plays.firstOrNull { it.vertex == vertex } ?: return TapOutcome.Ignored
|
||||||
|
return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored
|
||||||
|
}
|
||||||
|
if (movingKnight != null) {
|
||||||
|
val move = MoveKnight(movingKnight, vertex)
|
||||||
|
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
val move: CatanMove = when (building) {
|
||||||
|
BuildKind.SETTLEMENT -> BuildSettlement(vertex)
|
||||||
|
BuildKind.CITY -> BuildCity(vertex)
|
||||||
|
BuildKind.WALL -> BuildWall(vertex)
|
||||||
|
BuildKind.KNIGHT -> HireKnight(vertex)
|
||||||
|
else -> return if (building == null && (vertex in knightsWithActions(legal) || vertex in ownKnights)) {
|
||||||
|
TapOutcome.PickKnight(vertex)
|
||||||
|
} else {
|
||||||
|
TapOutcome.Ignored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEdgeTap(
|
||||||
|
edge: Int,
|
||||||
|
legal: List<CatanMove>,
|
||||||
|
building: BuildKind?,
|
||||||
|
setupVertex: Int?,
|
||||||
|
armed: ArmedCard? = null,
|
||||||
|
): TapOutcome {
|
||||||
|
if (legal.any { it is SetupPlace }) {
|
||||||
|
if (setupVertex == null) return TapOutcome.Ignored
|
||||||
|
val move = SetupPlace(setupVertex, edge)
|
||||||
|
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
|
||||||
|
}
|
||||||
|
if (armed != null) {
|
||||||
|
val play = armed.plays.firstOrNull { it.edge == edge } ?: return TapOutcome.Ignored
|
||||||
|
return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored
|
||||||
|
}
|
||||||
|
// Free roads need no build mode; a paid road does.
|
||||||
|
val canBuild = building == BuildKind.ROAD || legal.any { it is EndFreeRoads }
|
||||||
|
if (!canBuild) return TapOutcome.Ignored
|
||||||
|
val move = BuildRoad(edge)
|
||||||
|
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onHexTap(hex: Int, legal: List<CatanMove>, armed: ArmedCard? = null): TapOutcome {
|
||||||
|
if (armed != null) return onArmedHexTap(hex, legal, armed)
|
||||||
|
val coord = CatanBoard.HEXES.getOrNull(hex) ?: return TapOutcome.Ignored
|
||||||
|
val moves = legal.filterIsInstance<MoveRobber>().filter { it.hex == coord }
|
||||||
|
return when {
|
||||||
|
moves.isEmpty() -> TapOutcome.Ignored
|
||||||
|
moves.size == 1 -> TapOutcome.Submit(moves.first())
|
||||||
|
else -> TapOutcome.PickRobberVictim(coord, moves.mapNotNull { it.victim })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onArmedHexTap(hex: Int, legal: List<CatanMove>, armed: ArmedCard): TapOutcome {
|
||||||
|
if (armed.kind == ProgressType.INVENTOR) {
|
||||||
|
val first = armed.firstHex ?: return if (hex in armedTargets(armed).hexes) {
|
||||||
|
TapOutcome.PickFirstHex(hex)
|
||||||
|
} else {
|
||||||
|
TapOutcome.Ignored
|
||||||
|
}
|
||||||
|
val play = armed.plays.firstOrNull {
|
||||||
|
(it.hexA == first && it.hexB == hex) || (it.hexA == hex && it.hexB == first)
|
||||||
|
} ?: return TapOutcome.Ignored
|
||||||
|
return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored
|
||||||
|
}
|
||||||
|
val play = armed.plays.firstOrNull { it.hexA == hex } ?: return TapOutcome.Ignored
|
||||||
|
return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Action bar ------------------------------------------------------------
|
||||||
|
|
||||||
|
fun canRoll(legal: List<CatanMove>): Boolean = legal.any { it is RollDice }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hexes that paid out for this turn's roll, so the board can light them.
|
||||||
|
* Empty until the turn's roll has happened (the dice left over from the last
|
||||||
|
* turn are not this turn's), on a seven, and for the hex the robber sits on.
|
||||||
|
*/
|
||||||
|
fun rolledHexes(state: CatanState): Set<Int> {
|
||||||
|
if (state.lastDice.size != 2) return emptySet()
|
||||||
|
if (state.phase == CatanPhase.AWAIT_ROLL || state.phase == CatanPhase.SETUP_FIRST ||
|
||||||
|
state.phase == CatanPhase.SETUP_SECOND
|
||||||
|
) {
|
||||||
|
return emptySet()
|
||||||
|
}
|
||||||
|
val total = state.lastDice.sum()
|
||||||
|
if (total == 7) return emptySet()
|
||||||
|
return (0 until CatanBoard.HEX_COUNT)
|
||||||
|
.filter { state.numbers[it] == total && it != state.robber }
|
||||||
|
.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun canEndTurn(legal: List<CatanMove>): Boolean = legal.any { it is EndTurn }
|
||||||
|
|
||||||
|
fun canEndFreeRoads(legal: List<CatanMove>): Boolean = legal.any { it is EndFreeRoads }
|
||||||
|
|
||||||
|
fun canBuyDevCard(legal: List<CatanMove>): Boolean = legal.any { it is BuyDevCard }
|
||||||
|
|
||||||
|
fun canBuild(legal: List<CatanMove>): Set<BuildKind> = buildSet {
|
||||||
|
if (legal.any { it is BuildRoad }) add(BuildKind.ROAD)
|
||||||
|
if (legal.any { it is BuildSettlement }) add(BuildKind.SETTLEMENT)
|
||||||
|
if (legal.any { it is BuildCity }) add(BuildKind.CITY)
|
||||||
|
if (legal.any { it is BuildWall }) add(BuildKind.WALL)
|
||||||
|
if (legal.any { it is HireKnight }) add(BuildKind.KNIGHT)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun tradeResponses(legal: List<CatanMove>): Pair<Boolean, Boolean> =
|
||||||
|
legal.any { it is AcceptTrade } to legal.any { it is DeclineTrade }
|
||||||
|
|
||||||
|
// --- Development cards -----------------------------------------------------
|
||||||
|
|
||||||
|
/** [seat]'s development cards, each with the plays the engine currently allows. */
|
||||||
|
fun devHand(state: CatanState, seat: Int, legal: List<CatanMove>): List<DevCardInHand> =
|
||||||
|
state.devHands.getOrNull(seat).orEmpty().map { card ->
|
||||||
|
DevCardInHand(card, legal.filter { playOf(it) == card.id })
|
||||||
|
}.sortedWith(compareBy({ it.card.type.ordinal }, { it.card.id }))
|
||||||
|
|
||||||
|
private fun playOf(move: CatanMove): Int? = when (move) {
|
||||||
|
is PlayKnight -> move.card
|
||||||
|
is PlayRoadBuilding -> move.card
|
||||||
|
is PlayMonopoly -> move.card
|
||||||
|
is PlayYearOfPlenty -> move.card
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Monopoly moves for one card, by the resource each would name. */
|
||||||
|
fun monopolyChoices(plays: List<CatanMove>): Map<Resource, PlayMonopoly> =
|
||||||
|
plays.filterIsInstance<PlayMonopoly>().associateBy { it.resource }
|
||||||
|
|
||||||
|
/** The Year of Plenty move that takes exactly [take], if the engine allows it. */
|
||||||
|
fun plentyMove(plays: List<CatanMove>, take: List<Resource>): PlayYearOfPlenty? {
|
||||||
|
val want = take.groupingBy { it }.eachCount()
|
||||||
|
return plays.filterIsInstance<PlayYearOfPlenty>().firstOrNull {
|
||||||
|
it.take.groupingBy { r -> r }.eachCount() == want
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resources a Year of Plenty can still be asked for, given what is already
|
||||||
|
* [picked]: those that complete some take the engine allows. Empty once the
|
||||||
|
* two cards are chosen.
|
||||||
|
*/
|
||||||
|
fun plentyAvailable(plays: List<CatanMove>, picked: List<Resource>): Set<Resource> {
|
||||||
|
val chosen = picked.groupingBy { it }.eachCount()
|
||||||
|
val available = mutableSetOf<Resource>()
|
||||||
|
for (play in plays.filterIsInstance<PlayYearOfPlenty>()) {
|
||||||
|
if (!chosen.all { (resource, n) -> play.take.count { it == resource } >= n }) continue
|
||||||
|
val remaining = play.take.toMutableList()
|
||||||
|
picked.forEach { remaining.remove(it) }
|
||||||
|
available += remaining
|
||||||
|
}
|
||||||
|
return available
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Discarding -----------------------------------------------------------
|
||||||
|
|
||||||
|
/** How many cards a player holding [held] must discard on a seven. */
|
||||||
|
fun discardQuota(held: Int): Int = held / 2
|
||||||
|
|
||||||
|
/** The discard move that gives up exactly [selection], if the engine allows it. */
|
||||||
|
fun discardMove(legal: List<CatanMove>, selection: List<Resource>): Discard? {
|
||||||
|
val want = selection.groupingBy { it }.eachCount()
|
||||||
|
return legal.filterIsInstance<Discard>().firstOrNull {
|
||||||
|
it.cards.groupingBy { r -> r }.eachCount() == want
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Trading ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Bank trades on offer: what can be given, and for what. */
|
||||||
|
fun bankTrades(legal: List<CatanMove>): Map<Resource, List<Resource>> =
|
||||||
|
legal.filterIsInstance<MaritimeTrade>()
|
||||||
|
.groupBy({ it.give }, { it.get })
|
||||||
|
.mapValues { (_, gets) -> gets.sortedBy { it.ordinal } }
|
||||||
|
.toSortedMap(compareBy<Resource> { it.ordinal })
|
||||||
|
|
||||||
|
/** What it costs [seat] to give one [resource] to the bank: 4, 3 or 2 cards. */
|
||||||
|
fun bankRatio(state: CatanState, seat: Int, resource: Resource): Int =
|
||||||
|
CatanRules.bestPortRatio(state, seat, resource)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [seat] can offer [give] for [get] to [to] right now. Uses the
|
||||||
|
* engine's own structural check, since offers are not in the enumerated
|
||||||
|
* move list.
|
||||||
|
*/
|
||||||
|
fun offerAllowed(
|
||||||
|
state: CatanState,
|
||||||
|
seat: Int,
|
||||||
|
to: Int?,
|
||||||
|
give: List<Resource>,
|
||||||
|
get: List<Resource>,
|
||||||
|
): Boolean = give.isNotEmpty() && get.isNotEmpty() &&
|
||||||
|
CatanRules.isLegalMove(state, seat, OfferTrade(to, give, get))
|
||||||
|
|
||||||
|
/** Whether [seat] may answer the offer on the table with [give] for [get]. */
|
||||||
|
fun counterAllowed(state: CatanState, seat: Int, give: List<Resource>, get: List<Resource>): Boolean =
|
||||||
|
give.isNotEmpty() && get.isNotEmpty() &&
|
||||||
|
CatanRules.isLegalMove(state, seat, CounterTrade(give, get))
|
||||||
|
|
||||||
|
/** The counter-offers the offerer may take right now, in the order they came in. */
|
||||||
|
fun counterChoices(state: CatanState, legal: List<CatanMove>): List<TradeCounter> {
|
||||||
|
val takeable = legal.filterIsInstance<AcceptCounter>().map { it.seat }.toSet()
|
||||||
|
return state.pendingTrade?.counters.orEmpty().filter { it.by in takeable }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,634 @@
|
|||||||
|
package org.prolibertate.games.ui.game.catan
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.defaultMinSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import org.prolibertate.games.game.catan.BuildingType
|
||||||
|
import org.prolibertate.games.game.catan.CatanCk
|
||||||
|
import org.prolibertate.games.game.catan.CatanPhase
|
||||||
|
import org.prolibertate.games.game.catan.CatanRules
|
||||||
|
import org.prolibertate.games.game.catan.CatanState
|
||||||
|
import org.prolibertate.games.game.catan.EventFace
|
||||||
|
import org.prolibertate.games.game.catan.Good
|
||||||
|
import org.prolibertate.games.game.catan.Resource
|
||||||
|
import org.prolibertate.games.game.catan.Track
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Stateless building blocks for the Katan screen.
|
||||||
|
*
|
||||||
|
* Every composable here takes plain values and reports events through lambdas;
|
||||||
|
* none of them holds game state or touches the controller, so each can be
|
||||||
|
* restyled, re-laid-out or replaced on its own. Colours and proportions come
|
||||||
|
* from [CatanStyle] and wording from [CatanText].
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Resource tile
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One resource as a coloured tile with its count. The hand, the discard picker
|
||||||
|
* and the trade pickers all use this, so a resource looks the same everywhere.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ResourceTile(
|
||||||
|
resource: Resource,
|
||||||
|
count: Int,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
selected: Boolean = false,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
style: CatanStyle = LocalCatanStyle.current,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val shape = RoundedCornerShape(10.dp)
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.defaultMinSize(minWidth = 56.dp)
|
||||||
|
.clip(shape)
|
||||||
|
.background(style.resource.getValue(resource).copy(alpha = if (enabled) 1f else 0.4f))
|
||||||
|
.border(
|
||||||
|
width = if (selected) 3.dp else 1.dp,
|
||||||
|
color = if (selected) Color.White else Color(0x33000000),
|
||||||
|
shape = shape,
|
||||||
|
)
|
||||||
|
.then(if (onClick != null && enabled) Modifier.clickable { onClick() } else Modifier)
|
||||||
|
.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = count.toString(),
|
||||||
|
color = style.resourceInk,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = CatanText.resource(resource),
|
||||||
|
color = style.resourceInk,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One card type — a resource or a commodity — as a coloured tile with its count.
|
||||||
|
* Cities & Knights hands, discards and trades use this so commodities look the
|
||||||
|
* same everywhere.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GoodTile(
|
||||||
|
good: Good,
|
||||||
|
count: Int,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
selected: Boolean = false,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
style: CatanStyle = LocalCatanStyle.current,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val resource = good.resource
|
||||||
|
if (resource != null) {
|
||||||
|
ResourceTile(resource, count, modifier, selected, enabled, style, onClick)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val commodity = good.commodity!!
|
||||||
|
val shape = RoundedCornerShape(10.dp)
|
||||||
|
val ink = style.commodityInk.getValue(commodity)
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.defaultMinSize(minWidth = 56.dp)
|
||||||
|
.clip(shape)
|
||||||
|
.background(style.commodity.getValue(commodity).copy(alpha = if (enabled) 1f else 0.4f))
|
||||||
|
.border(
|
||||||
|
width = if (selected) 3.dp else 1.dp,
|
||||||
|
color = if (selected) Color.White else Color(0x33000000),
|
||||||
|
shape = shape,
|
||||||
|
)
|
||||||
|
.then(if (onClick != null && enabled) Modifier.clickable { onClick() } else Modifier)
|
||||||
|
.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(text = count.toString(), color = ink, fontWeight = FontWeight.Bold, fontSize = 20.sp)
|
||||||
|
Text(text = CatanText.good(good), color = ink, fontSize = 11.sp, maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The local player's cards: one tile per resource, and in Cities & Knights one
|
||||||
|
* per commodity as well, in a row that wraps.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun CatanHand(
|
||||||
|
state: CatanState,
|
||||||
|
seat: Int,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
FlowRow(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
if (state.isCk) {
|
||||||
|
val held = CatanCk.goodsOf(state, seat)
|
||||||
|
Good.entries.forEach { good -> GoodTile(good, held[good] ?: 0) }
|
||||||
|
} else {
|
||||||
|
Resource.entries.forEach { resource ->
|
||||||
|
ResourceTile(resource, state.resources[seat][resource] ?: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Players
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One player's public standing: colour, name, points, how many cards they hold
|
||||||
|
* (not which), and — in the roomier layout — development cards, knights and
|
||||||
|
* their longest road.
|
||||||
|
*
|
||||||
|
* **Fixed height.** Every line is always present (blank when there is nothing to
|
||||||
|
* say) and never wraps, so the card is the same height whatever the game is
|
||||||
|
* doing. The board is given whatever room is left, so anything that made this
|
||||||
|
* card taller or shorter would rescale the island under the player's thumb.
|
||||||
|
*
|
||||||
|
* [points] is passed in rather than computed here because the local player's
|
||||||
|
* own count includes hidden victory-point cards and everyone else's does not;
|
||||||
|
* the screen decides which. [compact] is the narrow portrait strip (four cards
|
||||||
|
* across a phone); the default has room for the detail lines.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CatanPlayerCard(
|
||||||
|
state: CatanState,
|
||||||
|
seat: Int,
|
||||||
|
seats: CatanSeats,
|
||||||
|
points: Int,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
style: CatanStyle = LocalCatanStyle.current,
|
||||||
|
compact: Boolean = false,
|
||||||
|
) {
|
||||||
|
val onTurn = state.turn == seat && state.phase != CatanPhase.GAME_OVER
|
||||||
|
val shape = RoundedCornerShape(12.dp)
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.clip(shape)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.border(
|
||||||
|
width = if (onTurn) 3.dp else 1.dp,
|
||||||
|
color = if (onTurn) MaterialTheme.colorScheme.primary else Color(0x22000000),
|
||||||
|
shape = shape,
|
||||||
|
)
|
||||||
|
.padding(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(14.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(style.seats[seat % style.seats.size])
|
||||||
|
.border(1.dp, style.pieceOutline, CircleShape),
|
||||||
|
)
|
||||||
|
CardLine(seats.label(seat), MaterialTheme.typography.labelLarge, FontWeight.Bold)
|
||||||
|
}
|
||||||
|
CardLine("$points VP", MaterialTheme.typography.titleMedium, FontWeight.Bold)
|
||||||
|
val ck = state.ck
|
||||||
|
if (ck != null) {
|
||||||
|
val cards = CatanCk.goodsCount(state, seat)
|
||||||
|
val strength = CatanCk.strength(state, seat)
|
||||||
|
if (compact) {
|
||||||
|
CardLine("$cards cards · ⚔$strength", MaterialTheme.typography.bodySmall)
|
||||||
|
} else {
|
||||||
|
CardLine("$cards cards · ${ck.progressCounts[seat]} progress", MaterialTheme.typography.bodySmall)
|
||||||
|
CardLine(
|
||||||
|
"⚔ ${CatanCk.knightsOf(state, seat).size} · str $strength · " +
|
||||||
|
"Road ${CatanRules.longestRoadFor(state, seat)}",
|
||||||
|
MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Trade, politics, science: the three improvement levels.
|
||||||
|
CardLine(
|
||||||
|
Track.entries.joinToString(" ") { "${it.name.first()}${CatanCk.improvement(state, seat, it)}" },
|
||||||
|
MaterialTheme.typography.bodySmall,
|
||||||
|
color = Color.Unspecified,
|
||||||
|
)
|
||||||
|
val ckAwards = buildList {
|
||||||
|
if (state.longestRoadOwner == seat) add(if (compact) "Road" else "Longest road")
|
||||||
|
ck.metropolises.forEach { (track, at) ->
|
||||||
|
if (at.seat == seat) add(if (compact) track.name.first().toString() else "${CatanText.track(track)} metropolis")
|
||||||
|
}
|
||||||
|
if (ck.merchant?.seat == seat) add("Merchant")
|
||||||
|
}
|
||||||
|
CardLine(
|
||||||
|
text = ckAwards.joinToString(" · ").ifEmpty { BLANK },
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
weight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
if (compact) {
|
||||||
|
CardLine("${state.resourceCount(seat)} cards", MaterialTheme.typography.bodySmall)
|
||||||
|
} else {
|
||||||
|
CardLine(
|
||||||
|
"${state.resourceCount(seat)} cards · ${state.devHandCounts[seat]} dev",
|
||||||
|
MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
CardLine(
|
||||||
|
"Knights ${state.knightsPlayed[seat]} · Road ${CatanRules.longestRoadFor(state, seat)}",
|
||||||
|
MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val awards = buildList {
|
||||||
|
if (state.longestRoadOwner == seat) add(if (compact) "Road" else "Longest road")
|
||||||
|
if (state.largestArmyOwner == seat) add(if (compact) "Army" else "Largest army")
|
||||||
|
}
|
||||||
|
// Always drawn, so the card does not grow when somebody takes an award.
|
||||||
|
CardLine(
|
||||||
|
text = awards.joinToString(" · ").ifEmpty { BLANK },
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
weight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A non-breaking space: a line of text with nothing in it, still one line tall. */
|
||||||
|
private const val BLANK = "\u00A0"
|
||||||
|
|
||||||
|
/** One line of a player card: never wraps, so the card's height cannot change. */
|
||||||
|
@Composable
|
||||||
|
private fun CardLine(
|
||||||
|
text: String,
|
||||||
|
style: TextStyle,
|
||||||
|
weight: FontWeight? = null,
|
||||||
|
color: Color = Color.Unspecified,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = style,
|
||||||
|
fontWeight = weight,
|
||||||
|
color = color,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Dice
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two dice as last rolled, with their total. Blank until the first roll. In
|
||||||
|
* Cities & Knights the dice are yellow and red (the red one decides progress
|
||||||
|
* cards) and a third, the event die, shows a ship or a coloured city gate.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CatanDice(
|
||||||
|
dice: List<Int>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
ck: Boolean = false,
|
||||||
|
event: EventFace? = null,
|
||||||
|
style: CatanStyle = LocalCatanStyle.current,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
val shown = if (dice.size == 2) dice else listOf(null, null)
|
||||||
|
shown.forEachIndexed { index, value ->
|
||||||
|
val face = when {
|
||||||
|
!ck -> Color.White
|
||||||
|
index == 0 -> Color(0xFFFFF176)
|
||||||
|
else -> Color(0xFFEF9A9A)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(face)
|
||||||
|
.border(1.dp, Color(0x55000000), RoundedCornerShape(8.dp)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = value?.toString() ?: "–",
|
||||||
|
color = Color(0xFF1C1B1F),
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ck) {
|
||||||
|
val gate = when (event) {
|
||||||
|
EventFace.TRADE -> style.track.getValue(Track.TRADE)
|
||||||
|
EventFace.POLITICS -> style.track.getValue(Track.POLITICS)
|
||||||
|
EventFace.SCIENCE -> style.track.getValue(Track.SCIENCE)
|
||||||
|
EventFace.SHIP -> style.barbarian
|
||||||
|
null -> Color(0x33000000)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(width = 52.dp, height = 36.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(gate)
|
||||||
|
.border(1.dp, Color(0x55000000), RoundedCornerShape(8.dp)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (event == EventFace.SHIP) "⛵ Ship" else CatanText.event(event),
|
||||||
|
color = if (event == EventFace.TRADE) Color(0xFF1C1B1F) else Color.White,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Always drawn ("= –" before the first roll) so the row never changes width.
|
||||||
|
Text(
|
||||||
|
text = if (dice.size == 2) "= ${dice.sum()}" else "= –",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cities & Knights: how close the barbarians are, and how the cities on the
|
||||||
|
* island compare with the knights standing ready. **Fixed height**, like every
|
||||||
|
* other strip around the board.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CatanBarbarians(state: CatanState, modifier: Modifier = Modifier, style: CatanStyle = LocalCatanStyle.current) {
|
||||||
|
val ck = state.ck ?: return
|
||||||
|
val cities = state.buildings.count { it?.type == BuildingType.CITY }
|
||||||
|
val defence = (0 until state.playerCount).sumOf { CatanCk.strength(state, it) }
|
||||||
|
Row(
|
||||||
|
modifier = modifier.fillMaxWidth().height(24.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
Text("⛵", fontSize = 14.sp)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.weight(1f)) {
|
||||||
|
// The ship starts seven steps out and lands when the last box fills.
|
||||||
|
val sailed = BARBARIAN_STEPS_DISPLAY - ck.barbarians
|
||||||
|
for (i in 0 until BARBARIAN_STEPS_DISPLAY) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(10.dp)
|
||||||
|
.clip(RoundedCornerShape(3.dp))
|
||||||
|
.background(if (i < sailed) style.barbarian else Color(0x33000000)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val verdict = if (defence >= cities) "holds" else "falls"
|
||||||
|
Text(
|
||||||
|
text = "${CatanText.barbarianSteps(ck.barbarians)} · knights $defence v. $cities cities ($verdict)",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Steps shown on the barbarian strip: the engine's seven. */
|
||||||
|
private const val BARBARIAN_STEPS_DISPLAY = org.prolibertate.games.game.catan.BARBARIAN_STEPS
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Actions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** What the action bar offers right now. Built by the screen from [CatanInteraction]. */
|
||||||
|
data class CatanActions(
|
||||||
|
val canRoll: Boolean = false,
|
||||||
|
val canBuild: Set<BuildKind> = emptySet(),
|
||||||
|
val building: BuildKind? = null,
|
||||||
|
val canBuyCard: Boolean = false,
|
||||||
|
val canPlayCard: Boolean = false,
|
||||||
|
val canTrade: Boolean = false,
|
||||||
|
val canEndTurn: Boolean = false,
|
||||||
|
val canEndFreeRoads: Boolean = false,
|
||||||
|
/** A setup settlement is chosen and a different one may still be picked. */
|
||||||
|
val canRepickSetup: Boolean = false,
|
||||||
|
/** Which kinds the Build menu lists: three in the base game, five in Cities & Knights. */
|
||||||
|
val buildKinds: List<BuildKind> = BuildKind.BASE,
|
||||||
|
/** Cities & Knights: the table plays the expansion, so the bar shows its buttons instead. */
|
||||||
|
val ck: Boolean = false,
|
||||||
|
val canImprove: Boolean = false,
|
||||||
|
val canPlayProgress: Boolean = false,
|
||||||
|
val progressCount: Int = 0,
|
||||||
|
val canEndSmith: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The buttons for whatever the player can do now. Pure display: every press is a lambda.
|
||||||
|
*
|
||||||
|
* **The bar never changes size.** The top row holds the one "primary" action
|
||||||
|
* (roll, end turn, done with roads, cancel) and keeps its height when it is
|
||||||
|
* empty; the second row is always all six buttons, greyed out when they do not
|
||||||
|
* apply. Buttons appearing and disappearing used to resize this area and, with
|
||||||
|
* it, the board above — so nothing here may be conditionally *present*, only
|
||||||
|
* conditionally *enabled*.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun CatanActionBar(
|
||||||
|
actions: CatanActions,
|
||||||
|
onRoll: () -> Unit,
|
||||||
|
onBuild: (BuildKind?) -> Unit,
|
||||||
|
onBuyCard: () -> Unit,
|
||||||
|
onPlayCard: () -> Unit,
|
||||||
|
onTrade: () -> Unit,
|
||||||
|
onEndTurn: () -> Unit,
|
||||||
|
onEndFreeRoads: () -> Unit,
|
||||||
|
onRepickSetup: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
/** Small screens: fold Road / Settlement / City into one "Build" button with a menu. */
|
||||||
|
compact: Boolean = false,
|
||||||
|
onImprove: () -> Unit = {},
|
||||||
|
onProgress: () -> Unit = {},
|
||||||
|
onEndSmith: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().height(PRIMARY_ROW_HEIGHT),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
if (actions.canRoll) Button(onClick = onRoll) { Text(CatanText.ROLL) }
|
||||||
|
if (actions.canRepickSetup) OutlinedButton(onClick = onRepickSetup) { Text(CatanText.CANCEL) }
|
||||||
|
if (actions.canEndFreeRoads) Button(onClick = onEndFreeRoads) { Text(CatanText.STOP_ROADS) }
|
||||||
|
if (actions.canEndSmith) Button(onClick = onEndSmith) { Text(CatanText.STOP_SMITH) }
|
||||||
|
if (actions.canEndTurn) Button(onClick = onEndTurn) { Text(CatanText.END_TURN) }
|
||||||
|
}
|
||||||
|
|
||||||
|
FlowRow(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
if (compact || actions.ck) {
|
||||||
|
BuildMenuButton(actions, onBuild)
|
||||||
|
} else {
|
||||||
|
BuildButton(BuildKind.ROAD, CatanText.ROAD, CatanText.ROAD_COST, actions, onBuild)
|
||||||
|
BuildButton(BuildKind.SETTLEMENT, CatanText.SETTLEMENT, CatanText.SETTLEMENT_COST, actions, onBuild)
|
||||||
|
BuildButton(BuildKind.CITY, CatanText.CITY, CatanText.CITY_COST, actions, onBuild)
|
||||||
|
}
|
||||||
|
if (actions.ck) {
|
||||||
|
OutlinedButton(onClick = onImprove, enabled = actions.canImprove) {
|
||||||
|
LabelWithCost(CatanText.IMPROVE, CatanText.NO_COST)
|
||||||
|
}
|
||||||
|
OutlinedButton(onClick = onProgress, enabled = actions.canPlayProgress) {
|
||||||
|
LabelWithCost(CatanText.CARDS_BUTTON, "${actions.progressCount} held")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
OutlinedButton(onClick = onBuyCard, enabled = actions.canBuyCard) {
|
||||||
|
LabelWithCost(CatanText.BUY_CARD, CatanText.DEV_COST)
|
||||||
|
}
|
||||||
|
OutlinedButton(onClick = onPlayCard, enabled = actions.canPlayCard) {
|
||||||
|
LabelWithCost(CatanText.PLAY_CARD, CatanText.NO_COST)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutlinedButton(onClick = onTrade, enabled = actions.canTrade) {
|
||||||
|
LabelWithCost(CatanText.TRADE, CatanText.NO_COST)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Room for one button, held whether or not there is one to show. */
|
||||||
|
private val PRIMARY_ROW_HEIGHT = 48.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BuildButton(
|
||||||
|
kind: BuildKind,
|
||||||
|
label: String,
|
||||||
|
cost: String,
|
||||||
|
actions: CatanActions,
|
||||||
|
onBuild: (BuildKind?) -> Unit,
|
||||||
|
) {
|
||||||
|
val enabled = kind in actions.canBuild
|
||||||
|
val active = actions.building == kind
|
||||||
|
val onClick = { onBuild(if (active) null else kind) }
|
||||||
|
if (active) {
|
||||||
|
FilledTonalButton(onClick = onClick, enabled = enabled) { LabelWithCost(label, cost) }
|
||||||
|
} else {
|
||||||
|
OutlinedButton(onClick = onClick, enabled = enabled) { LabelWithCost(label, cost) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The small-screen stand-in for the three build buttons: one "Build" button
|
||||||
|
* whose menu lists Road, Settlement and City with their costs. While a build
|
||||||
|
* mode is armed the button shows which, and choosing that entry again cancels it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun BuildMenuButton(actions: CatanActions, onBuild: (BuildKind?) -> Unit) {
|
||||||
|
var open by remember { mutableStateOf(false) }
|
||||||
|
val armed = actions.building
|
||||||
|
val anyEnabled = actions.canBuild.isNotEmpty() || armed != null
|
||||||
|
val label: @Composable () -> Unit = {
|
||||||
|
LabelWithCost(CatanText.BUILD, armed?.let { buildLabel(it) } ?: CatanText.NO_COST)
|
||||||
|
}
|
||||||
|
Box {
|
||||||
|
if (armed != null) {
|
||||||
|
FilledTonalButton(onClick = { open = true }, enabled = anyEnabled) { label() }
|
||||||
|
} else {
|
||||||
|
OutlinedButton(onClick = { open = true }, enabled = anyEnabled) { label() }
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||||
|
for (kind in actions.buildKinds) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { LabelWithCost(buildLabel(kind), buildCost(kind)) },
|
||||||
|
enabled = kind in actions.canBuild,
|
||||||
|
onClick = {
|
||||||
|
open = false
|
||||||
|
onBuild(if (armed == kind) null else kind)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildLabel(kind: BuildKind): String = when (kind) {
|
||||||
|
BuildKind.ROAD -> CatanText.ROAD
|
||||||
|
BuildKind.SETTLEMENT -> CatanText.SETTLEMENT
|
||||||
|
BuildKind.CITY -> CatanText.CITY
|
||||||
|
BuildKind.WALL -> CatanText.WALL
|
||||||
|
BuildKind.KNIGHT -> CatanText.KNIGHT
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildCost(kind: BuildKind): String = when (kind) {
|
||||||
|
BuildKind.ROAD -> CatanText.ROAD_COST
|
||||||
|
BuildKind.SETTLEMENT -> CatanText.SETTLEMENT_COST
|
||||||
|
BuildKind.CITY -> CatanText.CITY_COST
|
||||||
|
BuildKind.WALL -> CatanText.WALL_COST
|
||||||
|
BuildKind.KNIGHT -> CatanText.KNIGHT_COST
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LabelWithCost(label: String, cost: String) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(label)
|
||||||
|
Text(cost, fontSize = 9.sp, maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Log
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** The most recent happenings, newest last. Scrolls. */
|
||||||
|
@Composable
|
||||||
|
fun CatanLog(state: CatanState, modifier: Modifier = Modifier) {
|
||||||
|
if (state.log.isEmpty()) {
|
||||||
|
Text(CatanText.NO_LOG, style = MaterialTheme.typography.bodySmall, modifier = modifier)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Column(modifier = modifier.verticalScroll(rememberScrollState())) {
|
||||||
|
state.log.takeLast(40).forEach {
|
||||||
|
Text(text = it, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package org.prolibertate.games.ui.game.catan
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.prolibertate.games.game.GameCatalog
|
||||||
|
import org.prolibertate.games.game.catan.CatanAi
|
||||||
|
import org.prolibertate.games.game.catan.CatanOptions
|
||||||
|
import org.prolibertate.games.game.catan.CatanPhase
|
||||||
|
import org.prolibertate.games.game.catan.CatanRules
|
||||||
|
import org.prolibertate.games.game.catan.CatanState
|
||||||
|
import org.prolibertate.games.game.engine.PlayerKind
|
||||||
|
import org.prolibertate.games.game.engine.PlayerSlot
|
||||||
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Android Studio previews for the Katan UI. They exist so the look can be
|
||||||
|
* iterated on without starting a match: open this file, open the Preview pane,
|
||||||
|
* and edit CatanStyle / CatanText / the panels. Each preview draws a real
|
||||||
|
* position (the computer plays itself for a while), not a mock.
|
||||||
|
*
|
||||||
|
* Nothing here ships in a behaviour sense: previews are only compiled for tooling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
private val previewJson = Json { encodeDefaults = true }
|
||||||
|
|
||||||
|
/** A real game, played forward by the computer for [moves] moves, for previewing. */
|
||||||
|
private fun sampleState(players: Int = 4, moves: Int = 90, seed: Long = 11L): CatanState {
|
||||||
|
val config = TableConfig(
|
||||||
|
gameId = GameCatalog.CATAN,
|
||||||
|
seats = (0 until players).map { PlayerSlot(it, "P$it", PlayerKind.AI, it) },
|
||||||
|
optionsJson = previewJson.encodeToString(CatanOptions(playerCount = players)),
|
||||||
|
seed = seed,
|
||||||
|
)
|
||||||
|
val ai = CatanAi()
|
||||||
|
var state = CatanRules.initialState(config)
|
||||||
|
repeat(moves) {
|
||||||
|
if (state.phase == CatanPhase.GAME_OVER) return state
|
||||||
|
val seat = CatanRules.currentSeat(state) ?: return state
|
||||||
|
val legal = CatanRules.legalMoves(state, seat)
|
||||||
|
if (legal.isEmpty()) return state
|
||||||
|
state = CatanRules.applyMove(state, seat, ai.chooseMove(state, seat, legal))
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true, widthDp = 360, heightDp = 360)
|
||||||
|
@Composable
|
||||||
|
private fun BoardPreview() {
|
||||||
|
val state = remember { sampleState() }
|
||||||
|
CompositionLocalProvider(LocalCatanStyle provides catanStyle(dark = false)) {
|
||||||
|
CatanBoardView(
|
||||||
|
state = state,
|
||||||
|
targets = BoardTargets(),
|
||||||
|
selectedVertex = null,
|
||||||
|
onVertexTap = {},
|
||||||
|
onEdgeTap = {},
|
||||||
|
onHexTap = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true, widthDp = 360, heightDp = 360)
|
||||||
|
@Composable
|
||||||
|
private fun BoardDarkPreview() {
|
||||||
|
val state = remember { sampleState() }
|
||||||
|
CompositionLocalProvider(LocalCatanStyle provides catanStyle(dark = true)) {
|
||||||
|
CatanBoardView(
|
||||||
|
state = state,
|
||||||
|
targets = BoardTargets(),
|
||||||
|
selectedVertex = null,
|
||||||
|
onVertexTap = {},
|
||||||
|
onEdgeTap = {},
|
||||||
|
onHexTap = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true, widthDp = 360, heightDp = 360)
|
||||||
|
@Composable
|
||||||
|
private fun BoardWithTargetsPreview() {
|
||||||
|
val state = remember { sampleState(moves = 6) }
|
||||||
|
val legal = remember(state) { CatanRules.currentSeat(state)?.let { CatanRules.legalMoves(state, it) }.orEmpty() }
|
||||||
|
CompositionLocalProvider(LocalCatanStyle provides catanStyle(dark = false)) {
|
||||||
|
CatanBoardView(
|
||||||
|
state = state,
|
||||||
|
targets = CatanInteraction.targets(state, legal, building = null, setupVertex = null),
|
||||||
|
selectedVertex = null,
|
||||||
|
onVertexTap = {},
|
||||||
|
onEdgeTap = {},
|
||||||
|
onHexTap = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true, widthDp = 360, heightDp = 560)
|
||||||
|
@Composable
|
||||||
|
private fun PanelsPreview() {
|
||||||
|
val state = remember { sampleState() }
|
||||||
|
val seats = CatanSeats(me = 0, names = listOf("You", "Ann", "Bo", "Cy"))
|
||||||
|
val points = remember(state) { CatanRules.publicVictoryPoints(state) }
|
||||||
|
Column(Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
(0 until state.playerCount).forEach { seat ->
|
||||||
|
CatanPlayerCard(state, seat, seats, points[seat], Modifier.weight(1f), compact = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CatanHand(state, seat = 0)
|
||||||
|
CatanDice(state.lastDice)
|
||||||
|
CatanActionBar(
|
||||||
|
actions = CatanActions(
|
||||||
|
canBuild = setOf(BuildKind.ROAD, BuildKind.CITY),
|
||||||
|
building = BuildKind.ROAD,
|
||||||
|
canBuyCard = true,
|
||||||
|
canTrade = true,
|
||||||
|
canEndTurn = true,
|
||||||
|
),
|
||||||
|
onRoll = {}, onBuild = {}, onBuyCard = {}, onPlayCard = {}, onTrade = {},
|
||||||
|
onEndTurn = {}, onEndFreeRoads = {}, onRepickSetup = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,614 @@
|
|||||||
|
package org.prolibertate.games.ui.game.catan
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.prolibertate.games.game.catan.AcceptTrade
|
||||||
|
import org.prolibertate.games.game.catan.AqueductChore
|
||||||
|
import org.prolibertate.games.game.catan.BuyDevCard
|
||||||
|
import org.prolibertate.games.game.catan.CatanCk
|
||||||
|
import org.prolibertate.games.game.catan.CatanMove
|
||||||
|
import org.prolibertate.games.game.catan.CatanPhase
|
||||||
|
import org.prolibertate.games.game.catan.CatanRules
|
||||||
|
import org.prolibertate.games.game.catan.CatanState
|
||||||
|
import org.prolibertate.games.game.catan.DeclineTrade
|
||||||
|
import org.prolibertate.games.game.catan.DiscardChore
|
||||||
|
import org.prolibertate.games.game.catan.DiscardGoods
|
||||||
|
import org.prolibertate.games.game.catan.DropProgress
|
||||||
|
import org.prolibertate.games.game.catan.DropProgressChore
|
||||||
|
import org.prolibertate.games.game.catan.DismissCounters
|
||||||
|
import org.prolibertate.games.game.catan.EndFreeRoads
|
||||||
|
import org.prolibertate.games.game.catan.EndSmith
|
||||||
|
import org.prolibertate.games.game.catan.EndTurn
|
||||||
|
import org.prolibertate.games.game.catan.GiveChore
|
||||||
|
import org.prolibertate.games.game.catan.GiveGoods
|
||||||
|
import org.prolibertate.games.game.catan.MoveKnight
|
||||||
|
import org.prolibertate.games.game.catan.MoveRobber
|
||||||
|
import org.prolibertate.games.game.catan.PickDeckChore
|
||||||
|
import org.prolibertate.games.game.catan.RollDice
|
||||||
|
import org.prolibertate.games.game.catan.SetupPlace
|
||||||
|
import org.prolibertate.games.game.catan.SpyChore
|
||||||
|
import org.prolibertate.games.net.MatchController
|
||||||
|
import org.prolibertate.games.ui.ScreenScaffold
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Katan, on the island.
|
||||||
|
*
|
||||||
|
* This composable is only wiring. It reads the match from the controller, holds
|
||||||
|
* the handful of purely visual choices (which build mode is armed, which setup
|
||||||
|
* settlement is waiting for its road, which dialog is open), and hands
|
||||||
|
* everything else to single-purpose pieces:
|
||||||
|
*
|
||||||
|
* | Concern | Where it lives |
|
||||||
|
* | ------------------------------ | -------------------------- |
|
||||||
|
* | Colours, sizes, shapes | [CatanStyle] |
|
||||||
|
* | Every word on screen | [CatanText] |
|
||||||
|
* | Island → pixels, taps → hexes | [CatanGeometry] |
|
||||||
|
* | What is tappable, what a tap does | [CatanInteraction] |
|
||||||
|
* | Drawing the island | [CatanBoardView] |
|
||||||
|
* | Cards, players, dice, buttons | `CatanPanels.kt` |
|
||||||
|
* | Every dialog | `CatanDialogs.kt` |
|
||||||
|
*
|
||||||
|
* There are no game rules here: the engine's legal-move list is the only source
|
||||||
|
* of what the player may do, so the screen cannot offer a move the rules refuse.
|
||||||
|
* See `docs/UI-catan.md` for how to adapt the look and feel.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CatanScreen(
|
||||||
|
controller: MatchController<CatanState, CatanMove>,
|
||||||
|
localSeat: Int,
|
||||||
|
onExit: () -> Unit,
|
||||||
|
seatNames: List<String> = emptyList(),
|
||||||
|
) {
|
||||||
|
val state by controller.state.collectAsState()
|
||||||
|
val legal by controller.legalMoves.collectAsState()
|
||||||
|
val abandoned by controller.abandoned.collectAsState()
|
||||||
|
val notice by controller.notice.collectAsState()
|
||||||
|
|
||||||
|
// Purely visual state. Anything that affects the game goes through submit().
|
||||||
|
var building by remember { mutableStateOf<BuildKind?>(null) }
|
||||||
|
var setupVertex by remember { mutableStateOf<Int?>(null) }
|
||||||
|
var victimChoice by remember { mutableStateOf<TapOutcome.PickRobberVictim?>(null) }
|
||||||
|
var showCards by remember { mutableStateOf(false) }
|
||||||
|
var showTrade by remember { mutableStateOf(false) }
|
||||||
|
var confirmingEnd by remember { mutableStateOf(false) }
|
||||||
|
var resultDismissed by remember { mutableStateOf(false) }
|
||||||
|
// Cities & Knights: the knight whose menu is open, the one about to move,
|
||||||
|
// a progress card waiting for a tap on the board, and the two list dialogs.
|
||||||
|
var knightMenu by remember { mutableStateOf<Int?>(null) }
|
||||||
|
var movingKnight by remember { mutableStateOf<Int?>(null) }
|
||||||
|
var armed by remember { mutableStateOf<ArmedCard?>(null) }
|
||||||
|
var showImprove by remember { mutableStateOf(false) }
|
||||||
|
var showProgress by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val style = remember(dark) { catanStyle(dark) }
|
||||||
|
val seats = remember(localSeat, seatNames) { CatanSeats(localSeat, seatNames) }
|
||||||
|
|
||||||
|
// The engine's list changes after every move; drop any selection it no
|
||||||
|
// longer supports (a build mode the player can no longer afford, a setup
|
||||||
|
// settlement that is no longer pending).
|
||||||
|
LaunchedEffect(legal) {
|
||||||
|
if (building != null && building !in CatanInteraction.canBuild(legal)) building = null
|
||||||
|
if (setupVertex != null && legal.none { it is SetupPlace && it.vertex == setupVertex }) {
|
||||||
|
setupVertex = null
|
||||||
|
}
|
||||||
|
if (movingKnight != null && legal.none { it is MoveKnight && it.from == movingKnight }) movingKnight = null
|
||||||
|
armed?.let { card -> if (card.plays.none { it in legal }) armed = null }
|
||||||
|
if (legal.isEmpty()) {
|
||||||
|
showCards = false
|
||||||
|
showTrade = false
|
||||||
|
victimChoice = null
|
||||||
|
showImprove = false
|
||||||
|
showProgress = false
|
||||||
|
knightMenu = null
|
||||||
|
movingKnight = null
|
||||||
|
armed = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val submit: (CatanMove) -> Unit = { move ->
|
||||||
|
controller.submit(move)
|
||||||
|
building = null
|
||||||
|
setupVertex = null
|
||||||
|
victimChoice = null
|
||||||
|
showCards = false
|
||||||
|
showTrade = false
|
||||||
|
showImprove = false
|
||||||
|
showProgress = false
|
||||||
|
knightMenu = null
|
||||||
|
movingKnight = null
|
||||||
|
armed = null
|
||||||
|
}
|
||||||
|
|
||||||
|
val endGameAction: @Composable () -> Unit = {
|
||||||
|
TextButton(onClick = { confirmingEnd = true }) { Text(CatanText.END_GAME) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val current = state
|
||||||
|
if (current == null) {
|
||||||
|
ScreenScaffold(title = CatanText.TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||||
|
Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") }
|
||||||
|
abandoned?.let { CatanAbandonedDialog(it, onExit, controller::dismissAbandoned) }
|
||||||
|
if (confirmingEnd) CatanEndGameDialog(onExit) { confirmingEnd = false }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val targets = CatanInteraction.targets(current, legal, building, setupVertex, movingKnight, armed, localSeat)
|
||||||
|
val ownKnights = current.ck?.knights?.filter { it.owner == localSeat }?.map { it.vertex }?.toSet().orEmpty()
|
||||||
|
val devHand = CatanInteraction.devHand(current, localSeat, legal)
|
||||||
|
val progressHand = CatanInteraction.progressHand(current, localSeat, legal)
|
||||||
|
val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty()
|
||||||
|
val actions = CatanActions(
|
||||||
|
buildKinds = if (current.isCk) BuildKind.entries else BuildKind.BASE,
|
||||||
|
ck = current.isCk,
|
||||||
|
canImprove = CatanInteraction.improvable(legal).isNotEmpty(),
|
||||||
|
canPlayProgress = progressHand.any { it.playable },
|
||||||
|
progressCount = progressHand.size,
|
||||||
|
canEndSmith = CatanInteraction.canEndSmith(legal),
|
||||||
|
canRoll = CatanInteraction.canRoll(legal),
|
||||||
|
canBuild = CatanInteraction.canBuild(legal),
|
||||||
|
building = building,
|
||||||
|
canBuyCard = CatanInteraction.canBuyDevCard(legal),
|
||||||
|
canPlayCard = devHand.any { it.playable },
|
||||||
|
canTrade = playing,
|
||||||
|
canEndTurn = CatanInteraction.canEndTurn(legal),
|
||||||
|
canEndFreeRoads = CatanInteraction.canEndFreeRoads(legal),
|
||||||
|
canRepickSetup = setupVertex != null,
|
||||||
|
)
|
||||||
|
val rolledHexes = CatanInteraction.rolledHexes(current)
|
||||||
|
val prompt = CatanText.prompt(
|
||||||
|
current,
|
||||||
|
seats,
|
||||||
|
setupVertexChosen = setupVertex != null,
|
||||||
|
building = building,
|
||||||
|
movingKnight = movingKnight != null,
|
||||||
|
armed = armed?.kind,
|
||||||
|
)
|
||||||
|
|
||||||
|
val handle: (TapOutcome) -> Unit = { outcome ->
|
||||||
|
when (outcome) {
|
||||||
|
TapOutcome.Ignored -> Unit
|
||||||
|
is TapOutcome.Submit -> submit(outcome.move)
|
||||||
|
is TapOutcome.PickSetupVertex -> setupVertex = outcome.vertex
|
||||||
|
is TapOutcome.PickRobberVictim -> victimChoice = outcome
|
||||||
|
is TapOutcome.PickKnight -> knightMenu = outcome.vertex
|
||||||
|
is TapOutcome.PickFirstHex -> armed = armed?.copy(firstHex = outcome.hex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val publicPoints = CatanRules.publicVictoryPoints(current)
|
||||||
|
val pointsOf: (Int) -> Int = { seat ->
|
||||||
|
if (seat == localSeat) CatanRules.victoryPoints(current, seat) else publicPoints[seat]
|
||||||
|
}
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalCatanStyle provides style) {
|
||||||
|
ScreenScaffold(title = CatanText.TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||||
|
BoxWithConstraints(modifier = modifier) {
|
||||||
|
val wide = maxWidth > maxHeight * 1.1f
|
||||||
|
// Phones (either way up) get the one-button build menu.
|
||||||
|
val compactBuild = !wide || maxWidth < 600.dp
|
||||||
|
|
||||||
|
val board: @Composable (Modifier) -> Unit = { boardModifier ->
|
||||||
|
CatanBoardView(
|
||||||
|
state = current,
|
||||||
|
targets = targets,
|
||||||
|
selectedVertex = setupVertex,
|
||||||
|
onVertexTap = {
|
||||||
|
handle(
|
||||||
|
CatanInteraction.onVertexTap(
|
||||||
|
it, legal, building, setupVertex, movingKnight, armed,
|
||||||
|
ownKnights = if (legal.isNotEmpty()) ownKnights else emptySet(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onEdgeTap = { handle(CatanInteraction.onEdgeTap(it, legal, building, setupVertex, armed)) },
|
||||||
|
onHexTap = { handle(CatanInteraction.onHexTap(it, legal, armed)) },
|
||||||
|
modifier = boardModifier,
|
||||||
|
style = style,
|
||||||
|
rolledHexes = rolledHexes,
|
||||||
|
selectedKnight = movingKnight ?: knightMenu,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val players: @Composable (Boolean) -> Unit = { grid ->
|
||||||
|
PlayerCards(current, seats, pointsOf, style, twoPerRow = grid)
|
||||||
|
}
|
||||||
|
val promptRow: @Composable () -> Unit = {
|
||||||
|
PromptRow(prompt, notice, current)
|
||||||
|
}
|
||||||
|
val handAndActions: @Composable () -> Unit = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
CatanHand(current, localSeat)
|
||||||
|
CatanActionBar(
|
||||||
|
actions = actions,
|
||||||
|
onRoll = { submit(RollDice()) },
|
||||||
|
onBuild = { building = it },
|
||||||
|
onBuyCard = { legal.firstOrNull { m -> m is BuyDevCard }?.let(submit) },
|
||||||
|
onPlayCard = { showCards = true },
|
||||||
|
onTrade = { showTrade = true },
|
||||||
|
onEndTurn = { legal.firstOrNull { m -> m is EndTurn }?.let(submit) },
|
||||||
|
onEndFreeRoads = {
|
||||||
|
legal.firstOrNull { m -> m is EndFreeRoads }?.let(submit)
|
||||||
|
},
|
||||||
|
onRepickSetup = { setupVertex = null },
|
||||||
|
compact = compactBuild,
|
||||||
|
onImprove = { showImprove = true },
|
||||||
|
onProgress = { showProgress = true },
|
||||||
|
onEndSmith = { legal.firstOrNull { m -> m is EndSmith }?.let(submit) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wide) {
|
||||||
|
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Box(Modifier.weight(1.5f).fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
board(Modifier)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f).fillMaxSize().verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
players(true)
|
||||||
|
if (current.isCk) CatanBarbarians(current, style = style)
|
||||||
|
promptRow()
|
||||||
|
handAndActions()
|
||||||
|
CatanLog(current, Modifier.fillMaxWidth().height(120.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
players(false)
|
||||||
|
if (current.isCk) CatanBarbarians(current, style = style)
|
||||||
|
promptRow()
|
||||||
|
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
|
board(Modifier)
|
||||||
|
}
|
||||||
|
handAndActions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CatanDialogHost(
|
||||||
|
state = current,
|
||||||
|
seats = seats,
|
||||||
|
localSeat = localSeat,
|
||||||
|
legal = legal,
|
||||||
|
abandoned = abandoned,
|
||||||
|
confirmingEnd = confirmingEnd,
|
||||||
|
resultDismissed = resultDismissed,
|
||||||
|
victimChoice = victimChoice,
|
||||||
|
showCards = showCards,
|
||||||
|
showTrade = showTrade,
|
||||||
|
devHand = devHand,
|
||||||
|
knightMenu = knightMenu,
|
||||||
|
showImprove = showImprove,
|
||||||
|
showProgress = showProgress,
|
||||||
|
progressHand = progressHand,
|
||||||
|
onMoveKnight = { from ->
|
||||||
|
knightMenu = null
|
||||||
|
movingKnight = from
|
||||||
|
},
|
||||||
|
onArmCard = { card ->
|
||||||
|
showProgress = false
|
||||||
|
armed = card
|
||||||
|
},
|
||||||
|
onCancelKnight = { knightMenu = null },
|
||||||
|
onCancelImprove = { showImprove = false },
|
||||||
|
onCancelProgress = { showProgress = false },
|
||||||
|
onExit = onExit,
|
||||||
|
onSubmit = submit,
|
||||||
|
onStay = controller::dismissAbandoned,
|
||||||
|
onKeepPlaying = { confirmingEnd = false },
|
||||||
|
onDismissResult = { resultDismissed = true },
|
||||||
|
onCancelVictim = { victimChoice = null },
|
||||||
|
onCancelCards = { showCards = false },
|
||||||
|
onCancelTrade = { showTrade = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Layout pieces
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PlayerCards(
|
||||||
|
state: CatanState,
|
||||||
|
seats: CatanSeats,
|
||||||
|
pointsOf: (Int) -> Int,
|
||||||
|
style: CatanStyle,
|
||||||
|
twoPerRow: Boolean,
|
||||||
|
) {
|
||||||
|
val order = (0 until state.playerCount).toList()
|
||||||
|
val rows = if (twoPerRow) order.chunked(2) else listOf(order)
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
rows.forEach { row ->
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
row.forEach { seat ->
|
||||||
|
CatanPlayerCard(
|
||||||
|
state = state,
|
||||||
|
seat = seat,
|
||||||
|
seats = seats,
|
||||||
|
points = pointsOf(seat),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
style = style,
|
||||||
|
compact = !twoPerRow,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Keep an odd last row's card the same width as the others.
|
||||||
|
if (twoPerRow && row.size == 1) Box(Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one line telling the player what is going on, and the dice.
|
||||||
|
*
|
||||||
|
* **Fixed height.** The text always occupies exactly two lines (padded when it is
|
||||||
|
* shorter, clipped when it is longer) and a refused move replaces the prompt
|
||||||
|
* rather than adding to it, so this row is the same height whatever is said and
|
||||||
|
* the board below it never moves.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun PromptRow(prompt: String, notice: String?, state: CatanState) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = notice ?: prompt,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (notice != null) MaterialTheme.colorScheme.error else Color.Unspecified,
|
||||||
|
minLines = 2,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
CatanDice(state.lastDice, ck = state.isCk, event = state.ck?.eventDie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chooses which dialog, if any, is showing. Pulled out of [CatanScreen] so the
|
||||||
|
* priority order — an ended table beats a result beats a forced discard beats
|
||||||
|
* an optional picker — is in one readable place.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun CatanDialogHost(
|
||||||
|
state: CatanState,
|
||||||
|
seats: CatanSeats,
|
||||||
|
localSeat: Int,
|
||||||
|
legal: List<CatanMove>,
|
||||||
|
abandoned: String?,
|
||||||
|
confirmingEnd: Boolean,
|
||||||
|
resultDismissed: Boolean,
|
||||||
|
victimChoice: TapOutcome.PickRobberVictim?,
|
||||||
|
showCards: Boolean,
|
||||||
|
showTrade: Boolean,
|
||||||
|
devHand: List<DevCardInHand>,
|
||||||
|
knightMenu: Int?,
|
||||||
|
showImprove: Boolean,
|
||||||
|
showProgress: Boolean,
|
||||||
|
progressHand: List<ProgressInHand>,
|
||||||
|
onMoveKnight: (Int) -> Unit,
|
||||||
|
onArmCard: (ArmedCard) -> Unit,
|
||||||
|
onCancelKnight: () -> Unit,
|
||||||
|
onCancelImprove: () -> Unit,
|
||||||
|
onCancelProgress: () -> Unit,
|
||||||
|
onExit: () -> Unit,
|
||||||
|
onSubmit: (CatanMove) -> Unit,
|
||||||
|
onStay: () -> Unit,
|
||||||
|
onKeepPlaying: () -> Unit,
|
||||||
|
onDismissResult: () -> Unit,
|
||||||
|
onCancelVictim: () -> Unit,
|
||||||
|
onCancelCards: () -> Unit,
|
||||||
|
onCancelTrade: () -> Unit,
|
||||||
|
) {
|
||||||
|
val offer = state.pendingTrade
|
||||||
|
val goodsOffer = state.ck?.goodsOffer
|
||||||
|
when {
|
||||||
|
abandoned != null -> CatanAbandonedDialog(abandoned, onExit, onStay)
|
||||||
|
|
||||||
|
confirmingEnd -> CatanEndGameDialog(onEnd = onExit, onKeepPlaying = onKeepPlaying)
|
||||||
|
|
||||||
|
state.phase == CatanPhase.GAME_OVER ->
|
||||||
|
if (!resultDismissed) CatanGameOverDialog(state, seats, onExit, onDismissResult)
|
||||||
|
|
||||||
|
state.phase == CatanPhase.DISCARDING && state.discardSeat == localSeat ->
|
||||||
|
CatanDiscardDialog(
|
||||||
|
held = state.resources[localSeat],
|
||||||
|
quota = CatanInteraction.discardQuota(state.resourceCount(localSeat)),
|
||||||
|
legal = legal,
|
||||||
|
onSubmit = onSubmit,
|
||||||
|
)
|
||||||
|
|
||||||
|
state.phase == CatanPhase.CHORE && CatanRules.currentSeat(state) == localSeat ->
|
||||||
|
ChoreDialog(state, localSeat, legal, onSubmit)
|
||||||
|
|
||||||
|
state.phase == CatanPhase.TRADE_PENDING && goodsOffer != null && goodsOffer.asking == localSeat ->
|
||||||
|
CatanGoodsTradeResponseDialog(
|
||||||
|
state = state,
|
||||||
|
seat = localSeat,
|
||||||
|
offer = goodsOffer,
|
||||||
|
seats = seats,
|
||||||
|
canAccept = CatanInteraction.tradeResponses(legal).first,
|
||||||
|
onAccept = { onSubmit(AcceptTrade) },
|
||||||
|
onDecline = { onSubmit(DeclineTrade) },
|
||||||
|
onCounter = onSubmit,
|
||||||
|
)
|
||||||
|
|
||||||
|
state.phase == CatanPhase.TRADE_COUNTERS && goodsOffer != null && state.turn == localSeat && legal.isNotEmpty() ->
|
||||||
|
CatanGoodsCounterChoiceDialog(
|
||||||
|
counters = CatanInteraction.goodsCounterChoices(state, legal),
|
||||||
|
seats = seats,
|
||||||
|
onTake = onSubmit,
|
||||||
|
onNoDeal = { onSubmit(DismissCounters) },
|
||||||
|
)
|
||||||
|
|
||||||
|
state.phase == CatanPhase.TRADE_PENDING && offer != null && offer.asking == localSeat ->
|
||||||
|
CatanTradeResponseDialog(
|
||||||
|
state = state,
|
||||||
|
seat = localSeat,
|
||||||
|
offer = offer,
|
||||||
|
seats = seats,
|
||||||
|
canAccept = CatanInteraction.tradeResponses(legal).first,
|
||||||
|
onAccept = { onSubmit(AcceptTrade) },
|
||||||
|
onDecline = { onSubmit(DeclineTrade) },
|
||||||
|
onCounter = onSubmit,
|
||||||
|
)
|
||||||
|
|
||||||
|
state.phase == CatanPhase.TRADE_COUNTERS && state.turn == localSeat && legal.isNotEmpty() ->
|
||||||
|
CatanCounterChoiceDialog(
|
||||||
|
counters = CatanInteraction.counterChoices(state, legal),
|
||||||
|
seats = seats,
|
||||||
|
onTake = onSubmit,
|
||||||
|
onNoDeal = { onSubmit(DismissCounters) },
|
||||||
|
)
|
||||||
|
|
||||||
|
victimChoice != null ->
|
||||||
|
CatanVictimDialog(
|
||||||
|
victims = victimChoice.victims,
|
||||||
|
seats = seats,
|
||||||
|
onPick = { victim -> onSubmit(MoveRobber(victimChoice.hex, victim)) },
|
||||||
|
onCancel = onCancelVictim,
|
||||||
|
)
|
||||||
|
|
||||||
|
knightMenu != null ->
|
||||||
|
CatanKnightDialog(
|
||||||
|
state = state,
|
||||||
|
vertex = knightMenu,
|
||||||
|
actions = CatanInteraction.knightActions(legal, knightMenu),
|
||||||
|
onDo = onSubmit,
|
||||||
|
onMove = onMoveKnight,
|
||||||
|
onCancel = onCancelKnight,
|
||||||
|
)
|
||||||
|
|
||||||
|
showImprove ->
|
||||||
|
CatanImproveDialog(
|
||||||
|
state = state,
|
||||||
|
seat = localSeat,
|
||||||
|
legal = legal,
|
||||||
|
onBuy = onSubmit,
|
||||||
|
onCancel = onCancelImprove,
|
||||||
|
)
|
||||||
|
|
||||||
|
showProgress ->
|
||||||
|
CatanProgressDialog(
|
||||||
|
hand = progressHand,
|
||||||
|
seats = seats,
|
||||||
|
onPlay = onSubmit,
|
||||||
|
onArm = onArmCard,
|
||||||
|
onCancel = onCancelProgress,
|
||||||
|
)
|
||||||
|
|
||||||
|
showCards -> CatanPlayCardDialog(devHand, onPlay = onSubmit, onCancel = onCancelCards)
|
||||||
|
|
||||||
|
showTrade && state.isCk ->
|
||||||
|
CatanGoodsTradeDialog(
|
||||||
|
state = state,
|
||||||
|
seat = localSeat,
|
||||||
|
seats = seats,
|
||||||
|
legal = legal,
|
||||||
|
onBank = onSubmit,
|
||||||
|
onOffer = onSubmit,
|
||||||
|
onCancel = onCancelTrade,
|
||||||
|
)
|
||||||
|
|
||||||
|
showTrade ->
|
||||||
|
CatanTradeDialog(
|
||||||
|
state = state,
|
||||||
|
seat = localSeat,
|
||||||
|
seats = seats,
|
||||||
|
legal = legal,
|
||||||
|
onBank = onSubmit,
|
||||||
|
onOffer = onSubmit,
|
||||||
|
onCancel = onCancelTrade,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dialog for the chore at the front of the queue, when it is the local
|
||||||
|
* player's. Chores that are answered by tapping the board (a city to lose, a
|
||||||
|
* metropolis site, a knight to place) have no dialog: the prompt line tells the
|
||||||
|
* player what to tap.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ChoreDialog(
|
||||||
|
state: CatanState,
|
||||||
|
localSeat: Int,
|
||||||
|
legal: List<CatanMove>,
|
||||||
|
onSubmit: (CatanMove) -> Unit,
|
||||||
|
) {
|
||||||
|
val ck = state.ck ?: return
|
||||||
|
val check: (List<org.prolibertate.games.game.catan.Good>, (List<org.prolibertate.games.game.catan.Good>) -> CatanMove) -> Boolean =
|
||||||
|
{ cards, make -> CatanRules.isLegalMove(state, localSeat, make(cards)) }
|
||||||
|
when (val chore = ck.chores.firstOrNull()) {
|
||||||
|
is DiscardChore -> {
|
||||||
|
val quota = minOf(chore.count, CatanCk.goodsCount(state, localSeat))
|
||||||
|
CatanGoodsPickDialog(
|
||||||
|
title = "Discard $quota cards",
|
||||||
|
intro = "Choose $quota cards to give up",
|
||||||
|
held = CatanCk.goodsOf(state, localSeat),
|
||||||
|
quota = quota,
|
||||||
|
confirmLabel = "Discard",
|
||||||
|
selectable = { true },
|
||||||
|
allowed = { check(it, ::DiscardGoods) },
|
||||||
|
onConfirm = { onSubmit(DiscardGoods(it)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is GiveChore -> {
|
||||||
|
val pool = CatanCk.goodsOf(state, localSeat).filterKeys { !chore.commoditiesOnly || it.isCommodity }
|
||||||
|
val quota = minOf(chore.count, pool.values.sum())
|
||||||
|
CatanGoodsPickDialog(
|
||||||
|
title = "Hand over $quota ${if (quota == 1) "card" else "cards"}",
|
||||||
|
intro = if (chore.commoditiesOnly) "Choose a commodity to give" else "Choose which cards to give",
|
||||||
|
held = pool,
|
||||||
|
quota = quota,
|
||||||
|
confirmLabel = "Give",
|
||||||
|
selectable = { !chore.commoditiesOnly || it.isCommodity },
|
||||||
|
allowed = { check(it, ::GiveGoods) },
|
||||||
|
onConfirm = { onSubmit(GiveGoods(it)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is PickDeckChore -> CatanPickDeckDialog(legal) { onSubmit(it) }
|
||||||
|
|
||||||
|
is DropProgressChore -> CatanDropProgressDialog(ck.progressHands[localSeat]) { onSubmit(it) }
|
||||||
|
|
||||||
|
is AqueductChore -> CatanAqueductDialog(legal) { onSubmit(it) }
|
||||||
|
|
||||||
|
is SpyChore -> CatanSpyDialog(ck.progressHands[chore.target], legal) { onSubmit(it) }
|
||||||
|
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package org.prolibertate.games.ui.game.catan
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import org.prolibertate.games.game.catan.Commodity
|
||||||
|
import org.prolibertate.games.game.catan.Resource
|
||||||
|
import org.prolibertate.games.game.catan.Terrain
|
||||||
|
import org.prolibertate.games.game.catan.Track
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every visual decision for the Katan screen, in one place.
|
||||||
|
*
|
||||||
|
* **To restyle Katan, edit this file.** Nothing else in `ui/game/catan/` hard-
|
||||||
|
* codes a colour or a proportion: the board, the hand, the player strip and the
|
||||||
|
* dialogs all read a [CatanStyle] from [LocalCatanStyle]. A different art
|
||||||
|
* direction is a different [CatanStyle] value (see [catanStyle]); a new theme
|
||||||
|
* can be supplied at the screen's root with
|
||||||
|
* `CompositionLocalProvider(LocalCatanStyle provides myStyle)`.
|
||||||
|
*
|
||||||
|
* Wording is the same story in [CatanText]; geometry is in [CatanGeometry].
|
||||||
|
*
|
||||||
|
* Proportions are fractions of the *hex radius* (centre of a hex to one of its
|
||||||
|
* corners), so they scale with the board whatever the screen size.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class CatanStyle(
|
||||||
|
// --- Board surfaces -----------------------------------------------------
|
||||||
|
/** The water around the island. */
|
||||||
|
val sea: Color,
|
||||||
|
/** Fill of each hex by terrain. */
|
||||||
|
val terrain: Map<Terrain, Color>,
|
||||||
|
/** Outline between hexes. */
|
||||||
|
val hexEdge: Color,
|
||||||
|
/** The number-token disc, and the ink for ordinary and hot (6 / 8) numbers. */
|
||||||
|
val token: Color,
|
||||||
|
val tokenInk: Color,
|
||||||
|
val tokenHotInk: Color,
|
||||||
|
/** The robber's pawn. */
|
||||||
|
val robber: Color,
|
||||||
|
/** Outline and wash on hexes that produced for the latest dice roll. */
|
||||||
|
val rolled: Color,
|
||||||
|
/** Harbour marker disc and its ink. */
|
||||||
|
val portMarker: Color,
|
||||||
|
val portInk: Color,
|
||||||
|
|
||||||
|
// --- Resources (hand tiles, trade pickers, harbour markers) -------------
|
||||||
|
val resource: Map<Resource, Color>,
|
||||||
|
val resourceInk: Color,
|
||||||
|
|
||||||
|
// --- Cities & Knights ---------------------------------------------------
|
||||||
|
/** Commodity cards: cloth, coin and paper. */
|
||||||
|
val commodity: Map<Commodity, Color> = LightCommodities,
|
||||||
|
/** Ink on a commodity tile (paper is pale, so it is not white). */
|
||||||
|
val commodityInk: Map<Commodity, Color> = LightCommodityInk,
|
||||||
|
/** The colour of each improvement track, its city gate, its deck and its metropolis. */
|
||||||
|
val track: Map<Track, Color> = LightTracks,
|
||||||
|
/** Wall round a city, and the barbarian strip. */
|
||||||
|
val wall: Color = Color(0xFF5D4037),
|
||||||
|
val barbarian: Color = Color(0xFFB71C1C),
|
||||||
|
/** The halo round a knight that is awake. White, so it is not mistaken for the yellow tap ring. */
|
||||||
|
val knightReady: Color = Color(0xFFFFFFFF),
|
||||||
|
|
||||||
|
// --- Players ------------------------------------------------------------
|
||||||
|
/** One colour per seat, in seat order. Roads, settlements, cities, player strip. */
|
||||||
|
val seats: List<Color>,
|
||||||
|
/** Outline drawn round every piece so any seat colour reads on any terrain. */
|
||||||
|
val pieceOutline: Color,
|
||||||
|
|
||||||
|
// --- Interaction feedback ----------------------------------------------
|
||||||
|
/** Marks where the player may build or move right now. */
|
||||||
|
val target: Color,
|
||||||
|
/** The setup settlement that has been chosen and is waiting for its road. */
|
||||||
|
val targetSelected: Color,
|
||||||
|
|
||||||
|
// --- Metrics (fractions of the hex radius) ------------------------------
|
||||||
|
/** How far each tile polygon is pulled in from its corners, leaving a gap. */
|
||||||
|
val hexInset: Float = 0.04f,
|
||||||
|
val roadWidth: Float = 0.20f,
|
||||||
|
val settlementSize: Float = 0.30f,
|
||||||
|
val citySize: Float = 0.42f,
|
||||||
|
val tokenRadius: Float = 0.36f,
|
||||||
|
val robberSize: Float = 0.34f,
|
||||||
|
val portRadius: Float = 0.46f,
|
||||||
|
val targetRadius: Float = 0.20f,
|
||||||
|
/** How close a tap must land to a vertex / edge / hex centre to count. */
|
||||||
|
val tapReach: Float = 0.55f,
|
||||||
|
val tokenText: Float = 0.34f,
|
||||||
|
val portText: Float = 0.27f,
|
||||||
|
val knightSize: Float = 0.30f,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val LightCommodities = mapOf(
|
||||||
|
Commodity.CLOTH to Color(0xFF7E57C2),
|
||||||
|
Commodity.COIN to Color(0xFFF9A825),
|
||||||
|
Commodity.PAPER to Color(0xFFECEFF1),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val LightCommodityInk = mapOf(
|
||||||
|
Commodity.CLOTH to Color(0xFFFFFFFF),
|
||||||
|
Commodity.COIN to Color(0xFF3E2723),
|
||||||
|
Commodity.PAPER to Color(0xFF263238),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val LightTracks = mapOf(
|
||||||
|
Track.TRADE to Color(0xFFF2C300),
|
||||||
|
Track.POLITICS to Color(0xFF1E6FD9),
|
||||||
|
Track.SCIENCE to Color(0xFF2E9E4F),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The light palette, which is also the reference for the dark one. */
|
||||||
|
private val LightStyle = CatanStyle(
|
||||||
|
sea = Color(0xFF3A7CA5),
|
||||||
|
terrain = mapOf(
|
||||||
|
Terrain.FOREST to Color(0xFF2F6B3A),
|
||||||
|
Terrain.PASTURE to Color(0xFF9CCC65),
|
||||||
|
Terrain.FIELDS to Color(0xFFE8C84A),
|
||||||
|
Terrain.HILLS to Color(0xFFB4532F),
|
||||||
|
Terrain.MOUNTAINS to Color(0xFF7C8794),
|
||||||
|
Terrain.DESERT to Color(0xFFE6D3A0),
|
||||||
|
),
|
||||||
|
hexEdge = Color(0x55000000),
|
||||||
|
token = Color(0xFFF7EFD9),
|
||||||
|
tokenInk = Color(0xFF2B2B2B),
|
||||||
|
tokenHotInk = Color(0xFFC62828),
|
||||||
|
robber = Color(0xFF263238),
|
||||||
|
rolled = Color(0xFFFFFFFF),
|
||||||
|
portMarker = Color(0xFFFFF8E1),
|
||||||
|
portInk = Color(0xFF2B2B2B),
|
||||||
|
resource = mapOf(
|
||||||
|
Resource.LUMBER to Color(0xFF2F6B3A),
|
||||||
|
Resource.BRICK to Color(0xFFB4532F),
|
||||||
|
Resource.WOOL to Color(0xFF8BC34A),
|
||||||
|
Resource.GRAIN to Color(0xFFE0B020),
|
||||||
|
Resource.ORE to Color(0xFF6B7684),
|
||||||
|
),
|
||||||
|
resourceInk = Color(0xFFFFFFFF),
|
||||||
|
seats = listOf(
|
||||||
|
Color(0xFF1565C0), // blue
|
||||||
|
Color(0xFFEF6C00), // orange
|
||||||
|
Color(0xFFF5F5F5), // white
|
||||||
|
Color(0xFF8E24AA), // purple
|
||||||
|
),
|
||||||
|
pieceOutline = Color(0xFF1B1B1B),
|
||||||
|
target = Color(0xFFFFEB3B),
|
||||||
|
targetSelected = Color(0xFF00E5FF),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Deeper sea and slightly dimmer land so the board is not a glare at night. */
|
||||||
|
private val DarkStyle = LightStyle.copy(
|
||||||
|
sea = Color(0xFF14354D),
|
||||||
|
terrain = LightStyle.terrain.mapValues { (_, c) -> c.dim(0.82f) },
|
||||||
|
token = Color(0xFFE9DFC4),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The style for the current light / dark setting. */
|
||||||
|
fun catanStyle(dark: Boolean): CatanStyle = if (dark) DarkStyle else LightStyle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the Katan composables read their look from. Defaults to the light style
|
||||||
|
* so a preview or a test with no provider still draws something sensible;
|
||||||
|
* [CatanScreen] provides the right one for the current theme.
|
||||||
|
*/
|
||||||
|
val LocalCatanStyle = staticCompositionLocalOf { LightStyle }
|
||||||
|
|
||||||
|
private fun Color.dim(factor: Float): Color =
|
||||||
|
Color(red * factor, green * factor, blue * factor, alpha)
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
package org.prolibertate.games.ui.game.catan
|
||||||
|
|
||||||
|
import org.prolibertate.games.game.catan.AqueductChore
|
||||||
|
import org.prolibertate.games.game.catan.CatanCk
|
||||||
|
import org.prolibertate.games.game.catan.CatanPhase
|
||||||
|
import org.prolibertate.games.game.catan.CatanRules
|
||||||
|
import org.prolibertate.games.game.catan.CatanState
|
||||||
|
import org.prolibertate.games.game.catan.DevCardType
|
||||||
|
import org.prolibertate.games.game.catan.DiscardChore
|
||||||
|
import org.prolibertate.games.game.catan.DropProgressChore
|
||||||
|
import org.prolibertate.games.game.catan.EventFace
|
||||||
|
import org.prolibertate.games.game.catan.GiveChore
|
||||||
|
import org.prolibertate.games.game.catan.Good
|
||||||
|
import org.prolibertate.games.game.catan.MetropolisChore
|
||||||
|
import org.prolibertate.games.game.catan.PickDeckChore
|
||||||
|
import org.prolibertate.games.game.catan.PillageChore
|
||||||
|
import org.prolibertate.games.game.catan.PlaceKnightChore
|
||||||
|
import org.prolibertate.games.game.catan.Port
|
||||||
|
import org.prolibertate.games.game.catan.ProgressType
|
||||||
|
import org.prolibertate.games.game.catan.RelocateChore
|
||||||
|
import org.prolibertate.games.game.catan.RemoveKnightChore
|
||||||
|
import org.prolibertate.games.game.catan.Resource
|
||||||
|
import org.prolibertate.games.game.catan.SmithChore
|
||||||
|
import org.prolibertate.games.game.catan.SpyChore
|
||||||
|
import org.prolibertate.games.game.catan.Terrain
|
||||||
|
import org.prolibertate.games.game.catan.Track
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who is who at the table, for wording. [names] may be empty or short — a
|
||||||
|
* client device does not always know everyone's name — and the rest fall back
|
||||||
|
* to "Seat N".
|
||||||
|
*/
|
||||||
|
class CatanSeats(val me: Int, private val names: List<String> = emptyList()) {
|
||||||
|
fun label(seat: Int): String = when {
|
||||||
|
seat == me -> "You"
|
||||||
|
else -> names.getOrNull(seat)?.takeIf { it.isNotBlank() } ?: "Seat ${seat + 1}"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** For mid-sentence use: "you" rather than "You". */
|
||||||
|
fun lower(seat: Int): String = if (seat == me) "you" else label(seat)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every word the Katan screen shows, in one place.
|
||||||
|
*
|
||||||
|
* **To change copy, or to localise, edit this file.** The composables never
|
||||||
|
* build a sentence themselves; they ask for it here. Keeping the sentences as
|
||||||
|
* plain functions of state also means they are unit-tested (`CatanTextTest`)
|
||||||
|
* without a device.
|
||||||
|
*
|
||||||
|
* "Katan" is this app's name for the game (see `docs/RULES-catan.md`); the
|
||||||
|
* resources are lumber, brick, wool, grain and ore.
|
||||||
|
*/
|
||||||
|
object CatanText {
|
||||||
|
|
||||||
|
const val TITLE = "Katan"
|
||||||
|
|
||||||
|
// --- Names ---------------------------------------------------------------
|
||||||
|
|
||||||
|
fun resource(resource: Resource): String = when (resource) {
|
||||||
|
Resource.LUMBER -> "Lumber"
|
||||||
|
Resource.BRICK -> "Brick"
|
||||||
|
Resource.WOOL -> "Wool"
|
||||||
|
Resource.GRAIN -> "Grain"
|
||||||
|
Resource.ORE -> "Ore"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun terrain(terrain: Terrain): String = when (terrain) {
|
||||||
|
Terrain.FOREST -> "Forest"
|
||||||
|
Terrain.PASTURE -> "Pasture"
|
||||||
|
Terrain.FIELDS -> "Fields"
|
||||||
|
Terrain.HILLS -> "Hills"
|
||||||
|
Terrain.MOUNTAINS -> "Mountains"
|
||||||
|
Terrain.DESERT -> "Desert"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun devCard(type: DevCardType): String = when (type) {
|
||||||
|
DevCardType.KNIGHT -> "Knight"
|
||||||
|
DevCardType.ROAD_BUILDING -> "Road building"
|
||||||
|
DevCardType.MONOPOLY -> "Monopoly"
|
||||||
|
DevCardType.YEAR_OF_PLENTY -> "Year of plenty"
|
||||||
|
DevCardType.VICTORY_POINT -> "Victory point"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun devCardHelp(type: DevCardType): String = when (type) {
|
||||||
|
DevCardType.KNIGHT -> "Move the robber and steal a card. Three played knights take Largest Army."
|
||||||
|
DevCardType.ROAD_BUILDING -> "Build two roads for free."
|
||||||
|
DevCardType.MONOPOLY -> "Name a resource and take every card of it from the other players."
|
||||||
|
DevCardType.YEAR_OF_PLENTY -> "Take any two resource cards from the bank."
|
||||||
|
DevCardType.VICTORY_POINT -> "One victory point. Kept hidden until you win."
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "3:1" for a generic harbour, "2:1" for a resource one. */
|
||||||
|
fun portRatio(port: Port): String = "${port.ratio}:1"
|
||||||
|
|
||||||
|
fun portName(port: Port): String =
|
||||||
|
port.resource?.let { "${portRatio(port)} ${resource(it).lowercase()}" } ?: "3:1 any"
|
||||||
|
|
||||||
|
// --- Costs ---------------------------------------------------------------
|
||||||
|
|
||||||
|
fun cost(cost: Map<Resource, Int>): String =
|
||||||
|
cost.entries.joinToString(" + ") { (r, n) -> "$n ${resource(r).lowercase()}" }
|
||||||
|
|
||||||
|
val ROAD_COST: String get() = cost(CatanRules.ROAD_COST)
|
||||||
|
val SETTLEMENT_COST: String get() = cost(CatanRules.SETTLEMENT_COST)
|
||||||
|
val CITY_COST: String get() = cost(CatanRules.CITY_COST)
|
||||||
|
val DEV_COST: String get() = cost(CatanRules.DEV_COST)
|
||||||
|
|
||||||
|
// --- Buttons and headings --------------------------------------------------
|
||||||
|
|
||||||
|
const val ROLL = "Roll dice"
|
||||||
|
const val END_TURN = "End turn"
|
||||||
|
const val ROAD = "Road"
|
||||||
|
const val SETTLEMENT = "Settlement"
|
||||||
|
const val CITY = "City"
|
||||||
|
const val BUY_CARD = "Buy card"
|
||||||
|
const val PLAY_CARD = "Play card"
|
||||||
|
const val TRADE = "Trade"
|
||||||
|
const val CANCEL = "Cancel"
|
||||||
|
const val CONFIRM = "Confirm"
|
||||||
|
const val STOP_ROADS = "Done with roads"
|
||||||
|
const val BUILD = "Build"
|
||||||
|
const val COUNTER = "Counter…"
|
||||||
|
const val SEND_COUNTER = "Send counter"
|
||||||
|
const val EVERYONE = "Everyone"
|
||||||
|
const val NO_DEAL = "No deal"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The empty second line of a button that has no cost, so every button in the
|
||||||
|
* action bar is the same height. A non-breaking space: blank, but a line.
|
||||||
|
*/
|
||||||
|
const val NO_COST = "\u00A0"
|
||||||
|
const val YOUR_HAND = "Your hand"
|
||||||
|
const val CARDS = "cards"
|
||||||
|
const val ACCEPT = "Accept"
|
||||||
|
const val DECLINE = "Decline"
|
||||||
|
const val TRADE_WITH_PLAYER = "Offer to a player"
|
||||||
|
const val TRADE_WITH_BANK = "Trade with the bank"
|
||||||
|
const val YOU_GIVE = "You give"
|
||||||
|
const val YOU_GET = "You get"
|
||||||
|
const val SEND_OFFER = "Send offer"
|
||||||
|
const val NO_LOG = "Nothing has happened yet."
|
||||||
|
const val END_GAME = "End game"
|
||||||
|
const val LOOK_AT_BOARD = "Look at the board"
|
||||||
|
const val LEAVE_GAME = "Leave the game"
|
||||||
|
|
||||||
|
// --- The prompt line ------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one line that tells the player what is going on and what to do.
|
||||||
|
*
|
||||||
|
* [setupVertexChosen] is the setup settlement already picked and waiting
|
||||||
|
* for its road; [building] is the build mode the player has entered, if any.
|
||||||
|
*/
|
||||||
|
fun prompt(
|
||||||
|
state: CatanState,
|
||||||
|
seats: CatanSeats,
|
||||||
|
setupVertexChosen: Boolean,
|
||||||
|
building: BuildKind?,
|
||||||
|
/** Cities & Knights: a knight has been picked to move, and its destination is awaited. */
|
||||||
|
movingKnight: Boolean = false,
|
||||||
|
/** Cities & Knights: a progress card is waiting for a tap on the board. */
|
||||||
|
armed: ProgressType? = null,
|
||||||
|
): String {
|
||||||
|
if (state.isCk) return ckPrompt(state, seats, setupVertexChosen, building, movingKnight, armed)
|
||||||
|
val me = seats.me
|
||||||
|
val onClock = CatanRules.currentSeat(state)
|
||||||
|
val mine = onClock == me
|
||||||
|
val turn = seats.label(state.turn)
|
||||||
|
|
||||||
|
return when (state.phase) {
|
||||||
|
CatanPhase.GAME_OVER -> {
|
||||||
|
val winner = state.winner
|
||||||
|
if (winner == null) "The game is over." else "${seats.label(winner)} won the game."
|
||||||
|
}
|
||||||
|
|
||||||
|
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND -> when {
|
||||||
|
!mine -> "${seats.label(onClock ?: state.turn)} is placing a settlement and a road…"
|
||||||
|
setupVertexChosen -> "Now tap the road to place beside that settlement."
|
||||||
|
state.phase == CatanPhase.SETUP_SECOND ->
|
||||||
|
"Place your second settlement. It earns resources from the hexes around it."
|
||||||
|
|
||||||
|
else -> "Place a settlement, then a road."
|
||||||
|
}
|
||||||
|
|
||||||
|
CatanPhase.AWAIT_ROLL ->
|
||||||
|
if (mine) "Your turn. Roll the dice." else "$turn is rolling…"
|
||||||
|
|
||||||
|
CatanPhase.DISCARDING -> {
|
||||||
|
val discarder = state.discardSeat
|
||||||
|
when {
|
||||||
|
discarder == me -> "A seven! You hold ${state.resourceCount(me)} cards; " +
|
||||||
|
"discard ${state.resourceCount(me) / 2}."
|
||||||
|
|
||||||
|
discarder != null -> "A seven! ${seats.label(discarder)} must discard half."
|
||||||
|
else -> "A seven! Discarding…"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CatanPhase.ROBBER ->
|
||||||
|
if (mine) "Move the robber. Tap a hex." else "$turn is moving the robber…"
|
||||||
|
|
||||||
|
CatanPhase.PLAYING -> when {
|
||||||
|
!mine -> "$turn is playing…"
|
||||||
|
building == BuildKind.ROAD -> "Tap an edge to build a road."
|
||||||
|
building == BuildKind.SETTLEMENT -> "Tap an empty crossing to build a settlement."
|
||||||
|
building == BuildKind.CITY -> "Tap one of your settlements to upgrade it."
|
||||||
|
else -> "Your turn. Build, trade, or end the turn."
|
||||||
|
}
|
||||||
|
|
||||||
|
CatanPhase.FREE_ROADS ->
|
||||||
|
if (mine) {
|
||||||
|
"Road building: place ${state.pendingFreeRoads} more " +
|
||||||
|
if (state.pendingFreeRoads == 1) "road." else "roads."
|
||||||
|
} else {
|
||||||
|
"$turn is placing free roads…"
|
||||||
|
}
|
||||||
|
|
||||||
|
CatanPhase.CHORE -> "Waiting for ${seats.label(onClock ?: state.turn)}…"
|
||||||
|
|
||||||
|
CatanPhase.TRADE_PENDING -> {
|
||||||
|
val offer = state.pendingTrade
|
||||||
|
when {
|
||||||
|
offer == null -> "Waiting for a reply…"
|
||||||
|
offer.asking == me -> if (offer.to == null) {
|
||||||
|
"${seats.label(offer.by)} offers the table a trade. Accept, decline or counter."
|
||||||
|
} else {
|
||||||
|
"${seats.label(offer.by)} offers you a trade. Accept, decline or counter."
|
||||||
|
}
|
||||||
|
|
||||||
|
offer.by == me -> "Waiting for ${seats.label(offer.asking)} to answer your offer…"
|
||||||
|
else -> "${seats.label(offer.by)}'s offer is with ${seats.label(offer.asking)}."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CatanPhase.TRADE_COUNTERS ->
|
||||||
|
if (mine) {
|
||||||
|
"Nobody accepted, but there are counter-offers. Take one or turn them down."
|
||||||
|
} else {
|
||||||
|
"$turn is weighing counter-offers…"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "3 Lumber + 1 Brick" for a flat list of cards. */
|
||||||
|
fun cards(cards: List<Resource>): String =
|
||||||
|
cards.groupingBy { it }.eachCount().entries
|
||||||
|
.sortedBy { it.key.ordinal }
|
||||||
|
.joinToString(" + ") { (r, n) -> "$n ${resource(r)}" }
|
||||||
|
.ifEmpty { "nothing" }
|
||||||
|
|
||||||
|
fun tradeSummary(by: String, to: String, give: List<Resource>, get: List<Resource>): String =
|
||||||
|
"$by offers ${cards(give)} to $to for ${cards(get)}"
|
||||||
|
|
||||||
|
/** One counter-offer as the offerer sees it: what that seat gives and wants. */
|
||||||
|
fun counterSummary(by: String, give: List<Resource>, get: List<Resource>): String =
|
||||||
|
"$by gives ${cards(give)} for your ${cards(get)}"
|
||||||
|
|
||||||
|
fun winnerHeadline(state: CatanState, seats: CatanSeats): String {
|
||||||
|
val winner = state.winner ?: return "Game over"
|
||||||
|
return if (winner == seats.me) "You won!" else "${seats.label(winner)} won"
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cities & Knights ----------------------------------------------------
|
||||||
|
|
||||||
|
fun good(good: Good): String = when (good) {
|
||||||
|
Good.LUMBER -> "Lumber"
|
||||||
|
Good.BRICK -> "Brick"
|
||||||
|
Good.WOOL -> "Wool"
|
||||||
|
Good.GRAIN -> "Grain"
|
||||||
|
Good.ORE -> "Ore"
|
||||||
|
Good.CLOTH -> "Cloth"
|
||||||
|
Good.COIN -> "Coin"
|
||||||
|
Good.PAPER -> "Paper"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "2 Lumber + 1 Cloth" for a flat list of goods. */
|
||||||
|
fun goods(cards: List<Good>): String =
|
||||||
|
cards.groupingBy { it }.eachCount().entries
|
||||||
|
.sortedBy { it.key.ordinal }
|
||||||
|
.joinToString(" + ") { (g, n) -> "$n ${good(g)}" }
|
||||||
|
.ifEmpty { "nothing" }
|
||||||
|
|
||||||
|
fun track(track: Track): String = when (track) {
|
||||||
|
Track.TRADE -> "Trade"
|
||||||
|
Track.POLITICS -> "Politics"
|
||||||
|
Track.SCIENCE -> "Science"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the third level of a track gives, for the improvement dialog. */
|
||||||
|
fun trackPower(track: Track): String = when (track) {
|
||||||
|
Track.TRADE -> "From level 3, trade commodities with the bank two for one."
|
||||||
|
Track.POLITICS -> "From level 3, knights can be promoted to mighty."
|
||||||
|
Track.SCIENCE -> "From level 3, when the dice pay you nothing, take any resource."
|
||||||
|
}
|
||||||
|
|
||||||
|
fun knightName(level: Int): String = when (level) {
|
||||||
|
1 -> "Basic knight"
|
||||||
|
2 -> "Strong knight"
|
||||||
|
else -> "Mighty knight"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun event(face: EventFace?): String = when (face) {
|
||||||
|
null -> "–"
|
||||||
|
EventFace.SHIP -> "Ship"
|
||||||
|
EventFace.TRADE -> "Trade"
|
||||||
|
EventFace.POLITICS -> "Politics"
|
||||||
|
EventFace.SCIENCE -> "Science"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun progress(kind: ProgressType): String = CatanCk.progressName(kind)
|
||||||
|
|
||||||
|
fun progressHelp(kind: ProgressType): String = when (kind) {
|
||||||
|
ProgressType.ALCHEMIST -> "Before rolling, choose what the two number dice show."
|
||||||
|
ProgressType.CRANE -> "Your next city improvement costs one commodity less."
|
||||||
|
ProgressType.ENGINEER -> "Raise a wall round one of your cities for free."
|
||||||
|
ProgressType.INVENTOR -> "Swap two number tokens (not 2, 6, 8 or 12)."
|
||||||
|
ProgressType.IRRIGATION -> "Take 2 grain for each fields hex beside your settlements and cities."
|
||||||
|
ProgressType.MEDICINE -> "Upgrade a settlement to a city for 2 ore and 1 grain."
|
||||||
|
ProgressType.MINING -> "Take 2 ore for each mountains hex beside your settlements and cities."
|
||||||
|
ProgressType.PRINTER -> "One victory point."
|
||||||
|
ProgressType.ROAD_BUILDING -> "Build two roads for free."
|
||||||
|
ProgressType.SMITH -> "Promote up to two of your knights one level for free."
|
||||||
|
ProgressType.BISHOP -> "Move the robber and take a card from every player beside its new hex."
|
||||||
|
ProgressType.CONSTITUTION -> "One victory point."
|
||||||
|
ProgressType.DESERTER -> "Another player removes a knight; you place a free one of the same strength."
|
||||||
|
ProgressType.DIPLOMAT -> "Remove an open road. If it was yours, build it again anywhere for free."
|
||||||
|
ProgressType.INTRIGUE -> "Drive an opponent's knight off a crossing beside one of your roads."
|
||||||
|
ProgressType.SABOTEUR -> "Every player with as many points as you or more discards half their cards."
|
||||||
|
ProgressType.SPY -> "Look at another player's progress cards and take one."
|
||||||
|
ProgressType.WARLORD -> "Wake all your knights without paying grain."
|
||||||
|
ProgressType.WEDDING -> "Every player with more points gives you 2 cards."
|
||||||
|
ProgressType.COMMERCIAL_HARBOR -> "Offer a resource to each player; each swaps it for a commodity."
|
||||||
|
ProgressType.MASTER_MERCHANT -> "Take 2 cards from a player with more points."
|
||||||
|
ProgressType.MERCHANT -> "Put the merchant beside your settlement: trade that resource 2:1 and score a point."
|
||||||
|
ProgressType.MERCHANT_FLEET -> "This turn, trade one chosen card type with the bank two for one."
|
||||||
|
ProgressType.RESOURCE_MONOPOLY -> "Name a resource. Every other player gives you 2 of it."
|
||||||
|
ProgressType.TRADE_MONOPOLY -> "Name a commodity. Every other player gives you 1 of it."
|
||||||
|
}
|
||||||
|
|
||||||
|
const val IMPROVE = "Improve"
|
||||||
|
const val KNIGHTS = "Knights"
|
||||||
|
const val CARDS_BUTTON = "Progress"
|
||||||
|
const val WALL = "Wall"
|
||||||
|
const val KNIGHT = "Knight"
|
||||||
|
const val STOP_SMITH = "Done promoting"
|
||||||
|
|
||||||
|
val KNIGHT_COST: String get() = cost(CatanCk.KNIGHT_COST)
|
||||||
|
val ACTIVATE_COST: String get() = cost(CatanCk.ACTIVATE_COST)
|
||||||
|
val WALL_COST: String get() = cost(CatanCk.WALL_COST)
|
||||||
|
|
||||||
|
private fun ckPrompt(
|
||||||
|
state: CatanState,
|
||||||
|
seats: CatanSeats,
|
||||||
|
setupVertexChosen: Boolean,
|
||||||
|
building: BuildKind?,
|
||||||
|
movingKnight: Boolean,
|
||||||
|
armed: ProgressType?,
|
||||||
|
): String {
|
||||||
|
val me = seats.me
|
||||||
|
val onClock = CatanRules.currentSeat(state)
|
||||||
|
val mine = onClock == me
|
||||||
|
val turn = seats.label(state.turn)
|
||||||
|
val ck = state.ck!!
|
||||||
|
|
||||||
|
return when (state.phase) {
|
||||||
|
CatanPhase.GAME_OVER -> {
|
||||||
|
val winner = state.winner
|
||||||
|
if (winner == null) "The game is over." else "${seats.label(winner)} won the game."
|
||||||
|
}
|
||||||
|
|
||||||
|
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND -> when {
|
||||||
|
!mine -> "${seats.label(onClock ?: state.turn)} is placing a settlement and a road…"
|
||||||
|
setupVertexChosen -> "Now tap the road to place beside that settlement."
|
||||||
|
state.phase == CatanPhase.SETUP_SECOND ->
|
||||||
|
"Place your second piece: it is a city, and earns resources from the hexes around it."
|
||||||
|
|
||||||
|
else -> "Place a settlement, then a road."
|
||||||
|
}
|
||||||
|
|
||||||
|
CatanPhase.AWAIT_ROLL -> when {
|
||||||
|
!mine -> "$turn is rolling…"
|
||||||
|
armed != null -> armedPrompt(armed)
|
||||||
|
movingKnight -> "Tap where the knight should go."
|
||||||
|
else -> "Your turn. Roll the dice, or wake knights and play progress cards first."
|
||||||
|
}
|
||||||
|
|
||||||
|
CatanPhase.ROBBER -> when {
|
||||||
|
!mine -> "$turn is moving the robber…"
|
||||||
|
ck.bishop -> "The bishop: tap a hex. Everyone beside it pays you a card."
|
||||||
|
else -> "Move the robber. Tap a hex."
|
||||||
|
}
|
||||||
|
|
||||||
|
CatanPhase.PLAYING -> when {
|
||||||
|
!mine -> "$turn is playing…"
|
||||||
|
armed != null -> armedPrompt(armed)
|
||||||
|
movingKnight -> "Tap where the knight should go."
|
||||||
|
building == BuildKind.ROAD -> "Tap an edge to build a road."
|
||||||
|
building == BuildKind.SETTLEMENT -> "Tap an empty crossing to build a settlement."
|
||||||
|
building == BuildKind.CITY -> "Tap one of your settlements to upgrade it."
|
||||||
|
building == BuildKind.WALL -> "Tap one of your cities to raise a wall."
|
||||||
|
building == BuildKind.KNIGHT -> "Tap an empty crossing at the end of your road to hire a knight."
|
||||||
|
else -> "Your turn. Build, improve, trade, or end the turn. Tap a knight to use it."
|
||||||
|
}
|
||||||
|
|
||||||
|
CatanPhase.FREE_ROADS ->
|
||||||
|
if (mine) {
|
||||||
|
"Place ${state.pendingFreeRoads} free " +
|
||||||
|
if (state.pendingFreeRoads == 1) "road." else "roads."
|
||||||
|
} else {
|
||||||
|
"$turn is placing free roads…"
|
||||||
|
}
|
||||||
|
|
||||||
|
CatanPhase.TRADE_PENDING -> {
|
||||||
|
val offer = ck.goodsOffer
|
||||||
|
val asking = offer?.asking?.takeIf { it >= 0 }
|
||||||
|
when {
|
||||||
|
offer == null || asking == null -> "Waiting for a reply…"
|
||||||
|
asking == me -> "${seats.label(offer.by)} offers you a trade."
|
||||||
|
offer.by == me -> "Waiting for ${seats.label(asking)} to answer your offer…"
|
||||||
|
else -> "${seats.label(offer.by)} has offered ${seats.label(asking)} a trade."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CatanPhase.TRADE_COUNTERS ->
|
||||||
|
if (mine) {
|
||||||
|
"Nobody accepted, but there are counter-offers. Take one or turn them down."
|
||||||
|
} else {
|
||||||
|
"$turn is weighing counter-offers…"
|
||||||
|
}
|
||||||
|
|
||||||
|
CatanPhase.DISCARDING -> "Discarding…"
|
||||||
|
|
||||||
|
CatanPhase.CHORE -> {
|
||||||
|
val chore = ck.chores.firstOrNull()
|
||||||
|
val who = seats.label(chore?.seat ?: state.turn)
|
||||||
|
if (chore == null || chore.seat != me) {
|
||||||
|
when (chore) {
|
||||||
|
is PillageChore -> "The barbarians have landed. $who must give up a city."
|
||||||
|
is DiscardChore -> "$who must discard ${chore.count} cards."
|
||||||
|
else -> "Waiting for $who…"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
when (chore) {
|
||||||
|
is DiscardChore -> "Discard ${chore.count} cards."
|
||||||
|
is GiveChore -> "Give ${chore.count} card${if (chore.count == 1) "" else "s"} to ${seats.lower(chore.to)}."
|
||||||
|
is PillageChore -> "The barbarians won. Tap a city of yours to give up."
|
||||||
|
is PickDeckChore -> "Choose a progress deck to draw from."
|
||||||
|
is DropProgressChore -> "You hold too many progress cards. Give one back."
|
||||||
|
is MetropolisChore -> "Tap the city that will bear the ${track(chore.track).lowercase()} metropolis."
|
||||||
|
is AqueductChore -> "Your aqueduct pays. Take any resource."
|
||||||
|
is RelocateChore -> "Your knight was driven off. Tap where it goes."
|
||||||
|
is RemoveKnightChore -> "Tap one of your knights to remove."
|
||||||
|
is PlaceKnightChore -> "Tap where your free ${knightName(chore.level).lowercase()} stands."
|
||||||
|
is SpyChore -> "Choose a progress card to take."
|
||||||
|
is SmithChore -> "Tap a knight to promote (${chore.left} left), or stop."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun armedPrompt(kind: ProgressType): String = when (kind) {
|
||||||
|
ProgressType.ENGINEER -> "Engineer: tap a city to wall."
|
||||||
|
ProgressType.MEDICINE -> "Medicine: tap a settlement to upgrade."
|
||||||
|
ProgressType.MERCHANT -> "Merchant: tap a hex beside one of your buildings."
|
||||||
|
ProgressType.DIPLOMAT -> "Diplomat: tap an open road to remove."
|
||||||
|
ProgressType.INTRIGUE -> "Intrigue: tap an enemy knight beside your road."
|
||||||
|
ProgressType.INVENTOR -> "Inventor: tap two hexes to swap their tokens."
|
||||||
|
else -> "Tap the board."
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "6 steps away", "1 step away" for the barbarian ship. */
|
||||||
|
fun barbarianSteps(steps: Int): String = "$steps ${if (steps == 1) "step" else "steps"} away"
|
||||||
|
}
|
||||||
@@ -106,17 +106,33 @@ class CatalogTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `every game in the catalogue can be played`() {
|
fun `every reference game in the catalogue can be played`() {
|
||||||
// There is nothing left marked "coming soon". The machinery that hides
|
// Every game the app ships is playable. The one exception is Katan,
|
||||||
// unfinished games from a production build is still there and still
|
// listed so the slot exists and marked *Coming soon* until its engine
|
||||||
// tested above — it simply has nothing to hide at the moment, and this
|
// and screen land — `available = false` is what keeps an unfinished
|
||||||
// is what will fail the day something is added to the list before its
|
// game out of a production build while leaving it visible on a dev one.
|
||||||
// engine is written.
|
|
||||||
assertTrue(GameCatalog.all.all { it.available })
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = false),
|
listOf(GameCatalog.CATAN),
|
||||||
GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = true),
|
GameCatalog.all.filterNot { it.available }.map { it.id },
|
||||||
)
|
)
|
||||||
|
// It is also the only difference the two channels see in a menu.
|
||||||
|
val hiddenFromProduction = GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = true) -
|
||||||
|
GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = false)
|
||||||
|
assertEquals(listOf(GameCatalog.byId(GameCatalog.CATAN)!!), hiddenFromProduction)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a preview game opens on a dev build and nowhere else`() {
|
||||||
|
val preview = GameCatalog.all.filter { it.devPreview }
|
||||||
|
assertTrue("Katan is the preview game", preview.map { it.id } == listOf(GameCatalog.CATAN))
|
||||||
|
preview.forEach { game ->
|
||||||
|
assertFalse("${game.id}: preview is for games that are not yet available", game.available)
|
||||||
|
assertTrue("${game.id} should open on a dev build", game.playableIn(devBuild = true))
|
||||||
|
assertFalse("${game.id} must not open in production", game.playableIn(devBuild = false))
|
||||||
|
}
|
||||||
|
// An ordinary finished game opens everywhere.
|
||||||
|
val chess = GameCatalog.byId(GameCatalog.CHESS)!!
|
||||||
|
assertTrue(chess.playableIn(devBuild = true) && chess.playableIn(devBuild = false))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package org.prolibertate.games.game.catan
|
||||||
|
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
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 CatanAiTest {
|
||||||
|
|
||||||
|
private val json = Json { encodeDefaults = true }
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Plays one table of engines out to a result, refusing every illegal move. */
|
||||||
|
private fun playOut(options: CatanOptions, seed: Long, cap: Int = 4_000): CatanState {
|
||||||
|
val ai = CatanAi()
|
||||||
|
var state = CatanRules.initialState(config(options, seed))
|
||||||
|
var moves = 0
|
||||||
|
while (!CatanRules.isFinished(state)) {
|
||||||
|
assertTrue("seed $seed ran past $cap moves", moves++ < cap)
|
||||||
|
val seat = CatanRules.currentSeat(state) ?: break
|
||||||
|
val move = ai.chooseMove(state, seat, CatanRules.legalMoves(state, seat))
|
||||||
|
assertTrue(
|
||||||
|
"seed $seed: seat $seat chose the illegal move $move",
|
||||||
|
CatanRules.isLegalMove(state, seat, move),
|
||||||
|
)
|
||||||
|
state = CatanRules.applyMove(state, seat, move)
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `three engines finish a game at ten points`() {
|
||||||
|
listOf(11L, 22L, 33L).forEach { seed ->
|
||||||
|
val state = playOut(CatanOptions(), 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `four engines finish a game at ten points`() {
|
||||||
|
listOf(44L, 55L).forEach { seed ->
|
||||||
|
val state = playOut(CatanOptions(playerCount = 4), 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A mid-game state on seat 0's turn, one card short of a settlement. */
|
||||||
|
private fun offeringState(): CatanState =
|
||||||
|
CatanRules.initialState(config(CatanOptions(), seed = 7L))
|
||||||
|
.copy(
|
||||||
|
phase = CatanPhase.PLAYING,
|
||||||
|
turn = 0,
|
||||||
|
turnNumber = 1,
|
||||||
|
// Lumber to spare, brick missing, and a neighbour visibly
|
||||||
|
// holding brick. No roads or buildings, so no build is legal
|
||||||
|
// and no port is open — the offer is the only move forward.
|
||||||
|
resources = listOf(
|
||||||
|
hand(Resource.LUMBER to 4, Resource.WOOL to 1, Resource.GRAIN to 1),
|
||||||
|
hand(Resource.BRICK to 2),
|
||||||
|
hand(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun hand(vararg cards: Pair<Resource, Int>): Map<Resource, Int> =
|
||||||
|
Resource.entries.associateWith { cards.toMap()[it] ?: 0 }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the AI offers the trade that closes its build`() {
|
||||||
|
val state = offeringState()
|
||||||
|
val move = CatanAi().chooseMove(state, 0, CatanRules.legalMoves(state, 0))
|
||||||
|
assertEquals(
|
||||||
|
OfferTrade(to = 1, give = listOf(Resource.LUMBER, Resource.LUMBER), get = listOf(Resource.BRICK)),
|
||||||
|
move,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the AI offers at most one trade a turn`() {
|
||||||
|
// The same table, but the offer was already made and refused once.
|
||||||
|
val state = offeringState().copy(
|
||||||
|
log = listOf(
|
||||||
|
"Seat 0 rolls 3 + 2.",
|
||||||
|
"Seat 0 offers 2 lumber for a brick to seat 1.",
|
||||||
|
"Seat 1 declines the offer.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val move = CatanAi().chooseMove(state, 0, CatanRules.legalMoves(state, 0))
|
||||||
|
assertTrue("the AI re-offered within one turn: $move", move !is OfferTrade)
|
||||||
|
assertEquals(EndTurn, move)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the AI answers a pending offer with accept or decline`() {
|
||||||
|
val state = CatanRules.initialState(config(CatanOptions(), seed = 7L))
|
||||||
|
.copy(
|
||||||
|
phase = CatanPhase.TRADE_PENDING,
|
||||||
|
turn = 0,
|
||||||
|
turnNumber = 1,
|
||||||
|
pendingTrade = TradeOffer(
|
||||||
|
by = 0, to = 1,
|
||||||
|
give = listOf(Resource.LUMBER, Resource.LUMBER),
|
||||||
|
get = listOf(Resource.BRICK),
|
||||||
|
),
|
||||||
|
resources = listOf(
|
||||||
|
hand(Resource.LUMBER to 3),
|
||||||
|
hand(Resource.BRICK to 2, Resource.WOOL to 1),
|
||||||
|
hand(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val legal = CatanRules.legalMoves(state, 1)
|
||||||
|
val move = CatanAi().chooseMove(state, 1, legal)
|
||||||
|
assertTrue("neither accept nor decline: $move", move == AcceptTrade || move == DeclineTrade)
|
||||||
|
assertTrue(move in legal)
|
||||||
|
|
||||||
|
val after = CatanRules.applyMove(state, 1, move)
|
||||||
|
assertEquals(CatanPhase.PLAYING, after.phase)
|
||||||
|
assertNull(after.pendingTrade)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tradeTable(offer: TradeOffer, vararg hands: Map<Resource, Int>): CatanState =
|
||||||
|
CatanRules.initialState(config(CatanOptions(), seed = 7L)).copy(
|
||||||
|
phase = CatanPhase.TRADE_PENDING,
|
||||||
|
turn = 0,
|
||||||
|
turnNumber = 1,
|
||||||
|
pendingTrade = offer,
|
||||||
|
resources = hands.toList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the AI accepts an offer that gives it more than it costs, and declines the reverse`() {
|
||||||
|
val generous = tradeTable(
|
||||||
|
TradeOffer(by = 0, to = null, give = listOf(Resource.ORE, Resource.ORE), get = listOf(Resource.WOOL), asking = 1),
|
||||||
|
hand(Resource.ORE to 2), hand(Resource.WOOL to 2), hand(),
|
||||||
|
)
|
||||||
|
assertEquals(AcceptTrade, CatanAi().chooseMove(generous, 1, CatanRules.legalMoves(generous, 1)))
|
||||||
|
|
||||||
|
val stingy = tradeTable(
|
||||||
|
TradeOffer(by = 0, to = null, give = listOf(Resource.WOOL), get = listOf(Resource.ORE), asking = 1),
|
||||||
|
hand(Resource.WOOL to 1), hand(Resource.ORE to 2), hand(),
|
||||||
|
)
|
||||||
|
assertEquals(DeclineTrade, CatanAi().chooseMove(stingy, 1, CatanRules.legalMoves(stingy, 1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an AI offerer takes the best counter and refuses a losing one`() {
|
||||||
|
val counters = listOf(
|
||||||
|
TradeCounter(by = 1, give = listOf(Resource.ORE), get = listOf(Resource.LUMBER)),
|
||||||
|
TradeCounter(by = 2, give = listOf(Resource.ORE, Resource.GRAIN), get = listOf(Resource.WOOL)),
|
||||||
|
)
|
||||||
|
val state = tradeTable(
|
||||||
|
TradeOffer(by = 0, to = null, give = listOf(Resource.WOOL), get = listOf(Resource.ORE), asking = -1, counters = counters),
|
||||||
|
hand(Resource.LUMBER to 1, Resource.WOOL to 1), hand(Resource.ORE to 1), hand(Resource.ORE to 1, Resource.GRAIN to 1),
|
||||||
|
).copy(phase = CatanPhase.TRADE_COUNTERS)
|
||||||
|
val legal = CatanRules.legalMoves(state, 0)
|
||||||
|
assertEquals(AcceptCounter(2), CatanAi().chooseMove(state, 0, legal))
|
||||||
|
|
||||||
|
val bad = state.copy(
|
||||||
|
pendingTrade = state.pendingTrade!!.copy(
|
||||||
|
counters = listOf(TradeCounter(by = 1, give = listOf(Resource.WOOL), get = listOf(Resource.LUMBER))),
|
||||||
|
),
|
||||||
|
resources = listOf(hand(Resource.LUMBER to 1), hand(Resource.WOOL to 1), hand()),
|
||||||
|
)
|
||||||
|
assertEquals(DismissCounters, CatanAi().chooseMove(bad, 0, CatanRules.legalMoves(bad, 0)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package org.prolibertate.games.game.catan
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class CatanBoardTest {
|
||||||
|
|
||||||
|
// -- Geometry -----------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the island has 19 hexes, 54 vertices, 72 edges and 30 coastal edges`() {
|
||||||
|
assertEquals(19, CatanBoard.HEX_COUNT)
|
||||||
|
assertEquals(54, CatanBoard.VERTEX_COUNT)
|
||||||
|
assertEquals(72, CatanBoard.EDGE_COUNT)
|
||||||
|
assertEquals("the coast is one ring of 30 edges", 30, CatanBoard.COASTAL.size)
|
||||||
|
assertEquals("nine harbour positions", 9, CatanBoard.PORT_POSITIONS.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every hex has six distinct corners and every edge two distinct ends`() {
|
||||||
|
for (h in 0 until CatanBoard.HEX_COUNT) {
|
||||||
|
assertEquals("hex $h", 6, CatanBoard.HEX_CORNERS[h].size)
|
||||||
|
assertEquals("hex $h repeats a corner", 6, CatanBoard.HEX_CORNERS[h].distinct().size)
|
||||||
|
}
|
||||||
|
CatanBoard.EDGE_VERTICES.forEach { (a, b) -> assertTrue("edge loops on itself", a != b) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `two vertices never share a lattice position`() {
|
||||||
|
assertEquals(CatanBoard.VERTEX_COUNT, CatanBoard.VERTEX_POS.distinct().size)
|
||||||
|
assertEquals(CatanBoard.HEX_COUNT, (0 until CatanBoard.HEX_COUNT)
|
||||||
|
.map { CatanBoard.hexCenter(it) }
|
||||||
|
.distinct().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `adjacent hexes share exactly two vertices and one edge`() {
|
||||||
|
for (h in 0 until CatanBoard.HEX_COUNT) {
|
||||||
|
for (n in CatanBoard.HEX_NEIGHBORS[h]) {
|
||||||
|
val shared = CatanBoard.HEX_CORNERS[h].toSet()
|
||||||
|
.intersect(CatanBoard.HEX_CORNERS[n].toSet())
|
||||||
|
assertEquals("hexes $h and $n", 2, shared.size)
|
||||||
|
val sharedEdge = CatanBoard.EDGE_HEXES.indices.count { e ->
|
||||||
|
CatanBoard.EDGE_HEXES[e].containsAll(listOf(h, n))
|
||||||
|
}
|
||||||
|
assertEquals("hexes $h and $n", 1, sharedEdge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `vertices have two or three edges, each to a distinct neighbour`() {
|
||||||
|
for (v in 0 until CatanBoard.VERTEX_COUNT) {
|
||||||
|
assertTrue("vertex $v", CatanBoard.VERTEX_EDGES[v].size in 2..3)
|
||||||
|
assertEquals("vertex $v", CatanBoard.VERTEX_EDGES[v].size, CatanBoard.VERTEX_NEIGHBORS[v].size)
|
||||||
|
CatanBoard.VERTEX_NEIGHBORS[v].forEach { n ->
|
||||||
|
assertTrue(
|
||||||
|
"vertices $v and $n are not joined by an edge",
|
||||||
|
CatanBoard.VERTEX_EDGES[v].any { CatanBoard.otherEnd(it, v) == n },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `interior edges touch two hexes and coastal edges one`() {
|
||||||
|
CatanBoard.EDGE_HEXES.forEach { hexes -> assertTrue(hexes.size in 1..2) }
|
||||||
|
// Nineteen hexes times six edges each, counted per side: 114.
|
||||||
|
assertEquals(114, CatanBoard.EDGE_HEXES.sumOf { it.size })
|
||||||
|
val coastal = CatanBoard.EDGE_HEXES.indices.filter { CatanBoard.EDGE_HEXES[it].size == 1 }
|
||||||
|
assertEquals(CatanBoard.COASTAL.toSet(), coastal.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the coast is one closed ring and the harbours are spread along it`() {
|
||||||
|
CatanBoard.COASTAL.forEachIndexed { i, e ->
|
||||||
|
val next = CatanBoard.COASTAL[(i + 1) % CatanBoard.COASTAL.size]
|
||||||
|
val shared = ends(e).intersect(ends(next))
|
||||||
|
assertTrue("coastal edges $e and $next are not consecutive", shared.isNotEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(9, CatanBoard.PORT_POSITIONS.distinct().size)
|
||||||
|
assertTrue(CatanBoard.PORT_POSITIONS.all { it in CatanBoard.COASTAL })
|
||||||
|
val ringSpots = CatanBoard.PORT_POSITIONS.map { CatanBoard.COASTAL.indexOf(it) }.sorted()
|
||||||
|
val gaps = ringSpots.zipWithNext { a, b -> b - a } +
|
||||||
|
(CatanBoard.COASTAL.size - ringSpots.last() + ringSpots.first())
|
||||||
|
assertTrue("harbours sit closer than three edges apart: $gaps", gaps.all { it >= 3 })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `token odds follow the dice`() {
|
||||||
|
assertEquals(5, CatanBoard.numberOdds(6))
|
||||||
|
assertEquals(5, CatanBoard.numberOdds(8))
|
||||||
|
assertEquals(1, CatanBoard.numberOdds(2))
|
||||||
|
assertEquals(1, CatanBoard.numberOdds(12))
|
||||||
|
assertEquals(36, (2..12).sumOf { CatanBoard.numberOdds(it) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Island generation --------------------------------------------------
|
||||||
|
|
||||||
|
private val terrainCounts = mapOf(
|
||||||
|
Terrain.FOREST to 4,
|
||||||
|
Terrain.PASTURE to 4,
|
||||||
|
Terrain.FIELDS to 4,
|
||||||
|
Terrain.HILLS to 3,
|
||||||
|
Terrain.MOUNTAINS to 3,
|
||||||
|
Terrain.DESERT to 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The 18 number tokens dealt to the land, the seven withheld. */
|
||||||
|
private val tokens = listOf(6, 6, 8, 8, 2, 3, 3, 4, 4, 5, 5, 9, 9, 10, 10, 11, 11, 12)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every seed deals the fixed terrain mix`() {
|
||||||
|
for (seed in 1L..500L) {
|
||||||
|
val island = CatanBoard.generate(seed)
|
||||||
|
assertEquals("seed $seed", 19, island.terrains.size)
|
||||||
|
terrainCounts.forEach { (terrain, n) ->
|
||||||
|
assertEquals("seed $seed $terrain", n, island.terrains.count { it == terrain })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every seed deals the full token set and the desert stays bare`() {
|
||||||
|
for (seed in 1L..500L) {
|
||||||
|
val island = CatanBoard.generate(seed)
|
||||||
|
val dealt = island.numbers.filter { it > 0 }
|
||||||
|
assertEquals("seed $seed", 18, dealt.size)
|
||||||
|
assertEquals("seed $seed", tokens.sorted(), dealt.sorted())
|
||||||
|
assertEquals("seed $seed: the desert bears no token", 0, island.numbers[island.robber])
|
||||||
|
assertEquals(
|
||||||
|
"seed $seed: the robber does not start on the desert",
|
||||||
|
island.terrains.indexOf(Terrain.DESERT),
|
||||||
|
island.robber,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no seed puts two red tokens on adjacent hexes`() {
|
||||||
|
for (seed in 1L..500L) {
|
||||||
|
val island = CatanBoard.generate(seed)
|
||||||
|
val red = island.numbers.withIndex()
|
||||||
|
.filter { it.value == 6 || it.value == 8 }
|
||||||
|
.map { it.index }
|
||||||
|
assertEquals("seed $seed", 4, red.size)
|
||||||
|
for (i in red.indices) {
|
||||||
|
for (j in i + 1 until red.size) {
|
||||||
|
assertTrue(
|
||||||
|
"seed $seed: red hexes ${red[i]} and ${red[j]} touch",
|
||||||
|
!CatanBoard.hexesAdjacent(red[i], red[j]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every seed spreads nine harbours, five special and four generic`() {
|
||||||
|
for (seed in 1L..500L) {
|
||||||
|
val island = CatanBoard.generate(seed)
|
||||||
|
assertEquals("seed $seed", 9, island.ports.size)
|
||||||
|
assertEquals("seed $seed", 5, island.ports.count { it.resource != null })
|
||||||
|
assertEquals("seed $seed", 4, island.ports.count { it.resource == null })
|
||||||
|
assertTrue("seed $seed", island.ports.all { port ->
|
||||||
|
port.ratio == if (port.resource == null) 3 else 2
|
||||||
|
})
|
||||||
|
assertEquals(
|
||||||
|
"seed $seed: every resource has its own harbour",
|
||||||
|
Resource.entries,
|
||||||
|
island.ports.mapNotNull { it.resource }.sorted(),
|
||||||
|
)
|
||||||
|
assertEquals("seed $seed", 9, island.ports.map { it.edge }.distinct().size)
|
||||||
|
assertTrue("seed $seed", island.ports.all { it.edge in CatanBoard.COASTAL })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the same seed builds the same island`() {
|
||||||
|
for (seed in listOf(0L, 7L, 42L, 99L, -1L)) {
|
||||||
|
assertEquals("seed $seed", CatanBoard.generate(seed), CatanBoard.generate(seed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The two vertices of [edge], as a set. */
|
||||||
|
private fun ends(edge: Int): Set<Int> {
|
||||||
|
val (a, b) = CatanBoard.EDGE_VERTICES[edge]
|
||||||
|
return setOf(a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,182 @@
|
|||||||
|
package org.prolibertate.games.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.engine.PlayerKind
|
||||||
|
import org.prolibertate.games.game.engine.PlayerSlot
|
||||||
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Engines playing Cities & Knights against each other. These are the
|
||||||
|
* playtests: every move is checked for legality, the state is round-tripped
|
||||||
|
* through its wire format as it goes, nothing may leave a seat without a move,
|
||||||
|
* and the game has to reach a result.
|
||||||
|
*/
|
||||||
|
class CatanCkSelfPlayTest {
|
||||||
|
|
||||||
|
private val json = Json { encodeDefaults = true }
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** What one game looked like, for the summary printed at the end. */
|
||||||
|
private data class Report(
|
||||||
|
val seed: Long,
|
||||||
|
val moves: Int,
|
||||||
|
val turns: Int,
|
||||||
|
val attacks: Int,
|
||||||
|
val progressPlayed: Int,
|
||||||
|
val metropolises: Int,
|
||||||
|
val winner: Int?,
|
||||||
|
val points: List<Int>,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun playOut(options: CatanOptions, seed: Long, cap: Int = 30_000): Pair<CatanState, Report> {
|
||||||
|
val ai = CatanAi()
|
||||||
|
var state = CatanRules.initialState(config(options, seed))
|
||||||
|
var moves = 0
|
||||||
|
var attacks = 0
|
||||||
|
var progress = 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 before = state.ck!!.attacked
|
||||||
|
val wasAttacks = state.log.count { it.contains("The barbarians land") }
|
||||||
|
state = CatanRules.applyMove(state, seat, move)
|
||||||
|
attacks += state.log.count { it.contains("The barbarians land") } - wasAttacks
|
||||||
|
if (move is PlayProgress) progress++
|
||||||
|
if (!before && state.ck!!.attacked) Unit
|
||||||
|
|
||||||
|
if (moves % 97 == 0) {
|
||||||
|
val back = CatanRules.decodeState(CatanRules.encodeState(state))
|
||||||
|
assertEquals("seed $seed: the state did not survive its wire format", state, back)
|
||||||
|
// A seat's view never carries another seat's progress cards or the decks.
|
||||||
|
for (viewer in 0 until options.playerCount) {
|
||||||
|
val view = CatanRules.viewFor(state, viewer)
|
||||||
|
assertTrue(view.ck!!.decks.values.all { it.isEmpty() })
|
||||||
|
for (other in 0 until options.playerCount) {
|
||||||
|
if (other != viewer) {
|
||||||
|
val spied = (state.ck!!.chores.firstOrNull() as? SpyChore)
|
||||||
|
val allowed = spied != null && spied.seat == viewer && spied.target == other
|
||||||
|
if (!allowed) assertTrue(view.ck!!.progressHands[other].isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkInvariants(state, seed)
|
||||||
|
}
|
||||||
|
val ck = state.ck!!
|
||||||
|
return state to Report(
|
||||||
|
seed = seed,
|
||||||
|
moves = moves,
|
||||||
|
turns = state.turnNumber,
|
||||||
|
attacks = attacks,
|
||||||
|
progressPlayed = progress,
|
||||||
|
metropolises = ck.metropolises.size,
|
||||||
|
winner = state.winner,
|
||||||
|
points = CatanRules.publicVictoryPoints(state),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Things that must hold in every state of every game. */
|
||||||
|
private fun checkInvariants(state: CatanState, seed: Long) {
|
||||||
|
val ck = state.ck!!
|
||||||
|
val tag = "seed $seed turn ${state.turnNumber}"
|
||||||
|
// Cards are conserved: no good is held in more copies than the bank started with.
|
||||||
|
for (good in Good.entries) {
|
||||||
|
val held = (0 until state.playerCount).sumOf { CatanCk.goodsOf(state, it)[good] ?: 0 }
|
||||||
|
val cap = if (good.isCommodity) BANK_PER_COMMODITY else BANK_PER_RESOURCE
|
||||||
|
assertTrue("$tag: $held ${good} cards exist, the bank only had $cap", held in 0..cap)
|
||||||
|
}
|
||||||
|
for (seat in 0 until state.playerCount) {
|
||||||
|
assertTrue(
|
||||||
|
"$tag: seat $seat holds negative cards",
|
||||||
|
CatanCk.goodsOf(state, seat).values.all { it >= 0 },
|
||||||
|
)
|
||||||
|
for (level in 1..3) {
|
||||||
|
assertTrue(
|
||||||
|
"$tag: seat $seat has more than two level-$level knights",
|
||||||
|
ck.knights.count { it.owner == seat && it.level == level } <= KNIGHTS_PER_LEVEL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertTrue("$tag: too many progress cards in seat $seat's hand", ck.progressHands[seat].size <= PROGRESS_HAND_MAX + 1)
|
||||||
|
assertTrue("$tag: seat $seat has more than three walls", CatanCk.wallsOf(state, seat) <= MAX_WALLS)
|
||||||
|
assertTrue(
|
||||||
|
"$tag: seat $seat has more than five settlements",
|
||||||
|
state.buildings.count { it == CatanBuilding(BuildingType.SETTLEMENT, seat) } <= MAX_SETTLEMENTS,
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
"$tag: seat $seat has more than four cities",
|
||||||
|
state.buildings.count { it == CatanBuilding(BuildingType.CITY, seat) } <= MAX_CITIES,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// A knight stands on a crossing nothing else stands on, and no two share one.
|
||||||
|
assertEquals("$tag: two knights share a crossing", ck.knights.size, ck.knights.map { it.vertex }.toSet().size)
|
||||||
|
for (k in ck.knights) {
|
||||||
|
assertTrue("$tag: a knight stands on a building at ${k.vertex}", state.buildings[k.vertex] == null)
|
||||||
|
}
|
||||||
|
// Metropolises stand on cities of their owners.
|
||||||
|
for ((_, m) in ck.metropolises) {
|
||||||
|
assertEquals(
|
||||||
|
"$tag: a metropolis is not on its owner's city",
|
||||||
|
CatanBuilding(BuildingType.CITY, m.seat),
|
||||||
|
state.buildings[m.vertex],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Every progress card exists exactly once, in a deck or a hand, or was played face up.
|
||||||
|
val cards = ck.decks.values.sumOf { it.size } + ck.progressHands.sumOf { it.size } + ck.progressVp.sum()
|
||||||
|
assertEquals("$tag: progress cards were lost or duplicated", 54, cards)
|
||||||
|
assertTrue("$tag: barbarians out of range", ck.barbarians in 1..BARBARIAN_STEPS)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `three engines play Cities and Knights to a finish`() {
|
||||||
|
val reports = (1L..12L).map { seed ->
|
||||||
|
val (state, report) = playOut(
|
||||||
|
CatanOptions(playerCount = 3, pointsToWin = 13, ruleSet = CatanRuleSet.CITIES_AND_KNIGHTS),
|
||||||
|
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!!) >= 13,
|
||||||
|
)
|
||||||
|
report
|
||||||
|
}
|
||||||
|
reports.forEach { println("CK3 $it") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `four engines play Cities and Knights to a finish`() {
|
||||||
|
val reports = (101L..108L).map { seed ->
|
||||||
|
val (state, report) = playOut(
|
||||||
|
CatanOptions(playerCount = 4, pointsToWin = 13, ruleSet = CatanRuleSet.CITIES_AND_KNIGHTS),
|
||||||
|
seed,
|
||||||
|
)
|
||||||
|
assertEquals("seed $seed", CatanPhase.GAME_OVER, state.phase)
|
||||||
|
report
|
||||||
|
}
|
||||||
|
reports.forEach { println("CK4 $it") }
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,119 @@
|
|||||||
|
package org.prolibertate.games.game.engine
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.rules.TemporaryFolder
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class SavedGameStoreTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val folder = TemporaryFolder()
|
||||||
|
|
||||||
|
private fun store() = SavedGameStore(File(folder.root, "saves"))
|
||||||
|
|
||||||
|
private fun match(gameId: String = "chess", state: String = "{\"turn\":1}", at: Long = 1_000L) = SavedMatch(
|
||||||
|
gameId = gameId,
|
||||||
|
config = TableConfig(
|
||||||
|
gameId = gameId,
|
||||||
|
seats = listOf(
|
||||||
|
PlayerSlot(0, "You", PlayerKind.HUMAN_LOCAL, 0),
|
||||||
|
PlayerSlot(1, "Computer", PlayerKind.AI, 1),
|
||||||
|
),
|
||||||
|
optionsJson = "{}",
|
||||||
|
seed = 42L,
|
||||||
|
),
|
||||||
|
localSeat = 0,
|
||||||
|
stateJson = state,
|
||||||
|
savedAtMillis = at,
|
||||||
|
summary = "White to move",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a saved game comes back exactly as it went in`() {
|
||||||
|
val s = store()
|
||||||
|
s.save(match())
|
||||||
|
assertEquals(match(), s.load("chess"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `there is nothing to load before anything is saved`() {
|
||||||
|
assertNull(store().load("chess"))
|
||||||
|
assertTrue(store().all().isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a game keeps one slot, and saving again replaces it`() {
|
||||||
|
val s = store()
|
||||||
|
s.save(match(state = "one", at = 1))
|
||||||
|
s.save(match(state = "two", at = 2))
|
||||||
|
assertEquals("two", s.load("chess")!!.stateJson)
|
||||||
|
assertEquals(1, s.all().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clearing forgets the game and leaves the others`() {
|
||||||
|
val s = store()
|
||||||
|
s.save(match("chess"))
|
||||||
|
s.save(match("golf"))
|
||||||
|
s.clear("chess")
|
||||||
|
assertNull(s.load("chess"))
|
||||||
|
assertEquals("golf", s.load("golf")!!.gameId)
|
||||||
|
s.clear("chess") // clearing nothing is fine
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the most recently played game is listed first`() {
|
||||||
|
val s = store()
|
||||||
|
s.save(match("chess", at = 10))
|
||||||
|
s.save(match("golf", at = 30))
|
||||||
|
s.save(match("catan", at = 20))
|
||||||
|
assertEquals(listOf("golf", "catan", "chess"), s.all().map { it.gameId })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a file that cannot be read is dropped rather than thrown`() {
|
||||||
|
val s = store()
|
||||||
|
s.save(match())
|
||||||
|
File(folder.root, "saves/chess.json").writeText("{ this is not json")
|
||||||
|
assertNull(s.load("chess"))
|
||||||
|
assertFalse(File(folder.root, "saves/chess.json").exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a file holding another game is not served as this one`() {
|
||||||
|
val s = store()
|
||||||
|
s.save(match("golf"))
|
||||||
|
File(folder.root, "saves/golf.json").copyTo(File(folder.root, "saves/chess.json"))
|
||||||
|
assertNull(s.load("chess"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a save that was cut off half way leaves the previous position`() {
|
||||||
|
val s = store()
|
||||||
|
s.save(match(state = "good"))
|
||||||
|
// What a kill mid-write leaves behind: a stray temporary file.
|
||||||
|
File(folder.root, "saves/chess.json.tmp").writeText("{ half a wri")
|
||||||
|
assertEquals("good", s.load("chess")!!.stateJson)
|
||||||
|
assertEquals(1, s.all().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a game id cannot climb out of the folder`() {
|
||||||
|
val bad = runCatching { store().load("../secret") }
|
||||||
|
assertTrue(bad.isFailure)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unknown field from a newer build does not stop a save loading`() {
|
||||||
|
val s = store()
|
||||||
|
s.save(match())
|
||||||
|
val file = File(folder.root, "saves/chess.json")
|
||||||
|
file.writeText(file.readText().replaceFirst("{", "{\"addedLater\":true,"))
|
||||||
|
assertEquals("chess", s.load("chess")!!.gameId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package org.prolibertate.games.net
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.rules.TemporaryFolder
|
||||||
|
import org.prolibertate.games.game.engine.GameAi
|
||||||
|
import org.prolibertate.games.game.engine.GameRules
|
||||||
|
import org.prolibertate.games.game.engine.PlayerKind
|
||||||
|
import org.prolibertate.games.game.engine.PlayerSlot
|
||||||
|
import org.prolibertate.games.game.engine.SavedGameStore
|
||||||
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saving a table as it is played, and starting a table from a save.
|
||||||
|
*
|
||||||
|
* The game here is a counter: one seat, a move adds one, and it is finished at
|
||||||
|
* [GOAL]. It has hidden state — [Counter.secret] — so the tests can check that
|
||||||
|
* what is saved is the true state, not the view a player is shown.
|
||||||
|
*/
|
||||||
|
class MatchSaverTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val folder = TemporaryFolder()
|
||||||
|
|
||||||
|
private val scopes = mutableListOf<CoroutineScope>()
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
scopes.forEach { it.cancel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Counter(val n: Int, val secret: String)
|
||||||
|
|
||||||
|
private object Rules : GameRules<Counter, String> {
|
||||||
|
override val gameId = "counter"
|
||||||
|
override fun initialState(config: TableConfig) = Counter(0, "hidden-${config.seed}")
|
||||||
|
override fun currentSeat(state: Counter): Int? = if (state.n >= GOAL) null else 0
|
||||||
|
override fun legalMoves(state: Counter, seat: Int) = if (state.n >= GOAL) emptyList() else listOf("add")
|
||||||
|
override fun applyMove(state: Counter, seat: Int, move: String) = state.copy(n = state.n + 1)
|
||||||
|
override fun isFinished(state: Counter) = state.n >= GOAL
|
||||||
|
override fun summary(state: Counter) = "at ${state.n}"
|
||||||
|
override fun viewFor(state: Counter, seat: Int) = state.copy(secret = "")
|
||||||
|
override fun encodeState(state: Counter) = "${state.n}|${state.secret}"
|
||||||
|
override fun decodeState(json: String): Counter {
|
||||||
|
val (n, secret) = json.split("|", limit = 2)
|
||||||
|
return Counter(n.toInt(), secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun encodeMove(move: String) = move
|
||||||
|
override fun decodeMove(json: String) = json
|
||||||
|
}
|
||||||
|
|
||||||
|
private val config = TableConfig(
|
||||||
|
gameId = "counter",
|
||||||
|
seats = listOf(PlayerSlot(0, "You", PlayerKind.HUMAN_LOCAL, 0)),
|
||||||
|
optionsJson = "{}",
|
||||||
|
seed = 7L,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun scope() = CoroutineScope(Job() + Dispatchers.Default).also { scopes += it }
|
||||||
|
|
||||||
|
private fun controller(): MatchController<Counter, String> = MatchController(
|
||||||
|
rules = Rules,
|
||||||
|
ai = object : GameAi<Counter, String> {
|
||||||
|
override fun chooseMove(state: Counter, seat: Int, legal: List<String>) = legal.first()
|
||||||
|
},
|
||||||
|
config = config,
|
||||||
|
scope = scope(),
|
||||||
|
role = MatchController.Role.HOST,
|
||||||
|
localSeats = setOf(0),
|
||||||
|
primarySeat = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun store() = SavedGameStore(File(folder.root, "saves"))
|
||||||
|
|
||||||
|
private fun waitFor(what: String, check: () -> Boolean) = runBlocking {
|
||||||
|
repeat(200) {
|
||||||
|
if (check()) return@runBlocking
|
||||||
|
delay(10)
|
||||||
|
}
|
||||||
|
error("Timed out waiting for $what")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saver(s: SavedGameStore, c: MatchController<Counter, String>) =
|
||||||
|
MatchSaver(s, "counter", config, localSeat = 0, scope = scope(), clock = { 123L }).also {
|
||||||
|
it.start()
|
||||||
|
c.onHostState = it::offer
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every position the table reaches is kept, with the true state`() {
|
||||||
|
val s = store()
|
||||||
|
val c = controller()
|
||||||
|
saver(s, c)
|
||||||
|
c.startAsHost(emptyList())
|
||||||
|
waitFor("the deal") { c.state.value != null }
|
||||||
|
c.submit("add")
|
||||||
|
c.submit("add")
|
||||||
|
waitFor("two moves") { s.load("counter")?.stateJson?.startsWith("2|") == true }
|
||||||
|
|
||||||
|
val saved = s.load("counter")!!
|
||||||
|
assertEquals("2|hidden-7", saved.stateJson) // the hidden part is saved; the view blanks it
|
||||||
|
assertEquals("at 2", saved.summary)
|
||||||
|
assertEquals(123L, saved.savedAtMillis)
|
||||||
|
assertEquals(config, saved.config)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a table started from a save carries on from where it stood`() {
|
||||||
|
val s = store()
|
||||||
|
val first = controller()
|
||||||
|
saver(s, first)
|
||||||
|
first.startAsHost(emptyList())
|
||||||
|
waitFor("the deal") { first.state.value != null }
|
||||||
|
repeat(3) { first.submit("add") }
|
||||||
|
waitFor("three moves") { s.load("counter")?.stateJson?.startsWith("3|") == true }
|
||||||
|
first.close()
|
||||||
|
|
||||||
|
val second = controller()
|
||||||
|
assertTrue(second.restore(s.load("counter")!!.stateJson))
|
||||||
|
second.startAsHost(emptyList())
|
||||||
|
waitFor("the restored table") { second.state.value != null }
|
||||||
|
assertEquals(3, second.state.value!!.n)
|
||||||
|
second.submit("add")
|
||||||
|
waitFor("a move after resuming") { second.state.value!!.n == 4 }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a save the game cannot read is refused and the table is dealt afresh`() {
|
||||||
|
val c = controller()
|
||||||
|
assertFalse(c.restore("not a counter"))
|
||||||
|
c.startAsHost(emptyList())
|
||||||
|
waitFor("a fresh deal") { c.state.value != null }
|
||||||
|
assertEquals(0, c.state.value!!.n)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a finished game is not kept`() {
|
||||||
|
val s = store()
|
||||||
|
val c = controller()
|
||||||
|
saver(s, c)
|
||||||
|
c.startAsHost(emptyList())
|
||||||
|
waitFor("the deal") { s.load("counter") != null }
|
||||||
|
repeat(GOAL) { c.submit("add") }
|
||||||
|
waitFor("the finish") { c.finished.value }
|
||||||
|
waitFor("the slot to clear") { s.load("counter") == null }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `flush writes the position the background writer has not reached`() {
|
||||||
|
val s = store()
|
||||||
|
val saver = MatchSaver(s, "counter", config, 0, scope(), clock = { 1L })
|
||||||
|
// Not started: nothing is written in the background at all.
|
||||||
|
saver.offer(MatchController.HostSnapshot(false, "at 5") { "5|x" })
|
||||||
|
assertNull(s.load("counter"))
|
||||||
|
saver.flush()
|
||||||
|
assertEquals("5|x", s.load("counter")!!.stateJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ending a game on purpose forgets it and writes no more`() {
|
||||||
|
val s = store()
|
||||||
|
val saver = MatchSaver(s, "counter", config, 0, scope(), clock = { 1L })
|
||||||
|
saver.offer(MatchController.HostSnapshot(false, "at 1") { "1|x" })
|
||||||
|
saver.flush()
|
||||||
|
assertNotNull(s.load("counter"))
|
||||||
|
saver.discard()
|
||||||
|
assertNull(s.load("counter"))
|
||||||
|
// Leaving after ending must not put it back.
|
||||||
|
saver.offer(MatchController.HostSnapshot(false, "at 2") { "2|x" })
|
||||||
|
saver.flush()
|
||||||
|
assertNull(s.load("counter"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val GOAL = 5
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package org.prolibertate.games.net
|
||||||
|
|
||||||
|
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.engine.GameAi
|
||||||
|
import org.prolibertate.games.game.engine.GameRules
|
||||||
|
import org.prolibertate.games.game.engine.PlayerKind
|
||||||
|
import org.prolibertate.games.game.engine.PlayerSlot
|
||||||
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
import org.prolibertate.games.ui.seatCountFor
|
||||||
|
import org.prolibertate.games.game.backgammon.BackgammonAi
|
||||||
|
import org.prolibertate.games.game.backgammon.BackgammonOptions
|
||||||
|
import org.prolibertate.games.game.backgammon.BackgammonRules
|
||||||
|
import org.prolibertate.games.game.catan.CatanAi
|
||||||
|
import org.prolibertate.games.game.catan.CatanOptions
|
||||||
|
import org.prolibertate.games.game.catan.CatanRules
|
||||||
|
import org.prolibertate.games.game.checkers.CheckersAi
|
||||||
|
import org.prolibertate.games.game.checkers.CheckersOptions
|
||||||
|
import org.prolibertate.games.game.checkers.CheckersRules
|
||||||
|
import org.prolibertate.games.game.chess.ChessAi
|
||||||
|
import org.prolibertate.games.game.chess.ChessOptions
|
||||||
|
import org.prolibertate.games.game.chess.ChessRules
|
||||||
|
import org.prolibertate.games.game.crazyeights.CrazyEightsAi
|
||||||
|
import org.prolibertate.games.game.crazyeights.CrazyEightsOptions
|
||||||
|
import org.prolibertate.games.game.crazyeights.CrazyEightsRules
|
||||||
|
import org.prolibertate.games.game.cribbage.CribbageAi
|
||||||
|
import org.prolibertate.games.game.cribbage.CribbageOptions
|
||||||
|
import org.prolibertate.games.game.cribbage.CribbageRules
|
||||||
|
import org.prolibertate.games.game.euchre.EuchreAi
|
||||||
|
import org.prolibertate.games.game.euchre.EuchreOptions
|
||||||
|
import org.prolibertate.games.game.euchre.EuchreRules
|
||||||
|
import org.prolibertate.games.game.freecell.FreeCellOptions
|
||||||
|
import org.prolibertate.games.game.freecell.FreeCellRules
|
||||||
|
import org.prolibertate.games.game.golf.GolfAi
|
||||||
|
import org.prolibertate.games.game.golf.GolfOptions
|
||||||
|
import org.prolibertate.games.game.golf.GolfRules
|
||||||
|
import org.prolibertate.games.game.hearts.HeartsAi
|
||||||
|
import org.prolibertate.games.game.hearts.HeartsOptions
|
||||||
|
import org.prolibertate.games.game.hearts.HeartsRules
|
||||||
|
import org.prolibertate.games.game.kaiser.KaiserAi
|
||||||
|
import org.prolibertate.games.game.kaiser.KaiserOptions
|
||||||
|
import org.prolibertate.games.game.kaiser.KaiserRules
|
||||||
|
import org.prolibertate.games.game.klondike.KlondikeAi
|
||||||
|
import org.prolibertate.games.game.klondike.KlondikeOptions
|
||||||
|
import org.prolibertate.games.game.klondike.KlondikeRules
|
||||||
|
import org.prolibertate.games.game.mastermind.MastermindAi
|
||||||
|
import org.prolibertate.games.game.mastermind.MastermindOptions
|
||||||
|
import org.prolibertate.games.game.mastermind.MastermindRules
|
||||||
|
import org.prolibertate.games.game.morris.MorrisAi
|
||||||
|
import org.prolibertate.games.game.morris.MorrisOptions
|
||||||
|
import org.prolibertate.games.game.morris.MorrisRules
|
||||||
|
import org.prolibertate.games.game.pegsolitaire.PegSolitaireAi
|
||||||
|
import org.prolibertate.games.game.pegsolitaire.PegSolitaireOptions
|
||||||
|
import org.prolibertate.games.game.pegsolitaire.PegSolitaireRules
|
||||||
|
import org.prolibertate.games.game.pirates.PiratesAi
|
||||||
|
import org.prolibertate.games.game.pirates.PiratesOptions
|
||||||
|
import org.prolibertate.games.game.pirates.PiratesRules
|
||||||
|
import org.prolibertate.games.game.president.PresidentAi
|
||||||
|
import org.prolibertate.games.game.president.PresidentOptions
|
||||||
|
import org.prolibertate.games.game.president.PresidentRules
|
||||||
|
import org.prolibertate.games.game.pyramid.PyramidOptions
|
||||||
|
import org.prolibertate.games.game.pyramid.PyramidRules
|
||||||
|
import org.prolibertate.games.game.sequence.SequenceAi
|
||||||
|
import org.prolibertate.games.game.sequence.SequenceOptions
|
||||||
|
import org.prolibertate.games.game.sequence.SequenceRules
|
||||||
|
import org.prolibertate.games.game.solitaire.FirstLegalAi
|
||||||
|
import org.prolibertate.games.game.spider.SpiderOptions
|
||||||
|
import org.prolibertate.games.game.spider.SpiderRules
|
||||||
|
import org.prolibertate.games.game.tayu.TayuAi
|
||||||
|
import org.prolibertate.games.game.tayu.TayuOptions
|
||||||
|
import org.prolibertate.games.game.tayu.TayuRules
|
||||||
|
import org.prolibertate.games.game.wizard.WizardAi
|
||||||
|
import org.prolibertate.games.game.wizard.WizardOptions
|
||||||
|
import org.prolibertate.games.game.wizard.WizardRules
|
||||||
|
import org.prolibertate.games.game.yahtzee.YahtzeeAi
|
||||||
|
import org.prolibertate.games.game.yahtzee.YahtzeeOptions
|
||||||
|
import org.prolibertate.games.game.yahtzee.YahtzeeRules
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every game can be saved and picked up again.
|
||||||
|
*
|
||||||
|
* For each game in the catalogue a table is dealt and played by the computer, and
|
||||||
|
* at every single move the whole true state is encoded and decoded again — which
|
||||||
|
* is exactly what saving it to disk and resuming does — and play carries on from
|
||||||
|
* the decoded copy. A game whose state does not survive that, or that cannot make
|
||||||
|
* a legal move from a state it has been through it, fails here.
|
||||||
|
*/
|
||||||
|
class SaveRoundTripAllGamesTest {
|
||||||
|
|
||||||
|
private val json = Json { encodeDefaults = true }
|
||||||
|
|
||||||
|
private fun <S : Any, M : Any> exercise(
|
||||||
|
gameId: String,
|
||||||
|
rules: GameRules<S, M>,
|
||||||
|
ai: GameAi<S, M>,
|
||||||
|
optionsJson: String,
|
||||||
|
steps: Int = 120,
|
||||||
|
) {
|
||||||
|
val seats = seatCountFor(gameId, optionsJson)
|
||||||
|
val config = TableConfig(
|
||||||
|
gameId = gameId,
|
||||||
|
seats = (0 until seats).map {
|
||||||
|
PlayerSlot(it, "P$it", if (it == 0) PlayerKind.HUMAN_LOCAL else PlayerKind.AI, it)
|
||||||
|
},
|
||||||
|
optionsJson = optionsJson,
|
||||||
|
seed = 20240607L,
|
||||||
|
)
|
||||||
|
var state = rules.initialState(config)
|
||||||
|
var moves = 0
|
||||||
|
repeat(steps) {
|
||||||
|
if (rules.isFinished(state)) return
|
||||||
|
// The save and the resume: what goes to disk, and what comes back.
|
||||||
|
val saved = rules.encodeState(state)
|
||||||
|
val back = rules.decodeState(saved)
|
||||||
|
assertEquals("$gameId: a saved state encodes differently after a resume", saved, rules.encodeState(back))
|
||||||
|
assertEquals(
|
||||||
|
"$gameId: a resumed state is not on the same seat's turn",
|
||||||
|
rules.currentSeat(state),
|
||||||
|
rules.currentSeat(back),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"$gameId: a resumed state has different legal moves",
|
||||||
|
rules.currentSeat(state)?.let { rules.legalMoves(state, it) },
|
||||||
|
rules.currentSeat(back)?.let { rules.legalMoves(back, it) },
|
||||||
|
)
|
||||||
|
state = back
|
||||||
|
|
||||||
|
val seat = rules.currentSeat(state) ?: return // waiting on a scoreboard, which is not a move
|
||||||
|
val legal = rules.legalMoves(state, seat)
|
||||||
|
if (legal.isEmpty()) return
|
||||||
|
val move = ai.chooseMove(state, seat, legal)
|
||||||
|
assertTrue("$gameId: move $move is not legal after a resume", rules.isLegalMove(state, seat, move))
|
||||||
|
state = rules.applyMove(state, seat, move)
|
||||||
|
moves++
|
||||||
|
}
|
||||||
|
assertTrue("$gameId: made no moves", moves > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <reified O> opts(o: O) = json.encodeToString(o)
|
||||||
|
|
||||||
|
@Test fun euchre() = exercise(GameCatalog.EUCHRE, EuchreRules, EuchreAi(), opts(EuchreOptions()))
|
||||||
|
@Test fun sequence() = exercise(GameCatalog.SEQUENCE, SequenceRules, SequenceAi(), opts(SequenceOptions()))
|
||||||
|
@Test fun president() = exercise(GameCatalog.PRESIDENT, PresidentRules, PresidentAi(), opts(PresidentOptions()))
|
||||||
|
@Test fun golf() = exercise(GameCatalog.GOLF, GolfRules, GolfAi(), opts(GolfOptions()))
|
||||||
|
@Test fun kaiser() = exercise(GameCatalog.KAISER, KaiserRules, KaiserAi(), opts(KaiserOptions()))
|
||||||
|
@Test fun wizard() = exercise(GameCatalog.WIZARD, WizardRules, WizardAi(), opts(WizardOptions()))
|
||||||
|
@Test fun crazyEights() = exercise(GameCatalog.CRAZY_EIGHTS, CrazyEightsRules, CrazyEightsAi(), opts(CrazyEightsOptions()))
|
||||||
|
@Test fun cribbage() = exercise(GameCatalog.CRIBBAGE, CribbageRules, CribbageAi(), opts(CribbageOptions()))
|
||||||
|
@Test fun tayu() = exercise(GameCatalog.TAYU, TayuRules, TayuAi(), opts(TayuOptions()))
|
||||||
|
@Test fun checkers() = exercise(GameCatalog.CHECKERS, CheckersRules, CheckersAi(), opts(CheckersOptions()))
|
||||||
|
@Test fun backgammon() = exercise(GameCatalog.BACKGAMMON, BackgammonRules, BackgammonAi(), opts(BackgammonOptions()))
|
||||||
|
@Test fun mastermind() = exercise(GameCatalog.MASTERMIND, MastermindRules, MastermindAi(), opts(MastermindOptions()))
|
||||||
|
@Test fun pirates() = exercise(GameCatalog.PIRATES, PiratesRules, PiratesAi(), opts(PiratesOptions()))
|
||||||
|
@Test fun hearts() = exercise(GameCatalog.HEARTS, HeartsRules, HeartsAi, opts(HeartsOptions()))
|
||||||
|
@Test fun pegSolitaire() = exercise(GameCatalog.PEG_SOLITAIRE, PegSolitaireRules, PegSolitaireAi, opts(PegSolitaireOptions()))
|
||||||
|
@Test fun yahtzee() = exercise(GameCatalog.YAHTZEE, YahtzeeRules, YahtzeeAi, opts(YahtzeeOptions()))
|
||||||
|
@Test fun klondike() = exercise(GameCatalog.KLONDIKE, KlondikeRules, KlondikeAi, opts(KlondikeOptions()))
|
||||||
|
@Test fun freecell() = exercise(GameCatalog.FREECELL, FreeCellRules, FirstLegalAi(), opts(FreeCellOptions()))
|
||||||
|
@Test fun spider() = exercise(GameCatalog.SPIDER, SpiderRules, FirstLegalAi(), opts(SpiderOptions()))
|
||||||
|
@Test fun pyramid() = exercise(GameCatalog.PYRAMID, PyramidRules, FirstLegalAi(), opts(PyramidOptions()))
|
||||||
|
@Test fun morris() = exercise(GameCatalog.MORRIS, MorrisRules, MorrisAi(), opts(MorrisOptions()))
|
||||||
|
@Test fun chess() = exercise(GameCatalog.CHESS, ChessRules, ChessAi(), opts(ChessOptions()), steps = 24)
|
||||||
|
@Test fun catan() = exercise(GameCatalog.CATAN, CatanRules, CatanAi(), opts(CatanOptions()), steps = 400)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun catanCitiesAndKnights() = exercise(
|
||||||
|
GameCatalog.CATAN,
|
||||||
|
CatanRules,
|
||||||
|
CatanAi(),
|
||||||
|
opts(CatanOptions(3, 13, org.prolibertate.games.game.catan.CatanRuleSet.CITIES_AND_KNIGHTS)),
|
||||||
|
steps = 600,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -145,6 +145,11 @@ class ScorekeeperSessionTest {
|
|||||||
await("$name to be asking") { host.state.requests.any { it.id == deviceId } }
|
await("$name to be asking") { host.state.requests.any { it.id == deviceId } }
|
||||||
host.session.allow(deviceId)
|
host.session.allow(deviceId)
|
||||||
await("$name to be let in") { guest.state.value.connected }
|
await("$name to be let in") { guest.state.value.connected }
|
||||||
|
// The sheet follows the connection over the socket, so `connected` can
|
||||||
|
// flip while it is still in flight. Callers read the guest's sheet at
|
||||||
|
// once (its players, its rounds), and on a slow runner that was an empty
|
||||||
|
// one: wait until the guest is looking at the host's.
|
||||||
|
await("$name to receive the sheet") { guest.state.value.sheet == host.state.sheet }
|
||||||
return guest
|
return guest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package org.prolibertate.games.ui
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class SavedGameTextTest {
|
||||||
|
private val minute = 60_000L
|
||||||
|
private val hour = 60 * minute
|
||||||
|
private val day = 24 * hour
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `how long ago reads like a person would say it`() {
|
||||||
|
assertEquals("just now", agoText(1_000, 1_000))
|
||||||
|
assertEquals("just now", agoText(30_000, 0))
|
||||||
|
assertEquals("a minute ago", agoText(minute + 20_000, 0))
|
||||||
|
assertEquals("5 minutes ago", agoText(5 * minute, 0))
|
||||||
|
assertEquals("an hour ago", agoText(90 * minute, 0))
|
||||||
|
assertEquals("3 hours ago", agoText(3 * hour, 0))
|
||||||
|
assertEquals("yesterday", agoText(30 * hour, 0))
|
||||||
|
assertEquals("4 days ago", agoText(4 * day, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a clock that ran backwards does not say nonsense`() {
|
||||||
|
assertEquals("just now", agoText(0, 10 * minute))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
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.AcceptCounter
|
||||||
|
import org.prolibertate.games.game.catan.AcceptTrade
|
||||||
|
import org.prolibertate.games.game.catan.CounterGoods
|
||||||
|
import org.prolibertate.games.game.catan.DismissCounters
|
||||||
|
import org.prolibertate.games.game.catan.ActivateKnight
|
||||||
|
import org.prolibertate.games.game.catan.BuildCity
|
||||||
|
import org.prolibertate.games.game.catan.BuildRoad
|
||||||
|
import org.prolibertate.games.game.catan.BuildSettlement
|
||||||
|
import org.prolibertate.games.game.catan.BuildWall
|
||||||
|
import org.prolibertate.games.game.catan.BuyImprovement
|
||||||
|
import org.prolibertate.games.game.catan.CatanAi
|
||||||
|
import org.prolibertate.games.game.catan.CatanBoard
|
||||||
|
import org.prolibertate.games.game.catan.CatanMove
|
||||||
|
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.ChaseRobber
|
||||||
|
import org.prolibertate.games.game.catan.DeclineTrade
|
||||||
|
import org.prolibertate.games.game.catan.DiscardGoods
|
||||||
|
import org.prolibertate.games.game.catan.DropProgress
|
||||||
|
import org.prolibertate.games.game.catan.EndFreeRoads
|
||||||
|
import org.prolibertate.games.game.catan.EndSmith
|
||||||
|
import org.prolibertate.games.game.catan.EndTurn
|
||||||
|
import org.prolibertate.games.game.catan.GiveGoods
|
||||||
|
import org.prolibertate.games.game.catan.GoodsBankTrade
|
||||||
|
import org.prolibertate.games.game.catan.HireKnight
|
||||||
|
import org.prolibertate.games.game.catan.MoveKnight
|
||||||
|
import org.prolibertate.games.game.catan.MoveRobber
|
||||||
|
import org.prolibertate.games.game.catan.OfferGoods
|
||||||
|
import org.prolibertate.games.game.catan.PickDeck
|
||||||
|
import org.prolibertate.games.game.catan.PillageCity
|
||||||
|
import org.prolibertate.games.game.catan.PlaceKnight
|
||||||
|
import org.prolibertate.games.game.catan.PlaceMetropolis
|
||||||
|
import org.prolibertate.games.game.catan.PlayProgress
|
||||||
|
import org.prolibertate.games.game.catan.PromoteKnight
|
||||||
|
import org.prolibertate.games.game.catan.RelocateKnight
|
||||||
|
import org.prolibertate.games.game.catan.RemoveKnight
|
||||||
|
import org.prolibertate.games.game.catan.RollDice
|
||||||
|
import org.prolibertate.games.game.catan.SetupPlace
|
||||||
|
import org.prolibertate.games.game.catan.SpyTake
|
||||||
|
import org.prolibertate.games.game.catan.TakeAqueduct
|
||||||
|
import org.prolibertate.games.game.engine.PlayerKind
|
||||||
|
import org.prolibertate.games.game.engine.PlayerSlot
|
||||||
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cities & Knights has no device in this test suite, so the screen's logic is
|
||||||
|
* checked another way: engines play whole games, and at every position the
|
||||||
|
* player on the clock is treated as the local one. For every move the engine says
|
||||||
|
* is legal, [CatanInteraction] (and the dialogs' own filters) must offer a way to
|
||||||
|
* make it — a tap, a button or a dialog choice. A legal move that no control can
|
||||||
|
* produce would be a stuck game on a real phone.
|
||||||
|
*/
|
||||||
|
class CatanCkInteractionTest {
|
||||||
|
|
||||||
|
private val json = Json { encodeDefaults = true }
|
||||||
|
|
||||||
|
private fun config(players: Int, seed: Long) = TableConfig(
|
||||||
|
gameId = GameCatalog.CATAN,
|
||||||
|
seats = (0 until players).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
|
||||||
|
optionsJson = json.encodeToString(
|
||||||
|
CatanOptions(players, pointsToWin = 13, ruleSet = CatanRuleSet.CITIES_AND_KNIGHTS),
|
||||||
|
),
|
||||||
|
seed = seed,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** How a move reaches the engine from the screen, or null if nothing can produce it. */
|
||||||
|
private fun reachable(state: CatanState, legal: List<CatanMove>, move: CatanMove): String? {
|
||||||
|
fun tapVertex(vertex: Int, building: BuildKind? = null, moving: Int? = null, armed: ArmedCard? = null): TapOutcome =
|
||||||
|
CatanInteraction.onVertexTap(vertex, legal, building, null, moving, armed)
|
||||||
|
|
||||||
|
return when (move) {
|
||||||
|
is SetupPlace -> {
|
||||||
|
val vertex = CatanInteraction.targets(state, legal, null, null).vertices
|
||||||
|
val edges = CatanInteraction.targets(state, legal, null, move.vertex).edges
|
||||||
|
"setup".takeIf {
|
||||||
|
move.vertex in vertex && move.edge in edges &&
|
||||||
|
CatanInteraction.onVertexTap(move.vertex, legal, null, null) is TapOutcome.PickSetupVertex &&
|
||||||
|
CatanInteraction.onEdgeTap(move.edge, legal, null, move.vertex) == TapOutcome.Submit(move)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is BuildRoad -> {
|
||||||
|
val targets = CatanInteraction.targets(state, legal, BuildKind.ROAD, null)
|
||||||
|
"road".takeIf {
|
||||||
|
move.edge in targets.edges &&
|
||||||
|
CatanInteraction.onEdgeTap(move.edge, legal, BuildKind.ROAD, null) == TapOutcome.Submit(move)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is BuildSettlement -> viaBuild(state, legal, BuildKind.SETTLEMENT, move.vertex, move)
|
||||||
|
is BuildCity -> viaBuild(state, legal, BuildKind.CITY, move.vertex, move)
|
||||||
|
is BuildWall -> viaBuild(state, legal, BuildKind.WALL, move.vertex, move)
|
||||||
|
is HireKnight -> viaBuild(state, legal, BuildKind.KNIGHT, move.vertex, move)
|
||||||
|
|
||||||
|
is MoveRobber -> {
|
||||||
|
val hex = CatanBoard.hexIndex.getValue(move.hex)
|
||||||
|
val targets = CatanInteraction.targets(state, legal, null, null)
|
||||||
|
val tap = CatanInteraction.onHexTap(hex, legal)
|
||||||
|
"robber".takeIf {
|
||||||
|
hex in targets.hexes &&
|
||||||
|
(tap == TapOutcome.Submit(move) || (tap is TapOutcome.PickRobberVictim && move.victim in tap.victims))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is MoveKnight -> {
|
||||||
|
val targets = CatanInteraction.targets(state, legal, null, null, movingKnight = move.from)
|
||||||
|
"moveKnight".takeIf {
|
||||||
|
move.to in targets.vertices && tapVertex(move.to, moving = move.from) == TapOutcome.Submit(move) &&
|
||||||
|
move.from in CatanInteraction.targets(state, legal, null, null).knights &&
|
||||||
|
CatanInteraction.knightActions(legal, move.from).any { it is MoveKnight }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is ActivateKnight, is ChaseRobber, is PromoteKnight -> {
|
||||||
|
val vertex = when (move) {
|
||||||
|
is ActivateKnight -> move.vertex
|
||||||
|
is ChaseRobber -> move.vertex
|
||||||
|
else -> (move as PromoteKnight).vertex
|
||||||
|
}
|
||||||
|
val smith = legal.any { it is EndSmith }
|
||||||
|
if (move is PromoteKnight && smith) {
|
||||||
|
val targets = CatanInteraction.targets(state, legal, null, null)
|
||||||
|
"smith".takeIf {
|
||||||
|
vertex in targets.vertices && tapVertex(vertex) == TapOutcome.Submit(move)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val targets = CatanInteraction.targets(state, legal, null, null)
|
||||||
|
"knightMenu".takeIf {
|
||||||
|
vertex in targets.knights && tapVertex(vertex) == TapOutcome.PickKnight(vertex) &&
|
||||||
|
move in CatanInteraction.knightActions(legal, vertex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is PillageCity -> viaChore(state, legal, move.vertex, move)
|
||||||
|
is PlaceMetropolis -> viaChore(state, legal, move.vertex, move)
|
||||||
|
is RelocateKnight -> viaChore(state, legal, move.to, move)
|
||||||
|
is RemoveKnight -> viaChore(state, legal, move.vertex, move)
|
||||||
|
is PlaceKnight -> viaChore(state, legal, move.vertex, move)
|
||||||
|
|
||||||
|
is PlayProgress -> viaCard(state, legal, move)
|
||||||
|
|
||||||
|
is BuyImprovement -> "improve".takeIf { move.track in CatanInteraction.improvable(legal) }
|
||||||
|
|
||||||
|
is GoodsBankTrade ->
|
||||||
|
"bankTrade".takeIf { move.get in CatanInteraction.goodsBankTrades(legal)[move.give].orEmpty() }
|
||||||
|
|
||||||
|
RollDice(), is RollDice -> "roll".takeIf { CatanInteraction.canRoll(legal) }
|
||||||
|
EndTurn -> "endTurn".takeIf { CatanInteraction.canEndTurn(legal) }
|
||||||
|
EndFreeRoads -> "endRoads".takeIf { CatanInteraction.canEndFreeRoads(legal) }
|
||||||
|
EndSmith -> "endSmith".takeIf { CatanInteraction.canEndSmith(legal) }
|
||||||
|
AcceptTrade, DeclineTrade -> "tradeReply".takeIf { CatanInteraction.tradeResponses(legal) != (false to false) }
|
||||||
|
|
||||||
|
// Answered in a dialog that offers exactly the moves in the legal list.
|
||||||
|
is PickDeck, is DropProgress, is TakeAqueduct, is SpyTake -> "choreDialog"
|
||||||
|
|
||||||
|
// Structural: the discard and give dialogs build these and ask the engine.
|
||||||
|
is DiscardGoods, is GiveGoods -> "goodsDialog"
|
||||||
|
is OfferGoods, is CounterGoods -> "tradeDialog"
|
||||||
|
is AcceptCounter, DismissCounters -> "counterDialog"
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun viaBuild(state: CatanState, legal: List<CatanMove>, kind: BuildKind, vertex: Int, move: CatanMove): String? {
|
||||||
|
val targets = CatanInteraction.targets(state, legal, kind, null)
|
||||||
|
return "build $kind".takeIf {
|
||||||
|
vertex in targets.vertices &&
|
||||||
|
CatanInteraction.onVertexTap(vertex, legal, kind, null) == TapOutcome.Submit(move) &&
|
||||||
|
kind in CatanInteraction.canBuild(legal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun viaChore(state: CatanState, legal: List<CatanMove>, vertex: Int, move: CatanMove): String? {
|
||||||
|
val targets = CatanInteraction.targets(state, legal, null, null)
|
||||||
|
return "chore".takeIf {
|
||||||
|
vertex in targets.vertices &&
|
||||||
|
CatanInteraction.onVertexTap(vertex, legal, null, null) == TapOutcome.Submit(move)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun viaCard(state: CatanState, legal: List<CatanMove>, move: PlayProgress): String? {
|
||||||
|
val seat = CatanRules.currentSeat(state) ?: return null
|
||||||
|
val entry = CatanInteraction.progressHand(state, seat, legal).firstOrNull { it.card.id == move.card }
|
||||||
|
?: return null
|
||||||
|
val kind = entry.card.kind
|
||||||
|
if (move !in entry.plays) return null
|
||||||
|
if (kind !in CatanInteraction.BOARD_CARDS) return "cardDialog"
|
||||||
|
|
||||||
|
val armed = ArmedCard(kind, entry.plays)
|
||||||
|
val targets = CatanInteraction.targets(state, legal, null, null, armed = armed)
|
||||||
|
return when (kind) {
|
||||||
|
org.prolibertate.games.game.catan.ProgressType.DIPLOMAT ->
|
||||||
|
"armed".takeIf {
|
||||||
|
move.edge in targets.edges &&
|
||||||
|
CatanInteraction.onEdgeTap(move.edge!!, legal, null, null, armed) == TapOutcome.Submit(move)
|
||||||
|
}
|
||||||
|
|
||||||
|
org.prolibertate.games.game.catan.ProgressType.MERCHANT ->
|
||||||
|
"armed".takeIf {
|
||||||
|
move.hexA in targets.hexes &&
|
||||||
|
CatanInteraction.onHexTap(move.hexA!!, legal, armed) == TapOutcome.Submit(move)
|
||||||
|
}
|
||||||
|
|
||||||
|
org.prolibertate.games.game.catan.ProgressType.INVENTOR -> {
|
||||||
|
val first = CatanInteraction.onHexTap(move.hexA!!, legal, armed)
|
||||||
|
if (first != TapOutcome.PickFirstHex(move.hexA!!)) return null
|
||||||
|
val second = armed.copy(firstHex = move.hexA)
|
||||||
|
val partners = CatanInteraction.targets(state, legal, null, null, armed = second).hexes
|
||||||
|
"armed".takeIf {
|
||||||
|
move.hexB in partners &&
|
||||||
|
CatanInteraction.onHexTap(move.hexB!!, legal, second) == TapOutcome.Submit(move)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else ->
|
||||||
|
"armed".takeIf {
|
||||||
|
move.vertex in targets.vertices &&
|
||||||
|
CatanInteraction.onVertexTap(move.vertex!!, legal, null, null, null, armed) == TapOutcome.Submit(move)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun playAndCheck(players: Int, seed: Long): Map<String, Int> {
|
||||||
|
val ai = CatanAi()
|
||||||
|
var state = CatanRules.initialState(config(players, seed))
|
||||||
|
val seen = mutableMapOf<String, Int>()
|
||||||
|
var moves = 0
|
||||||
|
while (!CatanRules.isFinished(state) && moves++ < 20_000) {
|
||||||
|
val seat = CatanRules.currentSeat(state) ?: break
|
||||||
|
val legal = CatanRules.legalMoves(state, seat)
|
||||||
|
for (move in legal) {
|
||||||
|
val how = reachable(state, legal, move)
|
||||||
|
assertTrue(
|
||||||
|
"seed $seed: no control produces $move in ${state.phase} " +
|
||||||
|
"(chore ${state.ck?.chores?.firstOrNull()}); log tail: ${state.log.takeLast(3)}",
|
||||||
|
how != null,
|
||||||
|
)
|
||||||
|
seen.merge(how!!, 1, Int::plus)
|
||||||
|
}
|
||||||
|
state = CatanRules.applyMove(state, seat, ai.chooseMove(state, seat, legal))
|
||||||
|
}
|
||||||
|
return seen
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every legal move in a full game has a control that produces it`() {
|
||||||
|
val total = mutableMapOf<String, Int>()
|
||||||
|
listOf(3 to 21L, 4 to 22L, 3 to 23L, 4 to 24L).forEach { (players, seed) ->
|
||||||
|
playAndCheck(players, seed).forEach { (k, v) -> total.merge(k, v, Int::plus) }
|
||||||
|
}
|
||||||
|
println("CK UI coverage: ${total.toSortedMap()}")
|
||||||
|
// The ways the screen builds, moves and answers all got exercised.
|
||||||
|
listOf("setup", "road", "build SETTLEMENT", "build CITY", "knightMenu", "improve", "bankTrade", "roll", "endTurn")
|
||||||
|
.forEach { assertTrue("never exercised: $it in $total", (total[it] ?: 0) > 0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a sleeping knight with nothing to do can still be tapped for its menu`() {
|
||||||
|
val base = CatanRules.initialState(config(3, 5L))
|
||||||
|
.copy(phase = CatanPhase.PLAYING, turn = 0, turnNumber = 1)
|
||||||
|
val state = base.copy(
|
||||||
|
ck = base.ck!!.copy(knights = listOf(org.prolibertate.games.game.catan.Knight(10, 0, 1))),
|
||||||
|
)
|
||||||
|
val legal = CatanRules.legalMoves(state, 0)
|
||||||
|
assertTrue("no grain, so nothing to do", CatanInteraction.knightActions(legal, 10).isEmpty())
|
||||||
|
|
||||||
|
val targets = CatanInteraction.targets(state, legal, null, null, localSeat = 0)
|
||||||
|
assertTrue(10 in targets.idleKnights)
|
||||||
|
assertTrue("no tap ring for a knight with nothing to do", 10 !in targets.knights)
|
||||||
|
assertTrue(10 in targets.tappableVertices)
|
||||||
|
assertEquals(
|
||||||
|
TapOutcome.PickKnight(10),
|
||||||
|
CatanInteraction.onVertexTap(10, legal, null, null, ownKnights = setOf(10)),
|
||||||
|
)
|
||||||
|
// On somebody else's turn the legal list is empty and nothing is offered.
|
||||||
|
assertTrue(CatanInteraction.targets(state, emptyList(), null, null, localSeat = 0).idleKnights.isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
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.CatanAi
|
||||||
|
import org.prolibertate.games.game.catan.CatanOptions
|
||||||
|
import org.prolibertate.games.game.catan.CatanRuleSet
|
||||||
|
import org.prolibertate.games.game.catan.CatanRules
|
||||||
|
import org.prolibertate.games.game.catan.ProgressType
|
||||||
|
import org.prolibertate.games.game.engine.PlayerKind
|
||||||
|
import org.prolibertate.games.game.engine.PlayerSlot
|
||||||
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
import org.prolibertate.games.ui.catanDefaultPoints
|
||||||
|
import org.prolibertate.games.ui.catanPointsChoices
|
||||||
|
import org.prolibertate.games.ui.catanSwitchRules
|
||||||
|
|
||||||
|
class CatanCkTextTest {
|
||||||
|
|
||||||
|
private val json = Json { encodeDefaults = true }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every position of a Cities and Knights game has a prompt for every seat`() {
|
||||||
|
val config = TableConfig(
|
||||||
|
gameId = GameCatalog.CATAN,
|
||||||
|
seats = (0 until 3).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
|
||||||
|
optionsJson = json.encodeToString(
|
||||||
|
CatanOptions(3, 13, CatanRuleSet.CITIES_AND_KNIGHTS),
|
||||||
|
),
|
||||||
|
seed = 31L,
|
||||||
|
)
|
||||||
|
var state = CatanRules.initialState(config)
|
||||||
|
val ai = CatanAi()
|
||||||
|
var moves = 0
|
||||||
|
while (!CatanRules.isFinished(state) && moves++ < 20_000) {
|
||||||
|
for (me in 0 until 3) {
|
||||||
|
val seats = CatanSeats(me, listOf("A", "B", "C"))
|
||||||
|
val text = CatanText.prompt(state, seats, setupVertexChosen = false, building = null)
|
||||||
|
assertTrue("blank prompt in ${state.phase} for seat $me", text.isNotBlank())
|
||||||
|
}
|
||||||
|
val seat = CatanRules.currentSeat(state) ?: break
|
||||||
|
state = CatanRules.applyMove(state, seat, ai.chooseMove(state, seat, CatanRules.legalMoves(state, seat)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every progress card has a name and a description`() {
|
||||||
|
ProgressType.entries.forEach {
|
||||||
|
assertTrue(CatanText.progress(it).isNotBlank())
|
||||||
|
assertTrue("no help for $it", CatanText.progressHelp(it).length > 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the setup screen offers each rule set its own targets and switches sensibly`() {
|
||||||
|
assertEquals(listOf(5, 8, 10, 12), catanPointsChoices(CatanRuleSet.BASE))
|
||||||
|
assertEquals(listOf(8, 10, 13, 15), catanPointsChoices(CatanRuleSet.CITIES_AND_KNIGHTS))
|
||||||
|
assertEquals(10, catanDefaultPoints(CatanRuleSet.BASE))
|
||||||
|
assertEquals(13, catanDefaultPoints(CatanRuleSet.CITIES_AND_KNIGHTS))
|
||||||
|
|
||||||
|
val base = CatanOptions()
|
||||||
|
val ck = catanSwitchRules(base, CatanRuleSet.CITIES_AND_KNIGHTS)
|
||||||
|
assertEquals(CatanRuleSet.CITIES_AND_KNIGHTS, ck.ruleSet)
|
||||||
|
assertEquals("the published target for the new rules", 13, ck.pointsToWin)
|
||||||
|
assertEquals(10, catanSwitchRules(ck, CatanRuleSet.BASE).pointsToWin)
|
||||||
|
|
||||||
|
// A short game picked on purpose is kept when both menus have it.
|
||||||
|
val short = catanSwitchRules(base.copy(pointsToWin = 8), CatanRuleSet.CITIES_AND_KNIGHTS)
|
||||||
|
assertEquals(8, short.pointsToWin)
|
||||||
|
assertEquals(3, ck.playerCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package org.prolibertate.games.ui.game.catan
|
||||||
|
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.prolibertate.games.game.GameCatalog
|
||||||
|
import org.prolibertate.games.game.catan.BuildingType
|
||||||
|
import org.prolibertate.games.game.catan.CatanBuilding
|
||||||
|
import org.prolibertate.games.game.catan.CatanOptions
|
||||||
|
import org.prolibertate.games.game.catan.CatanPhase
|
||||||
|
import org.prolibertate.games.game.catan.CatanRules
|
||||||
|
import org.prolibertate.games.game.catan.CatanState
|
||||||
|
import org.prolibertate.games.game.catan.Resource
|
||||||
|
import org.prolibertate.games.game.engine.PlayerKind
|
||||||
|
import org.prolibertate.games.game.engine.PlayerSlot
|
||||||
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
|
||||||
|
/** Shared fixtures for the Katan UI-logic tests: real islands, bent to taste. */
|
||||||
|
internal object CatanFixtures {
|
||||||
|
|
||||||
|
private val json = Json { encodeDefaults = true }
|
||||||
|
|
||||||
|
fun config(players: Int = 3, seed: Long = 7L) = TableConfig(
|
||||||
|
gameId = GameCatalog.CATAN,
|
||||||
|
seats = (0 until players).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
|
||||||
|
optionsJson = json.encodeToString(CatanOptions(playerCount = players)),
|
||||||
|
seed = seed,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A fresh table, on the first setup placement. */
|
||||||
|
fun start(players: Int = 3, seed: Long = 7L): CatanState =
|
||||||
|
CatanRules.initialState(config(players, seed))
|
||||||
|
|
||||||
|
/** A mid-game state on [turn]'s turn, then adjusted by [adjust]. */
|
||||||
|
fun playing(
|
||||||
|
turn: Int = 0,
|
||||||
|
players: Int = 3,
|
||||||
|
adjust: CatanState.() -> CatanState = { this },
|
||||||
|
): CatanState = start(players)
|
||||||
|
.copy(phase = CatanPhase.PLAYING, turn = turn, turnNumber = 1)
|
||||||
|
.adjust()
|
||||||
|
|
||||||
|
fun hand(vararg cards: Pair<Resource, Int>): Map<Resource, Int> =
|
||||||
|
Resource.entries.associateWith { cards.toMap()[it] ?: 0 }
|
||||||
|
|
||||||
|
fun CatanState.withHand(seat: Int, hand: Map<Resource, Int>): CatanState =
|
||||||
|
copy(resources = resources.toMutableList().also { it[seat] = hand })
|
||||||
|
|
||||||
|
fun CatanState.withBuilding(vertex: Int, type: BuildingType, owner: Int): CatanState =
|
||||||
|
copy(buildings = buildings.toMutableList().also { it[vertex] = CatanBuilding(type, owner) })
|
||||||
|
|
||||||
|
fun CatanState.withRoad(edge: Int, owner: Int): CatanState =
|
||||||
|
copy(roads = roads.toMutableList().also { it[edge] = owner })
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package org.prolibertate.games.ui.game.catan
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.prolibertate.games.game.catan.CatanBoard
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
class CatanGeometryTest {
|
||||||
|
|
||||||
|
private val sizes = listOf(360f to 800f, 800f to 360f, 500f to 500f, 1000f to 1000f / CatanGeometry.ASPECT)
|
||||||
|
|
||||||
|
private fun dist(a: Pt, b: Pt): Float = sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the whole island fits inside the box at every shape`() {
|
||||||
|
for ((w, h) in sizes) {
|
||||||
|
val layout = CatanGeometry.fit(w, h)
|
||||||
|
for (v in 0 until CatanBoard.VERTEX_COUNT) {
|
||||||
|
val p = layout.vertex(v)
|
||||||
|
assertTrue("vertex $v x=${p.x} outside 0..$w", p.x in 0f..w)
|
||||||
|
assertTrue("vertex $v y=${p.y} outside 0..$h", p.y in 0f..h)
|
||||||
|
}
|
||||||
|
for (e in CatanBoard.PORT_POSITIONS) {
|
||||||
|
val p = layout.portAnchor(e)
|
||||||
|
assertTrue("harbour marker x=${p.x} outside 0..$w at ${w}x$h", p.x in 0f..w)
|
||||||
|
assertTrue("harbour marker y=${p.y} outside 0..$h at ${w}x$h", p.y in 0f..h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the layout is as large as the box allows`() {
|
||||||
|
// Tall and narrow is limited by width, short and wide by height; either
|
||||||
|
// way the island should use most of the limiting side.
|
||||||
|
val tall = CatanGeometry.fit(360f, 800f)
|
||||||
|
val wide = CatanGeometry.fit(800f, 360f)
|
||||||
|
assertTrue(tall.radius > 20f)
|
||||||
|
assertTrue(wide.radius > 20f)
|
||||||
|
val xs = (0 until CatanBoard.VERTEX_COUNT).map { tall.vertex(it).x }
|
||||||
|
assertTrue("island should span most of the width", xs.max() - xs.min() > 360f * 0.7f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every edge is one hex radius long and every hex corner is one radius from its centre`() {
|
||||||
|
val layout = CatanGeometry.fit(500f, 500f)
|
||||||
|
for (e in 0 until CatanBoard.EDGE_COUNT) {
|
||||||
|
val (a, b) = layout.edgeEnds(e)
|
||||||
|
assertEquals("edge $e length", layout.radius, dist(a, b), layout.radius * 0.01f)
|
||||||
|
}
|
||||||
|
for (h in 0 until CatanBoard.HEX_COUNT) {
|
||||||
|
val centre = layout.hexCenter(h)
|
||||||
|
for (corner in layout.hexCorners(h)) {
|
||||||
|
assertEquals("hex $h corner", layout.radius, dist(centre, corner), layout.radius * 0.01f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all fifty-four vertices sit at distinct places`() {
|
||||||
|
val layout = CatanGeometry.fit(500f, 500f)
|
||||||
|
val places = (0 until CatanBoard.VERTEX_COUNT).map {
|
||||||
|
val p = layout.vertex(it)
|
||||||
|
Math.round(p.x) to Math.round(p.y)
|
||||||
|
}.toSet()
|
||||||
|
assertEquals(CatanBoard.VERTEX_COUNT, places.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a tap on a vertex finds that vertex, and a tap far away finds nothing`() {
|
||||||
|
val layout = CatanGeometry.fit(500f, 500f)
|
||||||
|
val all = (0 until CatanBoard.VERTEX_COUNT).toList()
|
||||||
|
for (v in all) {
|
||||||
|
val p = layout.vertex(v)
|
||||||
|
assertEquals(v, layout.nearestVertex(p, layout.radius * 0.5f, all))
|
||||||
|
// A slightly clumsy tap still lands on it.
|
||||||
|
assertEquals(v, layout.nearestVertex(Pt(p.x + layout.radius * 0.2f, p.y - layout.radius * 0.15f), layout.radius * 0.55f, all))
|
||||||
|
}
|
||||||
|
assertNull(layout.nearestVertex(Pt(-100f, -100f), layout.radius * 0.5f, all))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `hit testing only considers the candidates it is given`() {
|
||||||
|
val layout = CatanGeometry.fit(500f, 500f)
|
||||||
|
val at = layout.vertex(5)
|
||||||
|
assertNull(layout.nearestVertex(at, layout.radius * 0.3f, listOf(6)))
|
||||||
|
assertEquals(6, layout.nearestVertex(layout.vertex(6), layout.radius * 0.3f, listOf(6)))
|
||||||
|
assertEquals(3, layout.nearestEdge(layout.edgeMidpoint(3), 5f, listOf(3, 4)))
|
||||||
|
assertEquals(7, layout.nearestHex(layout.hexCenter(7), 5f, listOf(7, 8)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the nearest candidate wins when several are in reach`() {
|
||||||
|
val layout = CatanGeometry.fit(500f, 500f)
|
||||||
|
val a = 10
|
||||||
|
val b = CatanBoard.VERTEX_NEIGHBORS[a].first()
|
||||||
|
val pa = layout.vertex(a)
|
||||||
|
val pb = layout.vertex(b)
|
||||||
|
// A point two-thirds of the way toward b is nearer b, though both are in reach.
|
||||||
|
val at = Pt(pa.x + (pb.x - pa.x) * 0.66f, pa.y + (pb.y - pa.y) * 0.66f)
|
||||||
|
assertEquals(b, layout.nearestVertex(at, layout.radius * 2f, listOf(a, b)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `harbour markers sit outside the coast`() {
|
||||||
|
val layout = CatanGeometry.fit(500f, 500f)
|
||||||
|
val centre = Pt(500f / 2f, 500f / 2f)
|
||||||
|
for (e in CatanBoard.PORT_POSITIONS) {
|
||||||
|
val anchor = layout.portAnchor(e)
|
||||||
|
val mid = layout.edgeMidpoint(e)
|
||||||
|
assertTrue("harbour on edge $e should be past the coast", dist(anchor, centre) > dist(mid, centre))
|
||||||
|
assertTrue("and not floating far from it", abs(dist(anchor, mid) - layout.radius * PORT_OFFSET) < 1f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
package org.prolibertate.games.ui.game.catan
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.prolibertate.games.game.catan.BuildCity
|
||||||
|
import org.prolibertate.games.game.catan.BuildRoad
|
||||||
|
import org.prolibertate.games.game.catan.BuildSettlement
|
||||||
|
import org.prolibertate.games.game.catan.BuildingType
|
||||||
|
import org.prolibertate.games.game.catan.CatanBoard
|
||||||
|
import org.prolibertate.games.game.catan.CatanPhase
|
||||||
|
import org.prolibertate.games.game.catan.CatanRules
|
||||||
|
import org.prolibertate.games.game.catan.DevCard
|
||||||
|
import org.prolibertate.games.game.catan.DevCardType
|
||||||
|
import org.prolibertate.games.game.catan.Discard
|
||||||
|
import org.prolibertate.games.game.catan.MaritimeTrade
|
||||||
|
import org.prolibertate.games.game.catan.MoveRobber
|
||||||
|
import org.prolibertate.games.game.catan.PlayKnight
|
||||||
|
import org.prolibertate.games.game.catan.PlayMonopoly
|
||||||
|
import org.prolibertate.games.game.catan.PlayYearOfPlenty
|
||||||
|
import org.prolibertate.games.game.catan.Resource
|
||||||
|
import org.prolibertate.games.game.catan.SetupPlace
|
||||||
|
import org.prolibertate.games.ui.game.catan.CatanFixtures.hand
|
||||||
|
import org.prolibertate.games.ui.game.catan.CatanFixtures.playing
|
||||||
|
import org.prolibertate.games.ui.game.catan.CatanFixtures.start
|
||||||
|
import org.prolibertate.games.ui.game.catan.CatanFixtures.withBuilding
|
||||||
|
import org.prolibertate.games.ui.game.catan.CatanFixtures.withHand
|
||||||
|
import org.prolibertate.games.ui.game.catan.CatanFixtures.withRoad
|
||||||
|
|
||||||
|
class CatanInteractionTest {
|
||||||
|
|
||||||
|
private val rich = hand(
|
||||||
|
Resource.LUMBER to 5, Resource.BRICK to 5, Resource.WOOL to 5,
|
||||||
|
Resource.GRAIN to 5, Resource.ORE to 5,
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Setup ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setup first offers every settlement spot, then only the roads beside the chosen one`() {
|
||||||
|
val state = start()
|
||||||
|
val legal = CatanRules.legalMoves(state, 0)
|
||||||
|
val setup = legal.filterIsInstance<SetupPlace>()
|
||||||
|
|
||||||
|
val first = CatanInteraction.targets(state, legal, null, null)
|
||||||
|
assertEquals(setup.map { it.vertex }.toSet(), first.vertices)
|
||||||
|
assertTrue(first.edges.isEmpty() && first.hexes.isEmpty())
|
||||||
|
|
||||||
|
val vertex = first.vertices.first()
|
||||||
|
val second = CatanInteraction.targets(state, legal, null, vertex)
|
||||||
|
assertTrue(second.vertices.isEmpty())
|
||||||
|
assertEquals(setup.filter { it.vertex == vertex }.map { it.edge }.toSet(), second.edges)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a setup placement is two taps - the settlement, then its road`() {
|
||||||
|
val state = start()
|
||||||
|
val legal = CatanRules.legalMoves(state, 0)
|
||||||
|
val vertex = CatanInteraction.targets(state, legal, null, null).vertices.first()
|
||||||
|
val edge = CatanInteraction.targets(state, legal, null, vertex).edges.first()
|
||||||
|
|
||||||
|
assertEquals(TapOutcome.PickSetupVertex(vertex), CatanInteraction.onVertexTap(vertex, legal, null, null))
|
||||||
|
assertEquals(TapOutcome.Submit(SetupPlace(vertex, edge)), CatanInteraction.onEdgeTap(edge, legal, null, vertex))
|
||||||
|
// A road tapped before any settlement is chosen means nothing.
|
||||||
|
assertEquals(TapOutcome.Ignored, CatanInteraction.onEdgeTap(edge, legal, null, null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a road that does not touch the chosen settlement is refused`() {
|
||||||
|
val state = start()
|
||||||
|
val legal = CatanRules.legalMoves(state, 0)
|
||||||
|
val vertex = CatanInteraction.targets(state, legal, null, null).vertices.first()
|
||||||
|
val beside = CatanBoard.VERTEX_EDGES[vertex].toSet()
|
||||||
|
val far = (0 until CatanBoard.EDGE_COUNT).first { it !in beside }
|
||||||
|
assertEquals(TapOutcome.Ignored, CatanInteraction.onEdgeTap(far, legal, null, vertex))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Building -------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Seat 0 owns a settlement at vertex 0 and a road two edges long leading away from it. */
|
||||||
|
private fun established(): Triple<org.prolibertate.games.game.catan.CatanState, Int, Int> {
|
||||||
|
val e1 = CatanBoard.VERTEX_EDGES[0][0]
|
||||||
|
val w = CatanBoard.otherEnd(e1, 0)
|
||||||
|
val e2 = CatanBoard.VERTEX_EDGES[w].first { it != e1 }
|
||||||
|
val x = CatanBoard.otherEnd(e2, w)
|
||||||
|
val state = playing {
|
||||||
|
withHand(0, rich).withBuilding(0, BuildingType.SETTLEMENT, 0).withRoad(e1, 0).withRoad(e2, 0)
|
||||||
|
}
|
||||||
|
return Triple(state, x, e2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `nothing on the board is tappable until a build mode is chosen`() {
|
||||||
|
val (state, _, _) = established()
|
||||||
|
val legal = CatanRules.legalMoves(state, 0)
|
||||||
|
assertTrue(CatanInteraction.targets(state, legal, null, null).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build modes target exactly what the engine allows`() {
|
||||||
|
val (state, x, _) = established()
|
||||||
|
val legal = CatanRules.legalMoves(state, 0)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
legal.filterIsInstance<BuildRoad>().map { it.edge }.toSet(),
|
||||||
|
CatanInteraction.targets(state, legal, BuildKind.ROAD, null).edges,
|
||||||
|
)
|
||||||
|
assertTrue(x in CatanInteraction.targets(state, legal, BuildKind.SETTLEMENT, null).vertices)
|
||||||
|
assertEquals(setOf(0), CatanInteraction.targets(state, legal, BuildKind.CITY, null).vertices)
|
||||||
|
assertEquals(setOf(BuildKind.ROAD, BuildKind.SETTLEMENT, BuildKind.CITY), CatanInteraction.canBuild(legal))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a tap builds only when the matching mode is armed and the spot is legal`() {
|
||||||
|
val (state, x, _) = established()
|
||||||
|
val legal = CatanRules.legalMoves(state, 0)
|
||||||
|
val road = legal.filterIsInstance<BuildRoad>().first().edge
|
||||||
|
|
||||||
|
assertEquals(TapOutcome.Submit(BuildSettlement(x)), CatanInteraction.onVertexTap(x, legal, BuildKind.SETTLEMENT, null))
|
||||||
|
assertEquals(TapOutcome.Submit(BuildCity(0)), CatanInteraction.onVertexTap(0, legal, BuildKind.CITY, null))
|
||||||
|
assertEquals(TapOutcome.Submit(BuildRoad(road)), CatanInteraction.onEdgeTap(road, legal, BuildKind.ROAD, null))
|
||||||
|
|
||||||
|
// No mode armed: nothing happens.
|
||||||
|
assertEquals(TapOutcome.Ignored, CatanInteraction.onVertexTap(x, legal, null, null))
|
||||||
|
assertEquals(TapOutcome.Ignored, CatanInteraction.onEdgeTap(road, legal, null, null))
|
||||||
|
// A settlement on an occupied crossing is not offered.
|
||||||
|
assertEquals(TapOutcome.Ignored, CatanInteraction.onVertexTap(0, legal, BuildKind.SETTLEMENT, null))
|
||||||
|
// The wrong mode for the spot.
|
||||||
|
assertEquals(TapOutcome.Ignored, CatanInteraction.onVertexTap(x, legal, BuildKind.CITY, null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `without the cards, nothing can be built`() {
|
||||||
|
val (state, _, _) = established()
|
||||||
|
val broke = state.withHand(0, hand())
|
||||||
|
val legal = CatanRules.legalMoves(broke, 0)
|
||||||
|
assertTrue(CatanInteraction.canBuild(legal).isEmpty())
|
||||||
|
assertFalse(CatanInteraction.canBuyDevCard(legal))
|
||||||
|
assertTrue(CatanInteraction.canEndTurn(legal))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `free roads from a road building card need no build mode`() {
|
||||||
|
val (state, _, _) = established()
|
||||||
|
val free = state.copy(phase = CatanPhase.FREE_ROADS, pendingFreeRoads = 2)
|
||||||
|
val legal = CatanRules.legalMoves(free, 0)
|
||||||
|
val targets = CatanInteraction.targets(free, legal, null, null)
|
||||||
|
assertTrue(targets.edges.isNotEmpty())
|
||||||
|
val edge = targets.edges.first()
|
||||||
|
assertEquals(TapOutcome.Submit(BuildRoad(edge)), CatanInteraction.onEdgeTap(edge, legal, null, null))
|
||||||
|
assertTrue(CatanInteraction.canEndFreeRoads(legal))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The robber ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the robber may go to any hex but the one it is on`() {
|
||||||
|
val state = playing { copy(phase = CatanPhase.ROBBER) }
|
||||||
|
val legal = CatanRules.legalMoves(state, 0)
|
||||||
|
val targets = CatanInteraction.targets(state, legal, null, null)
|
||||||
|
assertEquals((0 until CatanBoard.HEX_COUNT).filter { it != state.robber }.toSet(), targets.hexes)
|
||||||
|
|
||||||
|
val hex = targets.hexes.first()
|
||||||
|
// Nobody has built anything, so there is nobody to rob: the move goes straight through.
|
||||||
|
assertEquals(
|
||||||
|
TapOutcome.Submit(MoveRobber(CatanBoard.HEXES[hex], null)),
|
||||||
|
CatanInteraction.onHexTap(hex, legal),
|
||||||
|
)
|
||||||
|
assertEquals(TapOutcome.Ignored, CatanInteraction.onHexTap(state.robber, legal))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a hex with two possible victims asks which`() {
|
||||||
|
val base = playing { copy(phase = CatanPhase.ROBBER) }
|
||||||
|
val hex = (0 until CatanBoard.HEX_COUNT).first { it != base.robber }
|
||||||
|
val corners = CatanBoard.HEX_CORNERS[hex]
|
||||||
|
val state = base
|
||||||
|
.withBuilding(corners[0], BuildingType.SETTLEMENT, 1)
|
||||||
|
.withBuilding(corners[3], BuildingType.SETTLEMENT, 2)
|
||||||
|
.withHand(1, hand(Resource.ORE to 1))
|
||||||
|
.withHand(2, hand(Resource.GRAIN to 1))
|
||||||
|
val legal = CatanRules.legalMoves(state, 0)
|
||||||
|
|
||||||
|
val outcome = CatanInteraction.onHexTap(hex, legal)
|
||||||
|
assertTrue(outcome is TapOutcome.PickRobberVictim)
|
||||||
|
outcome as TapOutcome.PickRobberVictim
|
||||||
|
assertEquals(CatanBoard.HEXES[hex], outcome.hex)
|
||||||
|
assertEquals(setOf(1, 2), outcome.victims.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Discarding -----------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a seven costs half the hand, rounded down`() {
|
||||||
|
assertEquals(4, CatanInteraction.discardQuota(9))
|
||||||
|
assertEquals(4, CatanInteraction.discardQuota(8))
|
||||||
|
assertEquals(3, CatanInteraction.discardQuota(7))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a discard is found only when it names exactly an allowed set of cards`() {
|
||||||
|
val state = playing {
|
||||||
|
copy(phase = CatanPhase.DISCARDING, discardSeat = 0)
|
||||||
|
.withHand(0, hand(Resource.LUMBER to 3, Resource.BRICK to 3, Resource.WOOL to 3))
|
||||||
|
}
|
||||||
|
val legal = CatanRules.legalMoves(state, 0)
|
||||||
|
assertTrue(legal.all { it is Discard })
|
||||||
|
|
||||||
|
val good = listOf(Resource.LUMBER, Resource.LUMBER, Resource.BRICK, Resource.WOOL)
|
||||||
|
assertNotNull(CatanInteraction.discardMove(legal, good))
|
||||||
|
// Order does not matter.
|
||||||
|
assertNotNull(CatanInteraction.discardMove(legal, good.reversed()))
|
||||||
|
// Too few, and more than are held.
|
||||||
|
assertNull(CatanInteraction.discardMove(legal, good.take(3)))
|
||||||
|
assertNull(CatanInteraction.discardMove(legal, List(4) { Resource.LUMBER }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Trading ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `bank trades list what can be given and what it can buy`() {
|
||||||
|
val state = playing { withHand(0, hand(Resource.GRAIN to 4)) }
|
||||||
|
val legal = CatanRules.legalMoves(state, 0)
|
||||||
|
assertTrue(legal.any { it is MaritimeTrade })
|
||||||
|
|
||||||
|
val trades = CatanInteraction.bankTrades(legal)
|
||||||
|
assertEquals(setOf(Resource.GRAIN), trades.keys)
|
||||||
|
assertEquals(Resource.entries.filter { it != Resource.GRAIN }, trades.getValue(Resource.GRAIN))
|
||||||
|
assertEquals(4, CatanInteraction.bankRatio(state, 0, Resource.GRAIN))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an offer is allowed only to another player, for something different, from cards held`() {
|
||||||
|
val state = playing { withHand(0, hand(Resource.LUMBER to 2)) }
|
||||||
|
val lumber = listOf(Resource.LUMBER)
|
||||||
|
val grain = listOf(Resource.GRAIN)
|
||||||
|
|
||||||
|
assertTrue(CatanInteraction.offerAllowed(state, 0, 1, lumber, grain))
|
||||||
|
assertFalse("to oneself", CatanInteraction.offerAllowed(state, 0, 0, lumber, grain))
|
||||||
|
assertFalse("nothing offered", CatanInteraction.offerAllowed(state, 0, 1, emptyList(), grain))
|
||||||
|
assertFalse("nothing asked", CatanInteraction.offerAllowed(state, 0, 1, lumber, emptyList()))
|
||||||
|
assertFalse("like for like", CatanInteraction.offerAllowed(state, 0, 1, lumber, lumber))
|
||||||
|
assertFalse("more than held", CatanInteraction.offerAllowed(state, 0, 1, List(3) { Resource.LUMBER }, grain))
|
||||||
|
assertFalse("not their turn", CatanInteraction.offerAllowed(state, 1, 0, lumber, grain))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Development cards -------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a development card is playable unless it was bought this turn or is a victory point`() {
|
||||||
|
val state = playing {
|
||||||
|
copy(
|
||||||
|
devHands = listOf(
|
||||||
|
listOf(
|
||||||
|
DevCard(1, DevCardType.KNIGHT, boughtTurn = 0),
|
||||||
|
DevCard(2, DevCardType.VICTORY_POINT, boughtTurn = 0),
|
||||||
|
DevCard(3, DevCardType.KNIGHT, boughtTurn = 1),
|
||||||
|
),
|
||||||
|
emptyList(),
|
||||||
|
emptyList(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val legal = CatanRules.legalMoves(state, 0)
|
||||||
|
val hand = CatanInteraction.devHand(state, 0, legal)
|
||||||
|
|
||||||
|
// Sorted by type then id: the two knights, then the victory point.
|
||||||
|
assertEquals(listOf(1, 3, 2), hand.map { it.card.id })
|
||||||
|
assertEquals(listOf(true, false, false), hand.map { it.playable })
|
||||||
|
assertEquals(listOf<Any>(PlayKnight(1)), hand.first().plays)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `monopoly offers one play per resource`() {
|
||||||
|
val plays = Resource.entries.map { PlayMonopoly(9, it) }
|
||||||
|
val choices = CatanInteraction.monopolyChoices(plays)
|
||||||
|
assertEquals(Resource.entries.toSet(), choices.keys)
|
||||||
|
assertEquals(PlayMonopoly(9, Resource.ORE), choices.getValue(Resource.ORE))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `year of plenty narrows to the cards that still complete a take`() {
|
||||||
|
val plays = listOf(
|
||||||
|
PlayYearOfPlenty(4, listOf(Resource.LUMBER, Resource.BRICK)),
|
||||||
|
PlayYearOfPlenty(4, listOf(Resource.LUMBER, Resource.LUMBER)),
|
||||||
|
PlayYearOfPlenty(4, listOf(Resource.ORE)),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
setOf(Resource.LUMBER, Resource.BRICK, Resource.ORE),
|
||||||
|
CatanInteraction.plentyAvailable(plays, emptyList()),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
setOf(Resource.LUMBER, Resource.BRICK),
|
||||||
|
CatanInteraction.plentyAvailable(plays, listOf(Resource.LUMBER)),
|
||||||
|
)
|
||||||
|
assertEquals(setOf(Resource.LUMBER), CatanInteraction.plentyAvailable(plays, listOf(Resource.BRICK)))
|
||||||
|
assertTrue(CatanInteraction.plentyAvailable(plays, listOf(Resource.LUMBER, Resource.LUMBER)).isEmpty())
|
||||||
|
|
||||||
|
assertEquals(plays[0], CatanInteraction.plentyMove(plays, listOf(Resource.BRICK, Resource.LUMBER)))
|
||||||
|
assertEquals(plays[2], CatanInteraction.plentyMove(plays, listOf(Resource.ORE)))
|
||||||
|
assertNull(CatanInteraction.plentyMove(plays, listOf(Resource.GRAIN)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Trade replies ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the addressee of an offer is given accept and decline`() {
|
||||||
|
val base = playing {
|
||||||
|
withHand(0, hand(Resource.LUMBER to 1)).withHand(1, hand(Resource.GRAIN to 1))
|
||||||
|
}
|
||||||
|
val offer = org.prolibertate.games.game.catan.TradeOffer(
|
||||||
|
by = 0, to = 1, give = listOf(Resource.LUMBER), get = listOf(Resource.GRAIN),
|
||||||
|
)
|
||||||
|
val pending = base.copy(phase = CatanPhase.TRADE_PENDING, pendingTrade = offer)
|
||||||
|
val legal = CatanRules.legalMoves(pending, 1)
|
||||||
|
assertEquals(true to true, CatanInteraction.tradeResponses(legal))
|
||||||
|
|
||||||
|
// If the addressee no longer holds what was asked, only decline remains.
|
||||||
|
val short = pending.withHand(1, hand())
|
||||||
|
assertEquals(false to true, CatanInteraction.tradeResponses(CatanRules.legalMoves(short, 1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Rolled-hex highlight --------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rolled hexes are those carrying the number, minus the robber's`() {
|
||||||
|
val base = playing(turn = 0)
|
||||||
|
val number = base.numbers.first { it != 0 && it != 7 }
|
||||||
|
val state = base.copy(lastDice = if (number <= 7) listOf(number - 1, 1) else listOf(6, number - 6))
|
||||||
|
val all = (0 until CatanBoard.HEX_COUNT).filter { state.numbers[it] == number }
|
||||||
|
assertTrue(all.isNotEmpty())
|
||||||
|
assertEquals(all.filter { it != state.robber }.toSet(), CatanInteraction.rolledHexes(state))
|
||||||
|
val blocked = state.copy(robber = all.first())
|
||||||
|
assertFalse(all.first() in CatanInteraction.rolledHexes(blocked))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `nothing is highlighted before this turn's roll, on a seven, or in setup`() {
|
||||||
|
val rolled = playing(turn = 0).copy(lastDice = listOf(3, 3))
|
||||||
|
assertTrue(CatanInteraction.rolledHexes(rolled.copy(phase = CatanPhase.AWAIT_ROLL)).isEmpty())
|
||||||
|
assertTrue(CatanInteraction.rolledHexes(rolled.copy(lastDice = listOf(3, 4))).isEmpty())
|
||||||
|
assertTrue(CatanInteraction.rolledHexes(rolled.copy(phase = CatanPhase.SETUP_FIRST)).isEmpty())
|
||||||
|
assertTrue(CatanInteraction.rolledHexes(rolled.copy(lastDice = emptyList())).isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package org.prolibertate.games.ui.game.catan
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.prolibertate.games.game.catan.CatanPhase
|
||||||
|
import org.prolibertate.games.game.catan.Port
|
||||||
|
import org.prolibertate.games.game.catan.Resource
|
||||||
|
import org.prolibertate.games.ui.game.catan.CatanFixtures.hand
|
||||||
|
import org.prolibertate.games.ui.game.catan.CatanFixtures.playing
|
||||||
|
import org.prolibertate.games.ui.game.catan.CatanFixtures.start
|
||||||
|
import org.prolibertate.games.ui.game.catan.CatanFixtures.withHand
|
||||||
|
|
||||||
|
class CatanTextTest {
|
||||||
|
|
||||||
|
private val names = listOf("Ann", "Bo", "Cy")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `seats are named, with You for the local player and a number when a name is unknown`() {
|
||||||
|
val seats = CatanSeats(me = 1, names = names)
|
||||||
|
assertEquals("You", seats.label(1))
|
||||||
|
assertEquals("Ann", seats.label(0))
|
||||||
|
assertEquals("you", seats.lower(1))
|
||||||
|
assertEquals("Seat 4", seats.label(3))
|
||||||
|
assertEquals("Seat 1", CatanSeats(me = 1).label(0))
|
||||||
|
assertEquals("Seat 1", CatanSeats(me = 1, names = listOf(" ")).label(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setup prompts say what to place next`() {
|
||||||
|
val state = start()
|
||||||
|
val mine = CatanSeats(0, names)
|
||||||
|
assertTrue(CatanText.prompt(state, mine, setupVertexChosen = false, building = null).contains("Place a settlement"))
|
||||||
|
assertTrue(CatanText.prompt(state, mine, setupVertexChosen = true, building = null).contains("road"))
|
||||||
|
assertTrue(CatanText.prompt(state, CatanSeats(1, names), false, null).startsWith("Ann is placing"))
|
||||||
|
val second = state.copy(phase = CatanPhase.SETUP_SECOND, setupIndex = 3)
|
||||||
|
assertTrue(CatanText.prompt(second, CatanSeats(2, names), false, null).contains("second settlement"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the turn prompts follow the phase`() {
|
||||||
|
val roll = playing().copy(phase = CatanPhase.AWAIT_ROLL)
|
||||||
|
assertTrue(CatanText.prompt(roll, CatanSeats(0, names), false, null).contains("Roll the dice"))
|
||||||
|
assertEquals("Ann is rolling…", CatanText.prompt(roll, CatanSeats(1, names), false, null))
|
||||||
|
|
||||||
|
val turn = playing()
|
||||||
|
assertTrue(CatanText.prompt(turn, CatanSeats(0, names), false, null).contains("Your turn"))
|
||||||
|
assertEquals("Tap an edge to build a road.", CatanText.prompt(turn, CatanSeats(0, names), false, BuildKind.ROAD))
|
||||||
|
assertEquals("Bo is playing…", CatanText.prompt(playing(turn = 1), CatanSeats(0, names), false, null))
|
||||||
|
|
||||||
|
val robber = playing().copy(phase = CatanPhase.ROBBER)
|
||||||
|
assertTrue(CatanText.prompt(robber, CatanSeats(0, names), false, null).contains("robber"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the discard prompt names the quota`() {
|
||||||
|
val state = playing {
|
||||||
|
copy(phase = CatanPhase.DISCARDING, discardSeat = 0)
|
||||||
|
.withHand(0, hand(Resource.LUMBER to 9))
|
||||||
|
}
|
||||||
|
assertTrue(CatanText.prompt(state, CatanSeats(0, names), false, null).contains("discard 4"))
|
||||||
|
assertTrue(CatanText.prompt(state, CatanSeats(1, names), false, null).contains("Ann must discard half"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `road building counts down in the singular and the plural`() {
|
||||||
|
val two = playing().copy(phase = CatanPhase.FREE_ROADS, pendingFreeRoads = 2)
|
||||||
|
val one = two.copy(pendingFreeRoads = 1)
|
||||||
|
assertTrue(CatanText.prompt(two, CatanSeats(0, names), false, null).endsWith("2 more roads."))
|
||||||
|
assertTrue(CatanText.prompt(one, CatanSeats(0, names), false, null).endsWith("1 more road."))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the end of the game names the winner`() {
|
||||||
|
val over = playing().copy(phase = CatanPhase.GAME_OVER, winner = 1)
|
||||||
|
assertEquals("Bo won the game.", CatanText.prompt(over, CatanSeats(0, names), false, null))
|
||||||
|
assertEquals("You won!", CatanText.winnerHeadline(over, CatanSeats(1, names)))
|
||||||
|
assertEquals("Bo won", CatanText.winnerHeadline(over, CatanSeats(0, names)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `cards read as a sorted tally`() {
|
||||||
|
assertEquals(
|
||||||
|
"2 Lumber + 1 Grain",
|
||||||
|
CatanText.cards(listOf(Resource.GRAIN, Resource.LUMBER, Resource.LUMBER)),
|
||||||
|
)
|
||||||
|
assertEquals("nothing", CatanText.cards(emptyList()))
|
||||||
|
assertEquals(
|
||||||
|
"Ann offers 1 Lumber to you for 2 Ore",
|
||||||
|
CatanText.tradeSummary("Ann", "you", listOf(Resource.LUMBER), listOf(Resource.ORE, Resource.ORE)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `costs and harbours read as the rules state them`() {
|
||||||
|
assertEquals("1 lumber + 1 brick", CatanText.ROAD_COST)
|
||||||
|
assertEquals("2 grain + 3 ore", CatanText.CITY_COST)
|
||||||
|
assertEquals("2:1 ore", CatanText.portName(Port(edge = 0, resource = Resource.ORE, ratio = 2)))
|
||||||
|
assertEquals("3:1 any", CatanText.portName(Port(edge = 0, resource = null, ratio = 3)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# docs/
|
||||||
|
|
||||||
|
Conventions for this repo's documentation. Seeded at bootstrap (WALA-118).
|
||||||
|
|
||||||
|
## Per-game rules: `RULES-<game>.md`
|
||||||
|
|
||||||
|
Every game in `GameCatalog` gets one file describing how *this app* implements
|
||||||
|
it — not a generic rule dump. The rules code
|
||||||
|
(`app/src/main/java/org/prolibertate/games/game/<game>/`) is the source of
|
||||||
|
truth; the doc exists so a human or an agent can check intent against
|
||||||
|
behaviour without reading Kotlin first.
|
||||||
|
|
||||||
|
Naming: the catalogue id, lowercase, e.g. `crazy8s` → `RULES-crazy8s.md`.
|
||||||
|
|
||||||
|
| Game (catalogue id) | Rules doc |
|
||||||
|
| --- | --- |
|
||||||
|
| Backgammon | [RULES-backgammon.md](RULES-backgammon.md) |
|
||||||
|
| Checkers | [RULES-checkers.md](RULES-checkers.md) |
|
||||||
|
| Chess | [RULES-chess.md](RULES-chess.md) |
|
||||||
|
| Crazy 8s | [RULES-crazy8s.md](RULES-crazy8s.md) |
|
||||||
|
| Cribbage | [RULES-cribbage.md](RULES-cribbage.md) |
|
||||||
|
| Euchre | [RULES-euchre.md](RULES-euchre.md) |
|
||||||
|
| FreeCell | — (TODO) |
|
||||||
|
| Golf | [RULES-golf.md](RULES-golf.md) |
|
||||||
|
| Hearts | — (TODO) |
|
||||||
|
| Kaiser | [RULES-kaiser.md](RULES-kaiser.md) |
|
||||||
|
| Katan (catalogue id `catan`) | [RULES-catan.md](RULES-catan.md) — and its expansion rule set, [RULES-catan-ck.md](RULES-catan-ck.md) |
|
||||||
|
| Klondike | — (TODO) |
|
||||||
|
| Mastermind | [RULES-mastermind.md](RULES-mastermind.md) |
|
||||||
|
| Morris | [RULES-morris.md](RULES-morris.md) |
|
||||||
|
| Peg solitaire | — (TODO) |
|
||||||
|
| Pirates | [RULES-pirates.md](RULES-pirates.md) |
|
||||||
|
| President | [RULES-president.md](RULES-president.md) |
|
||||||
|
| Pyramid | — (TODO) |
|
||||||
|
| Sequence | [RULES-sequence.md](RULES-sequence.md) |
|
||||||
|
| Spider | — (TODO) |
|
||||||
|
| Ta Yü | [RULES-tayu.md](RULES-tayu.md) |
|
||||||
|
| Wizard | [RULES-wizard.md](RULES-wizard.md) |
|
||||||
|
| Yahtzee | — (TODO) |
|
||||||
|
|
||||||
|
## Other docs
|
||||||
|
|
||||||
|
- Build, CI, releases and OTA are described in the CI section of the root
|
||||||
|
[README](../README.md).
|
||||||
|
- [SAVED-GAMES.md](SAVED-GAMES.md) — how a game in progress is kept between sessions, and what is
|
||||||
|
not kept.
|
||||||
|
- [UI-catan.md](UI-catan.md) — how the Katan screen is layered, and how to restyle,
|
||||||
|
re-copy or re-lay-out it without touching the rules.
|
||||||
|
- `licenses/` — third-party licences that must travel with the app
|
||||||
|
(Uncial Antiqua under the SIL OFL; the app itself is GPL-3.0, see `/LICENSE`).
|
||||||
|
- Repo-level agent notes live in [`CLAUDE.md`](../CLAUDE.md) at the root.
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
# Katan: Cities & Knights — implemented rules
|
||||||
|
|
||||||
|
This is the second rule set of Katan (see [RULES-catan.md](RULES-catan.md) for the
|
||||||
|
base game). It is chosen on the setup screen — **Rules: Base game / Cities &
|
||||||
|
Knights** — and stored as `CatanOptions.ruleSet` (`CatanRuleSet.CITIES_AND_KNIGHTS`).
|
||||||
|
A table plays one or the other; the choice reaches guests with the rest of the
|
||||||
|
options.
|
||||||
|
|
||||||
|
The expansion is a whole rule set rather than a toggle, because it replaces rules
|
||||||
|
(no development cards, no Largest Army, a different seven) as well as adding them.
|
||||||
|
Everything it needs lives behind `CatanState.ck`, which is `null` in the base game,
|
||||||
|
so a base game's state and rules are untouched. The code is:
|
||||||
|
|
||||||
|
| File | Holds |
|
||||||
|
| --- | --- |
|
||||||
|
| `CatanCkModel.kt` | the data: commodities, improvement tracks, progress cards, knights, the chore queue, every new move |
|
||||||
|
| `CatanCk.kt` | the rules: legal moves, applying them, the roll, the barbarians, scoring |
|
||||||
|
| `CatanCkAi.kt` | the computer opponent for this rule set |
|
||||||
|
| `CatanRules.kt` | the single entry point; calls into `CatanCk` whenever `state.ck != null` |
|
||||||
|
|
||||||
|
Behaviour is pinned by `CatanCkRulesTest` (each rule on a crafted position) and
|
||||||
|
`CatanCkSelfPlayTest` (whole games, engine against engine). Three or four players,
|
||||||
|
as in the base game.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
The rules were checked against the in-game almanac of Catan Universe (rules text
|
||||||
|
only: costs, card wordings, the barbarian and improvement rules). Where the almanac
|
||||||
|
is silent or ambiguous the reading chosen is listed under *Choices made here* below.
|
||||||
|
|
||||||
|
## What changes from the base game
|
||||||
|
|
||||||
|
| | Base game | Cities & Knights |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Points to win | 10 | **13** (setup offers 8 / 10 / 13 / 15) |
|
||||||
|
| Development cards | 25-card deck | **none** — knights and progress cards replace them |
|
||||||
|
| Largest Army | 5 *(see RULES-catan.md)* | **none** |
|
||||||
|
| Longest Road | 5 *(see RULES-catan.md)* | **2** |
|
||||||
|
| Second setup piece | settlement | **city** (resources only, no commodities) |
|
||||||
|
| Dice | two | **two number dice + the event die** |
|
||||||
|
| A seven | discard half over 7; robber moves | discard half over **7 + 2 per wall**; the robber **only moves after the first barbarian attack** |
|
||||||
|
| Cities | 2 of a resource | forest: 1 lumber + **1 paper**; pasture: 1 wool + **1 cloth**; mountains: 1 ore + **1 coin**; hills / fields: 2 of the resource |
|
||||||
|
|
||||||
|
## The dice
|
||||||
|
|
||||||
|
The yellow die and the red die are added for production, as in the base game. The
|
||||||
|
**event die** has three ships and one gate each of yellow (trade), blue (politics)
|
||||||
|
and green (science). It is resolved **before** production:
|
||||||
|
|
||||||
|
- **Ship** — the barbarians come one step nearer (they start 7 steps out).
|
||||||
|
- **Gate** — every player with at least one level of improvement in that colour,
|
||||||
|
**in turn order from the roller**, whose **red die is no higher than their level**
|
||||||
|
in that track, draws a progress card of that colour.
|
||||||
|
|
||||||
|
`RollDice(forced, event)` stages both for tests, like the base game's forced roll.
|
||||||
|
An **Alchemist** played before the roll sets the two number dice (the event die is
|
||||||
|
still rolled).
|
||||||
|
|
||||||
|
## Commodities
|
||||||
|
|
||||||
|
Three commodities — **cloth, coin, paper** — join the five resources. The bank holds
|
||||||
|
12 of each (19 of each resource). Hands, discards, thefts and trades all treat them as
|
||||||
|
cards; progress cards are not cards for any of these purposes.
|
||||||
|
|
||||||
|
When the bank cannot meet the demand for a card type: with **one** seat owed it,
|
||||||
|
that seat takes what is left; with **two or more** owed, nobody receives it.
|
||||||
|
|
||||||
|
## The barbarians
|
||||||
|
|
||||||
|
The strip shows how many steps away the ship is. On the seventh ship it **lands**:
|
||||||
|
|
||||||
|
- Barbarian strength is the **number of cities on the board** (a metropolis is a city).
|
||||||
|
- Catan's strength is the sum of the strengths of every **active** knight (basic 1,
|
||||||
|
strong 2, mighty 3).
|
||||||
|
- If Catan's strength is **at least** the barbarians', Catan holds: the player with
|
||||||
|
the strictly highest contribution takes a **Defender of Catan** card (one point);
|
||||||
|
if several tie for highest, **each draws a progress card from a deck of their
|
||||||
|
choice** instead.
|
||||||
|
- Otherwise Catan falls: among the players who own a city that is not a metropolis,
|
||||||
|
those who contributed **least** each lose one city, **to their own choice**. A lost
|
||||||
|
city becomes a settlement (or, if the seat has no settlement piece left, is removed),
|
||||||
|
and its wall goes. **A metropolis can never be pillaged.**
|
||||||
|
- Either way every knight stands down, the ship returns to seven steps, and the
|
||||||
|
**robber wakes** — until now it has not been on the board.
|
||||||
|
|
||||||
|
## Knights
|
||||||
|
|
||||||
|
Each seat has **two knights of each strength** (six in all).
|
||||||
|
|
||||||
|
| Action | Cost | Rule |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Hire | 1 ore + 1 wool | a **basic** knight on an empty crossing at the end of one of your roads; only if one of your two basic knights is free |
|
||||||
|
| Activate | 1 grain | a sleeping knight wakes; only active knights count against the barbarians |
|
||||||
|
| Promote | 1 ore + 1 wool | one level, once per knight per turn; a **mighty** knight needs **level three of politics** (the fortress); the next level's pieces must be free |
|
||||||
|
| Move | — | an active knight moves along **its own roads** to an empty crossing, or onto a **strictly weaker** foreign knight, driving it off; it then rests |
|
||||||
|
| Chase the robber | — | an active knight on a crossing of the robber's hex sends the robber off (only once the robber is awake); it then rests, and the robber move follows |
|
||||||
|
|
||||||
|
- **A knight cannot act on the turn it woke.** It can be woken again after it acts.
|
||||||
|
- A driven-off knight is moved by **its owner** along its own roads to any empty
|
||||||
|
crossing it can reach; if there is none, it is lost.
|
||||||
|
- A knight is a wall, like an opposing building: roads cannot be laid past an enemy
|
||||||
|
knight, a settlement cannot be built on a knight, and the knight cuts **Longest Road**.
|
||||||
|
- Knights can act (wake, move, chase) **before the roll** as well as after.
|
||||||
|
|
||||||
|
## City walls
|
||||||
|
|
||||||
|
2 brick, on a city without a wall, **three per player**. Each wall raises the hand
|
||||||
|
limit on a seven by two (so a seven discards when you hold more than `7 + 2 × walls`).
|
||||||
|
|
||||||
|
## City improvements
|
||||||
|
|
||||||
|
Three tracks, five levels each, bought with the track's commodity: **trade (cloth,
|
||||||
|
yellow), politics (coin, blue), science (paper, green)**. Level *n* costs *n* of the
|
||||||
|
commodity. You must own a city to improve.
|
||||||
|
|
||||||
|
| Track | Level 3 gives |
|
||||||
|
| --- | --- |
|
||||||
|
| Trade | commodities trade with the bank **2:1** |
|
||||||
|
| Politics | knights may be promoted to **mighty** |
|
||||||
|
| Science | the **aqueduct**: when the dice pay you nothing, take any resource |
|
||||||
|
|
||||||
|
The level names (Trading Post … Grand Bazaar, Town Hall … Palace, Abbey … University)
|
||||||
|
are this app's own.
|
||||||
|
|
||||||
|
### Metropolises
|
||||||
|
|
||||||
|
The first player to reach **level four** in a track takes that track's **metropolis**
|
||||||
|
(a marker on one of their cities: +2 points; a city carries at most one). Reaching
|
||||||
|
**level five** takes it from a holder who is still on four. A player with several
|
||||||
|
cities chooses which; with one it is placed at once; a player with no free city
|
||||||
|
**waits**, and claims it when a city is built.
|
||||||
|
|
||||||
|
## Progress cards
|
||||||
|
|
||||||
|
54 cards: 18 in each of three decks. A card played returns to the **bottom** of its
|
||||||
|
deck. A hand holds **four**; a fifth must be given back at once. They can be played at
|
||||||
|
any point of your own turn (before or after the roll), except where noted.
|
||||||
|
|
||||||
|
**Science (green)** — Alchemist ×2 *(before the roll only)*, Crane ×2, Engineer, Inventor ×2,
|
||||||
|
Irrigation ×2, Medicine ×2, Mining ×2, Printer, Road Building ×2, Smith ×2.
|
||||||
|
|
||||||
|
**Politics (blue)** — Bishop ×2 *(after the first attack)*, Constitution, Deserter ×2,
|
||||||
|
Diplomat ×2, Intrigue ×2, Saboteur ×2, Spy ×3, Warlord ×2, Wedding ×2.
|
||||||
|
|
||||||
|
**Trade (yellow)** — Commercial Harbor ×2, Master Merchant ×2, Merchant ×6,
|
||||||
|
Merchant Fleet ×2, Resource Monopoly ×4, Trade Monopoly ×2.
|
||||||
|
|
||||||
|
**Printer** and **Constitution** are one point each and are **laid face up the moment
|
||||||
|
they are drawn**; they never sit in a hand.
|
||||||
|
|
||||||
|
How each plays is shown on the card in the app (`CatanText.progressHelp`); the engine's
|
||||||
|
behaviour is in `CatanCk.applyProgress`. Points worth knowing:
|
||||||
|
|
||||||
|
- **Saboteur** hits every other player with **as many points as you or more**, who each
|
||||||
|
discard half their cards (rounded down). **Wedding** takes two cards (or what they
|
||||||
|
have) from each player with **more** points. **Master Merchant** needs a victim with
|
||||||
|
more points, and takes two cards you choose.
|
||||||
|
- **Spy**: you see the target's progress cards and take one; nobody else sees them.
|
||||||
|
- **Merchant**: placed beside one of your buildings; you trade that hex's resource 2:1
|
||||||
|
and hold a victory point until someone else plays a Merchant.
|
||||||
|
- **Deserter**: the victim removes a knight of *their* choice; you place a free one of the
|
||||||
|
same strength (or a basic if yours is not free), awake or asleep as theirs was.
|
||||||
|
- **Diplomat**: removes a road with a loose end (an end touching no other road, building
|
||||||
|
or knight); if it was yours you may rebuild it anywhere for free.
|
||||||
|
- **Inventor** swaps two tokens, never a 2, 6, 8 or 12.
|
||||||
|
|
||||||
|
## The chore queue
|
||||||
|
|
||||||
|
Anything that needs an answer from somebody — a discard, a city to lose, a progress card
|
||||||
|
to hand over, a metropolis site, a knight to place — is queued as a **chore**
|
||||||
|
(`CatanPhase.CHORE`, `CkState.chores`). The game waits on the seat the first chore names
|
||||||
|
(not always the player whose turn it is), then resumes where `CkState.back` says. This is
|
||||||
|
how a seven, a barbarian landing, a wedding or a spy are all one mechanism, and why
|
||||||
|
the AI and the network driver needed no new concepts.
|
||||||
|
|
||||||
|
The two free-form chores — **discarding** and **handing over** cards — are structural
|
||||||
|
like the base game's trade offers: the engine checks them in `isLegalMove` and the legal
|
||||||
|
list carries one example, so a seat is never without a move.
|
||||||
|
|
||||||
|
## Scoring and winning
|
||||||
|
|
||||||
|
Settlement 1, city 2, **metropolis +2**, **Longest Road 2**, **Defender of Catan 1** each,
|
||||||
|
**Printer / Constitution 1** each, **the Merchant 1**. Nothing is hidden. Win at 13 (default),
|
||||||
|
checked after every move and when a turn arrives.
|
||||||
|
|
||||||
|
## Choices made here, and why
|
||||||
|
|
||||||
|
- **The red die must be no higher than the improvement level** to draw a progress card
|
||||||
|
(level 1 draws on a 1, level 5 on 1–5). The almanac says the red die must match a
|
||||||
|
number on one of your improvements and that the number rises with the level; this is
|
||||||
|
the reading that makes both true.
|
||||||
|
- **One resource is offered to every player for a Commercial Harbor.** The printed card
|
||||||
|
lets you offer a different one to each.
|
||||||
|
- **The merchant cannot go on the desert**, which produces nothing to trade.
|
||||||
|
- **Improvements need a city** to be built.
|
||||||
|
- **A 3:1 harbour trades commodities too**; a 2:1 harbour trades only its own resource.
|
||||||
|
- **A knight that cannot find a refuge is lost**, as the almanac says of an intrigue.
|
||||||
|
- **A claim on a metropolis with no city to carry it waits** and is made when a city is
|
||||||
|
built.
|
||||||
|
- **Domestic trade** works as in the base game — an offer to one seat or to the whole
|
||||||
|
table, asked in seat order, each seat accepting, declining or countering — and may
|
||||||
|
include commodities (`OfferGoods`, `CounterGoods`; accepting and dismissing counters
|
||||||
|
reuse the base game's `AcceptCounter` and `DismissCounters`).
|
||||||
|
- **No Seafarers, no 5–6 player extension**, no scenario or variant of any kind.
|
||||||
|
|
||||||
|
## The computer opponent
|
||||||
|
|
||||||
|
`CatanCkAi` plays a safe, unambitious game: it settles and raises cities as the base AI
|
||||||
|
does, spends commodities on improvements as soon as it can (leaning to politics), hires
|
||||||
|
and wakes knights as the barbarians draw near, chases the robber off hexes it needs, plays
|
||||||
|
the progress cards whose value is obvious, and answers every chore with the card it values
|
||||||
|
least. It never plans a knight's route, never plays an Alchemist, Inventor, Diplomat,
|
||||||
|
Deserter or Intrigue, and makes no domestic offers (though it answers them). Every engine
|
||||||
|
game in `CatanCkSelfPlayTest` ends in a win at thirteen points.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- The screen is built and its logic is tested, but **not yet verified on two real
|
||||||
|
devices**; the catalogue entry stays a dev-channel preview.
|
||||||
|
- No animation for the event die, the barbarian landing or a knight's move.
|
||||||
|
- The Commercial Harbor offers a single resource type (see above).
|
||||||
|
- The AI is not a strong opponent and makes no claim to be.
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
# Katan — implemented rules
|
||||||
|
|
||||||
|
This is the ruleset the engine enforces. Anything marked **toggle** is exposed
|
||||||
|
in the game setup screen.
|
||||||
|
|
||||||
|
Katan is this app's name for the island game the collection is built around.
|
||||||
|
The catalogue id is `catan` (so this file is `RULES-catan.md` and the code lives
|
||||||
|
in `game/catan/`), but the printed name is trademarked, so the game is listed
|
||||||
|
and titled **Katan**. The rules themselves are the base game for three to four
|
||||||
|
players, as published.
|
||||||
|
|
||||||
|
The engine is a pure state machine (`CatanRules`) with no Android types, and its
|
||||||
|
behaviour is pinned by `CatanBoardTest`, `CatanRulesTest` and `CatanAiTest`.
|
||||||
|
|
||||||
|
## Rule sets
|
||||||
|
|
||||||
|
A table plays one of two rule sets, chosen on the setup screen
|
||||||
|
(`CatanOptions.ruleSet`):
|
||||||
|
|
||||||
|
- **Base game** — everything in this document.
|
||||||
|
- **Cities & Knights** — the expansion, documented in
|
||||||
|
[RULES-catan-ck.md](RULES-catan-ck.md). It reuses this document's board, setup,
|
||||||
|
building, robber and trade rules and changes or replaces the rest; its state lives
|
||||||
|
behind `CatanState.ck`, which is `null` in the base game.
|
||||||
|
|
||||||
|
## Table
|
||||||
|
|
||||||
|
- **Rules — *toggle*.** Base game or Cities & Knights (`CatanOptions.ruleSet`).
|
||||||
|
- **Players — *toggle*.** Three or four. `CatanOptions.playerCount`; the engine
|
||||||
|
refuses anything else.
|
||||||
|
- **Points to win — *toggle*.** Defaults to 10. `CatanOptions.pointsToWin`, at
|
||||||
|
least 2. A lower target makes a test game short.
|
||||||
|
|
||||||
|
The setup screen exposes all three (players: 3 or 4; game to 5, 8, 10 or 12 points in the
|
||||||
|
base game, 8, 10, 13 or 15 in Cities & Knights, whose published target is 13).
|
||||||
|
|
||||||
|
## Board
|
||||||
|
|
||||||
|
The island is the standard layout, derived once from axial hex coordinates in
|
||||||
|
`CatanBoard` and checked at class-init:
|
||||||
|
|
||||||
|
- **19 hexes**, the hexes within distance 2 of the centre.
|
||||||
|
- **54 vertices** and **72 edges**, shared between neighbouring hexes by exact
|
||||||
|
integer position, not floating point. `HEX_CORNERS`, `EDGE_VERTICES`,
|
||||||
|
`VERTEX_EDGES`, `VERTEX_HEXES` and `EDGE_HEXES` are the shared graph.
|
||||||
|
- **30 coastal edges**, one closed ring.
|
||||||
|
- **9 harbours**, on fixed positions around the coast.
|
||||||
|
|
||||||
|
Terrain is dealt from the physical mix and shuffled: **4 forest, 4 pasture,
|
||||||
|
4 fields, 3 hills, 3 mountains, 1 desert**.
|
||||||
|
|
||||||
|
Number tokens are the 18 the game ships, shuffled over the land hexes: four red
|
||||||
|
tokens (**6, 6, 8, 8**) and fourteen others
|
||||||
|
(**2, 3, 3, 4, 4, 5, 5, 9, 9, 10, 10, 11, 11, 12**). Seven never appears. The
|
||||||
|
desert bears no token and the robber starts on it.
|
||||||
|
|
||||||
|
### Keeping 6s and 8s apart
|
||||||
|
|
||||||
|
The published game asks that the two red numbers not sit on adjacent hexes, and
|
||||||
|
`generate` guarantees it rather than hoping for it:
|
||||||
|
|
||||||
|
1. Shuffle the land and take four hexes; accept the draw if none of the four
|
||||||
|
touch each other. Retry up to 60 times.
|
||||||
|
2. If no draw separates them, fall back to four of the six board-corner hexes,
|
||||||
|
which are pairwise non-adjacent by construction and at most one of which can
|
||||||
|
be the desert.
|
||||||
|
|
||||||
|
`no seed puts two red tokens on adjacent hexes` runs the whole thing over seeds
|
||||||
|
1..500.
|
||||||
|
|
||||||
|
Harbours: the nine **positions** are fixed (the physical frame is not shuffled),
|
||||||
|
but the nine **types** are shuffled onto them — **five 2:1 resource harbours**
|
||||||
|
(one per resource) and **four generic 3:1 harbours**. The frame positions are
|
||||||
|
chosen so no two harbours sit closer than three coastal edges apart.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Placement runs in snake order: seats `0, 1, …, n-1` then `n-1, …, 1, 0`, each
|
||||||
|
placing one settlement and one road. During setup the distance rule still holds
|
||||||
|
(a settlement may not sit one edge from any other building) but the road need
|
||||||
|
not connect to anything already placed.
|
||||||
|
|
||||||
|
A player's **second** settlement earns one resource card for each adjacent
|
||||||
|
producing hex, supply permitting — duplicates counted, so a second settlement on
|
||||||
|
two forests earns two lumber. The first pass earns nothing.
|
||||||
|
|
||||||
|
After the last placement, seat 0 leads and the first real turn begins.
|
||||||
|
|
||||||
|
## Turn
|
||||||
|
|
||||||
|
A turn runs through the phases in `CatanPhase`:
|
||||||
|
|
||||||
|
1. **AWAIT_ROLL** — the player on the clock rolls.
|
||||||
|
2. A seven goes to **DISCARDING** and then **ROBBER**; anything else produces and
|
||||||
|
goes straight to **PLAYING**.
|
||||||
|
3. **PLAYING** — build, trade, buy and play development cards, then end the turn.
|
||||||
|
4. **FREE_ROADS** is a detour inside PLAYING while a Road Building card is being
|
||||||
|
spent.
|
||||||
|
|
||||||
|
### Rolling
|
||||||
|
|
||||||
|
The dice are derived from the game seed and a monotonic counter (`rngStep`), not
|
||||||
|
from a random generator stored in the state: the same seed and the same moves
|
||||||
|
replay identically. `RollDice(forced = …)` exists so tests can stage a number;
|
||||||
|
it is deliberately **not** a member of the enumerated move list, so a client
|
||||||
|
cannot send one over the network — only code holding the state directly can use
|
||||||
|
it.
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
For a non-seven total, each hex whose number matches pays its producing terrain:
|
||||||
|
a settlement takes **1**, a city takes **2**. The robber's hex pays nothing.
|
||||||
|
|
||||||
|
**When the bank cannot meet the demand for a resource, nobody receives that
|
||||||
|
resource this roll.** This is the official ruling, implemented per-resource:
|
||||||
|
demand is totalled first and, if it exceeds the bank's supply, every gain of
|
||||||
|
that resource is dropped.
|
||||||
|
|
||||||
|
## The robber
|
||||||
|
|
||||||
|
A **seven** sends the robber: first every player holding **more than seven**
|
||||||
|
resource cards discards half their hand (rounded down, so nine cards means four
|
||||||
|
gone), in seat order starting from the lowest overfull seat; then the roller
|
||||||
|
moves the robber and may steal.
|
||||||
|
|
||||||
|
The robber **must move to a different hex**. Moving onto a hex lets the roller
|
||||||
|
steal one card from one opponent who has a building on that hex and at least one
|
||||||
|
card in hand. The card stolen is chosen at random, derived like the dice. A
|
||||||
|
hex with no opponent holding cards offers only the move, no steal.
|
||||||
|
|
||||||
|
A played Knight enters the same ROBBER phase, so the robber move after a Knight
|
||||||
|
is the same code path.
|
||||||
|
|
||||||
|
## Trading
|
||||||
|
|
||||||
|
### Domestic
|
||||||
|
|
||||||
|
An offer is addressed to **one** seat or, with `to = null`, to **everyone**, and
|
||||||
|
names two card multisets: what the offerer gives, what they want.
|
||||||
|
|
||||||
|
Seats are asked **one at a time**, in seat order after the offerer (seats holding
|
||||||
|
no cards are skipped; an addressed offer asks only its addressee). The seat being
|
||||||
|
asked may:
|
||||||
|
|
||||||
|
- **accept** — the trade happens at once and the offer closes (first accept wins);
|
||||||
|
- **decline** — the offer moves on to the next seat;
|
||||||
|
- **counter** (`CounterTrade`) — propose different terms: what they would give and
|
||||||
|
what they want from the offerer. A counter must differ from the offer as made
|
||||||
|
(that is an accept), and both sides must hold the cards. The offer moves on.
|
||||||
|
|
||||||
|
When every seat has answered and nobody accepted: with no counters the offer
|
||||||
|
simply lapses; with counters the phase becomes `TRADE_COUNTERS` and the offerer
|
||||||
|
either **takes one** (`AcceptCounter(seat)`, still checked for affordability) or
|
||||||
|
**turns them all down** (`DismissCounters`).
|
||||||
|
|
||||||
|
An offer is legal only when the offerer's turn is in PLAYING, the addressee is
|
||||||
|
another seat, both legs are non-empty, the two legs are not the same multiset
|
||||||
|
(`countOf(give) != countOf(get)` — like-for-like is not a trade), and the
|
||||||
|
offerer actually holds the cards being offered.
|
||||||
|
|
||||||
|
A seat may **accept** only if it holds what was asked and the offerer still holds
|
||||||
|
what was promised; otherwise only decline (or counter) is offered. A refusal
|
||||||
|
leaves every card where it was.
|
||||||
|
|
||||||
|
Offers are free-form intents rather than enumerated moves (the space of card
|
||||||
|
multisets is combinatorial), so they are validated structurally by
|
||||||
|
`CatanRules.isLegalMove` and checked by the network driver's gate like any other
|
||||||
|
move.
|
||||||
|
|
||||||
|
### Maritime
|
||||||
|
|
||||||
|
Bank trade gives `ratio` of one resource for one of another, where the ratio is
|
||||||
|
the best the seat can get: **4:1** through the bank, **3:1** through a generic
|
||||||
|
harbour, **2:1** through a harbour matching the resource being given. A harbour
|
||||||
|
counts when the seat owns a settlement or city on **either** endpoint vertex of
|
||||||
|
its edge.
|
||||||
|
|
||||||
|
## Building and development cards
|
||||||
|
|
||||||
|
| Build | Cost |
|
||||||
|
| --- | --- |
|
||||||
|
| Road | 1 lumber + 1 brick |
|
||||||
|
| Settlement | 1 lumber + 1 brick + 1 wool + 1 grain |
|
||||||
|
| City | 2 grain + 3 ore |
|
||||||
|
| Development card | 1 wool + 1 grain + 1 ore |
|
||||||
|
|
||||||
|
A settlement goes on an **empty vertex** at least two edges from every standing
|
||||||
|
building and connected to the seat's own road network. A city upgrades one of
|
||||||
|
the seat's own settlements. A road goes on an empty edge that connects to the
|
||||||
|
seat's network: it must start from a crossing where the seat has a building, or
|
||||||
|
where one of its roads ends.
|
||||||
|
|
||||||
|
**An opponent's settlement or city on a crossing breaks the network there.** A
|
||||||
|
road cannot be laid past it, even from a crossing where one of your own roads
|
||||||
|
ends — only a crossing you own a building on is a way through. The same rule
|
||||||
|
applies to the free roads of a Road Building card, and it is the same wall that
|
||||||
|
cuts Longest Road (below).
|
||||||
|
|
||||||
|
**Pieces are limited**: each player has **15 roads, 5 settlements and 4 cities**.
|
||||||
|
A player who has laid them all cannot build another of that kind. Raising a
|
||||||
|
settlement into a city returns the settlement to the supply, so the five
|
||||||
|
settlements remain available to be laid again.
|
||||||
|
|
||||||
|
The development deck is the published 25 cards: **14 Knights, 2 Road Building,
|
||||||
|
2 Monopoly, 2 Year of Plenty, 5 Victory Point**. Cards are bought blind from the
|
||||||
|
top of the deck.
|
||||||
|
|
||||||
|
Timing rules the engine enforces:
|
||||||
|
|
||||||
|
- **One development card per turn.** `devCardPlayedThisTurn` gates every play.
|
||||||
|
- **A card cannot be played the turn it was bought.** Each card records
|
||||||
|
`boughtTurn` and is skipped while that equals the current turn.
|
||||||
|
- **Victory Point cards are never played.** They are counted straight from the
|
||||||
|
hand by `victoryPoints`, and are only revealed by winning.
|
||||||
|
- **Knight** → the robber phase, and counts toward Largest Army.
|
||||||
|
- **Road Building** → two free road placements (FREE_ROADS); the player may
|
||||||
|
forfeit any that remain.
|
||||||
|
- **Monopoly** → seizes every card of the named resource from the other seats.
|
||||||
|
- **Year of Plenty** → takes up to two cards from the bank; taking two of a kind
|
||||||
|
requires the bank to hold both.
|
||||||
|
|
||||||
|
## Scoring and winning
|
||||||
|
|
||||||
|
Victory points are:
|
||||||
|
|
||||||
|
- **Settlement 1, city 2.**
|
||||||
|
- **Longest Road 5** — the longest single road of **five or more** segments.
|
||||||
|
- **Largest Army 5** — first to **three** played knights.
|
||||||
|
- **Victory Point cards**, one each.
|
||||||
|
|
||||||
|
A seat wins on reaching **points to win** (default 10). The win is checked after
|
||||||
|
every build, when a turn ends, and when a seat's turn arrives — the last covers
|
||||||
|
points that changed on someone else's turn, such as a broken Longest Road being
|
||||||
|
re-awarded. A seat already at the target therefore wins at the start of its own
|
||||||
|
turn rather than having to act.
|
||||||
|
|
||||||
|
### Longest Road
|
||||||
|
|
||||||
|
The award goes to the longest **simple path** over a seat's own roads. The path
|
||||||
|
never passes *through* an opponent's building (it may run up to one) and never
|
||||||
|
revisits a vertex — so a figure of two loops sharing an intersection counts as
|
||||||
|
one road of five, not two roads, because the intersection is a branch.
|
||||||
|
|
||||||
|
Once held, the award is kept until another seat builds a **strictly longer**
|
||||||
|
road; an equal length leaves it where it is. If the holder's road falls below
|
||||||
|
five segments the award reopens to the longest road of five or more, lowest seat
|
||||||
|
first on a tie. A settlement placed across an opponent's road breaks it, and the
|
||||||
|
award is recomputed for everyone after any settlement.
|
||||||
|
|
||||||
|
### Largest Army
|
||||||
|
|
||||||
|
First to three knights takes it. It is stolen only by a **strictly larger** army:
|
||||||
|
a tie leaves the incumbent ahead, exactly as Longest Road does.
|
||||||
|
|
||||||
|
## The computer opponent
|
||||||
|
|
||||||
|
`CatanAi` plays a legal, mildly strategic game so a solo player can host against
|
||||||
|
computers, with the lobby filling empty seats.
|
||||||
|
|
||||||
|
It reads **only public information** — terrain, numbers, buildings, roads,
|
||||||
|
resource counts, development-card counts and knights played — plus its own hand.
|
||||||
|
It never looks into the deck, another seat's development cards, or the seed
|
||||||
|
beyond the roll the rules derive from it. That is a design constraint, not an
|
||||||
|
accident: it runs on the host, where the authoritative state is fully visible.
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
|
||||||
|
- **Setup.** Rates a settlement by the production its three hexes give (weighted
|
||||||
|
by the token odds and by how much it wants each resource), plus a bonus for
|
||||||
|
resource variety and for sitting on a harbour, plus a small term for where the
|
||||||
|
accompanying road points.
|
||||||
|
- **Main phase**, in priority order: raise a city, build a settlement, play a
|
||||||
|
knight, play Year of Plenty or Monopoly when it closes a gap, play Road
|
||||||
|
Building when two segments are useful, buy a development card, build a road
|
||||||
|
that reaches a spot worth settling, trade at the bank only when the trade
|
||||||
|
completes a build this turn, make one domestic offer that closes a build, else
|
||||||
|
end the turn.
|
||||||
|
- **The robber.** Moves onto the hex where it blocks the most opposing
|
||||||
|
production (weighted by token odds), preferring to rob whoever is holding the
|
||||||
|
most cards and the most points.
|
||||||
|
- **Trades.** Accepts an offer when what arrives is worth more than what leaves
|
||||||
|
by its own resource valuations; a mild table, not a shark.
|
||||||
|
- **Discards.** Throws away what it values least (wool, then brick, then lumber,
|
||||||
|
before grain and ore).
|
||||||
|
|
||||||
|
It makes **at most one domestic offer a turn**. A declined offer changes nothing,
|
||||||
|
so re-offering the same thing would only bounce between two seats forever; the
|
||||||
|
engine allows more offers, the AI chooses not to.
|
||||||
|
|
||||||
|
`CatanAiTest` plays three- and four-seat tables of engines out to a result from
|
||||||
|
several seeds, refusing any illegal move the AI chooses, and checks the winner
|
||||||
|
reached the target.
|
||||||
|
|
||||||
|
## Provenance and rule choices
|
||||||
|
|
||||||
|
The rules are the published base game. Where the published rules leave room, the
|
||||||
|
engine picks one reading and records it here.
|
||||||
|
|
||||||
|
**Followed from the published rules:** the board and its component mix; the
|
||||||
|
number tokens; 6s and 8s not adjacent; snake-order setup and second-settlement
|
||||||
|
income; production weighting; the robber on a seven; discard half over seven;
|
||||||
|
domestic and maritime trade; build costs; the 25-card development deck and its
|
||||||
|
timing rules; Longest Road and Largest Army worth five; victory at ten.
|
||||||
|
|
||||||
|
**Choices made here, and why:**
|
||||||
|
|
||||||
|
- **A bank short of a resource pays nobody that roll.** This is the official
|
||||||
|
ruling, and it is implemented per-resource rather than per-player.
|
||||||
|
- **Longest Road and Largest Army are kept on a tie**, and stolen only by a
|
||||||
|
strictly longer road or a strictly larger army. The alternative reading lets a
|
||||||
|
tie steal the award; "strictly better" is the common tournament reading and
|
||||||
|
keeps the award from flickering.
|
||||||
|
- **A ring road counts as a simple path.** No vertex is revisited, so six
|
||||||
|
segments around one hex are worth five, not six.
|
||||||
|
- **The win is checked when a seat's turn arrives**, not only on that seat's own
|
||||||
|
action, so points that move on another player's turn still win.
|
||||||
|
- **A bought Victory Point card does not win immediately.** It is counted when
|
||||||
|
the win is next checked (a build or the end of the turn), which is when the
|
||||||
|
holder would reveal it.
|
||||||
|
- **Year of Plenty two-of-a-kind needs the bank to hold both cards**, not one.
|
||||||
|
- **The robber must move.** Some printings allow staying put; this engine always
|
||||||
|
requires a different hex.
|
||||||
|
- **Offers may be open to the whole table** and can be countered; the first accept wins (see Trading).
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- **Longest Road and Largest Army are worth 5 each here.** The published base game awards
|
||||||
|
**2** each (Catan Universe's almanac says so for Largest Army, and the printed rules
|
||||||
|
for both). Cities & Knights scores Longest Road at 2, as printed. Left unchanged in
|
||||||
|
the base game so far; it is a one-constant change in `publicVictoryPoints`.
|
||||||
|
- **A bank short of a resource pays nobody that roll**, even when only one player is owed
|
||||||
|
it. The published ruling lets a lone claimant take what is left; Cities & Knights does.
|
||||||
|
- **No timer or cancel on offers.** An offer waits for each asked seat in turn; the offerer cannot withdraw it mid-round, and a player who never answers stalls the table.
|
||||||
|
- **The AI never counters.** It accepts, declines, and picks among counters it is sent, but does not propose them.
|
||||||
|
- **No trading away a development card.** Not part of the base game.
|
||||||
|
- **The screen is built, not yet verified on two devices.** The Katan screen and
|
||||||
|
setup options exist (see [UI-catan.md](UI-catan.md)); the catalogue entry is a
|
||||||
|
dev-channel preview until two-device play has been verified.
|
||||||
|
- **The AI does not look ahead.** It scores one move deep with a fixed resource
|
||||||
|
valuation. It plays legally and plausibly; it is not a strong opponent, and no
|
||||||
|
strength claim is made.
|
||||||
|
- **The board is the standard published layout, generated.** Unlike the Sequence
|
||||||
|
board there is no unverified substitute here — the geometry is the real one —
|
||||||
|
but the *island* (terrain and token placement) is freshly generated each game,
|
||||||
|
so it will not match any particular physical set-up. That is the intent.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Saved games
|
||||||
|
|
||||||
|
A game played against the computer (or on one device) is kept as it is played, and can be
|
||||||
|
picked up again after the app has been closed.
|
||||||
|
|
||||||
|
## What the player sees
|
||||||
|
|
||||||
|
- **Main menu → Continue.** Each game left half-played is listed, most recent first
|
||||||
|
("Chess · saved 5 minutes ago"); tapping it resumes.
|
||||||
|
- **A game's setup screen** shows a *Game in progress* card with **Resume** and **Discard**.
|
||||||
|
Starting a new game from that screen replaces the saved one.
|
||||||
|
- **Leaving the table** (the back arrow, or the app being closed or sent to the background)
|
||||||
|
keeps the position. **End game → End the game** is the deliberate way to throw it away.
|
||||||
|
- A game that is **finished** is not kept.
|
||||||
|
- A **networked** table is not kept: the other player's seat cannot be held for them. Only a
|
||||||
|
table with no remote human at it (`PlayerKind.HUMAN_REMOTE`) is saved.
|
||||||
|
|
||||||
|
One slot per game: there is one saved Chess game, one saved Katan game, and so on.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
| Piece | Where | Does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `SavedMatch`, `SavedGameStore` | `game/engine/SavedGame.kt` | one JSON file per game under `filesDir/saved-games/`, written atomically (temp file, then renamed); an unreadable file is deleted and read as absent |
|
||||||
|
| `MatchController.onHostState`, `restore` | `net/MatchController.kt` | the host reports every position it reaches; `restore(json)` starts the table from a saved position instead of dealing |
|
||||||
|
| `MatchSaver` | `net/MatchSaver.kt` | takes the newest position and writes it off the main thread; `flush()` writes synchronously on leaving or on `ON_STOP`; `discard()` forgets the game |
|
||||||
|
| `StartMatch` | `ui/PlayScreen.kt` | wires the two together for every game; consumes the resume state once, so a solitaire "new deal" is not put back |
|
||||||
|
| `LocalEndGameHandle` | `ui/EndGameHandle.kt` | how each *End game* dialog tells the table to forget its save |
|
||||||
|
|
||||||
|
What is saved is `rules.encodeState` of the **host's true state** (hidden cards included),
|
||||||
|
plus the `TableConfig` (seed, seats, options) and the local seat. Nothing in the store knows
|
||||||
|
which game it is holding, so a new game is saved with no work, provided its state encodes.
|
||||||
|
|
||||||
|
A save this build cannot decode (one written by an older version whose state has since
|
||||||
|
changed shape) is thrown away and a fresh game is dealt.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `SavedGameStoreTest` — the files: round trip, one slot, ordering, corruption, a cut-off write.
|
||||||
|
- `MatchSaverTest` — saving as a table is played, resuming from a save, finished games, flush, discard.
|
||||||
|
- `SaveRoundTripAllGamesTest` — **every game in the catalogue**: the whole state is encoded and
|
||||||
|
decoded at every move of a computer-played game, and play carries on from the decoded copy.
|
||||||
|
A new game should be added to it.
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# Katan UI — how it is built, and how to change it
|
||||||
|
|
||||||
|
The Katan screen (`app/src/main/java/org/prolibertate/games/ui/game/catan/`) is
|
||||||
|
built in layers so that **look, wording, layout and interaction can each be
|
||||||
|
changed on their own**. If you are an agent asked to restyle, retheme,
|
||||||
|
re-copy or re-lay-out Katan, start here and touch only the layer you were asked
|
||||||
|
about.
|
||||||
|
|
||||||
|
The rules live elsewhere and are not part of the UI: `game/catan/` is a pure
|
||||||
|
state machine (see [RULES-catan.md](RULES-catan.md)). **Nothing in `ui/game/catan/`
|
||||||
|
decides what is legal.** The engine's legal-move list is the single source of
|
||||||
|
truth, so the UI can only ever offer a move the rules would accept.
|
||||||
|
|
||||||
|
## The layers
|
||||||
|
|
||||||
|
| Layer | File | Owns | Change it to… |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **Style** | `CatanStyle.kt` | Every colour, proportion and the light/dark palettes | Re-skin: new terrain colours, seat colours, piece sizes, token look |
|
||||||
|
| **Copy** | `CatanText.kt` | Every sentence, button label and name shown | Reword, localise, rename resources |
|
||||||
|
| **Geometry** | `CatanGeometry.kt` | Engine lattice → pixels, and pixel → nearest vertex/edge/hex | Change board scale, margin, harbour placement, tap reach (rarely) |
|
||||||
|
| **Interaction** | `CatanInteraction.kt` | What is tappable, what a tap means, which buttons are live | Change *how* something is selected (never *whether* it is legal) |
|
||||||
|
| **Board drawing** | `CatanBoardView.kt` | The `Canvas`: tiles, tokens, ports, robber, roads, pieces, highlights | Change how a piece is drawn (art, shapes, animation) |
|
||||||
|
| **Panels** | `CatanPanels.kt` | Resource tiles, hand, player cards, dice, action bar, log | Restyle or rearrange widgets |
|
||||||
|
| **Dialogs** | `CatanDialogs.kt` | Discard, robber victim, play card, trade, trade reply, game over | Change a dialog's flow or look |
|
||||||
|
| **Screen** | `CatanScreen.kt` | Wiring to `MatchController`, portrait/landscape layout, which dialog shows | Change the layout or add a new element |
|
||||||
|
| **Previews** | `CatanPreviews.kt` | Android Studio previews of real positions | Iterate on the look without a match |
|
||||||
|
|
||||||
|
Everything outside this folder that mentions Katan is one-line wiring:
|
||||||
|
`PlayScreen.kt` (builds the `MatchController`), `GameSetupScreen.kt` (players and
|
||||||
|
points-to-win options), `Catalog.kt` (the catalogue entry).
|
||||||
|
|
||||||
|
## Recipes
|
||||||
|
|
||||||
|
**Change colours or sizes.** Edit `CatanStyle.kt`. `LightStyle` is the reference
|
||||||
|
palette; `DarkStyle` is derived from it. Metrics (`roadWidth`, `settlementSize`,
|
||||||
|
`tokenRadius`, …) are fractions of the hex radius, so they scale with the screen.
|
||||||
|
To try a whole new theme, provide it at the root:
|
||||||
|
`CompositionLocalProvider(LocalCatanStyle provides myStyle) { … }`.
|
||||||
|
|
||||||
|
**Change wording or translate.** Edit `CatanText.kt`. Composables never build a
|
||||||
|
sentence themselves. `prompt(...)` is the one-line "what is happening / what do I
|
||||||
|
do" text; it is a pure function of the state, with tests in `CatanTextTest`.
|
||||||
|
|
||||||
|
**Redraw a piece** (settlement, road, robber, port, token). Edit the matching
|
||||||
|
`draw…` function in `CatanBoardView.kt`. Positions come from `BoardLayout`, so you
|
||||||
|
only decide the shape. Keep the hit target generous (see Accessibility).
|
||||||
|
|
||||||
|
**Rearrange the screen.** Edit `CatanScreen.kt`. It builds four independent
|
||||||
|
pieces (`board`, `players`, `promptRow`, `handAndActions`) and arranges them
|
||||||
|
differently in portrait and landscape. Moving or dropping a piece does not affect
|
||||||
|
the others.
|
||||||
|
|
||||||
|
**Add a widget.** Make it a stateless composable in `CatanPanels.kt` (values in,
|
||||||
|
lambdas out). Do not give it the `MatchController`.
|
||||||
|
|
||||||
|
**Change how a selection works** (for example a one-tap build instead of a
|
||||||
|
build-mode toggle). Edit `CatanInteraction.kt` and its tests, then adjust the
|
||||||
|
screen's UI state. The legal-move list stays the gate.
|
||||||
|
|
||||||
|
## Rules of the road
|
||||||
|
|
||||||
|
1. **Never put a rule in a composable.** If a button's enabled state or a tap's
|
||||||
|
result depends on game logic, it goes in `CatanInteraction` (pure, tested) and
|
||||||
|
is derived from the engine's `legal` list or `CatanRules`.
|
||||||
|
2. **Never hard-code a colour or a string** outside `CatanStyle` / `CatanText`.
|
||||||
|
3. **Stateless by default.** Panels and dialogs take values and report events. The
|
||||||
|
only state they may own is the transient selection they are helping the player
|
||||||
|
make (cards picked in a trade, for example).
|
||||||
|
4. **Hidden information.** The local view comes from `CatanRules.viewFor`: other
|
||||||
|
players' development cards and the deck are empty on a client. Do not show the
|
||||||
|
size of the deck; it is not known to a client. Card *counts* (`resourceCount`,
|
||||||
|
`devHandCounts`) are public.
|
||||||
|
5. **The state is immutable.** Never mutate it; submit a move.
|
||||||
|
6. **The layout must not move.** Everything outside the board has a *fixed
|
||||||
|
height*, and the board takes whatever is left, so the island's scale is the
|
||||||
|
same all game. Concretely: a control is *enabled or disabled*, never
|
||||||
|
*present or absent* (the action bar always shows every button); text that can
|
||||||
|
vary in length is pinned to a fixed number of lines (`minLines = maxLines`);
|
||||||
|
a line that is sometimes empty is still drawn (a non-breaking space), as the
|
||||||
|
award line on a player card is. A change that makes any non-board element
|
||||||
|
taller or shorter at runtime will rescale the board under the player's thumb.
|
||||||
|
|
||||||
|
## Interaction model (what the player actually does)
|
||||||
|
|
||||||
|
| Phase | The player… |
|
||||||
|
| --- | --- |
|
||||||
|
| Setup | taps a highlighted crossing (settlement), then a highlighted edge beside it (road). *Cancel* re-picks the settlement |
|
||||||
|
| Roll | presses **Roll dice** |
|
||||||
|
| Seven | **Discard** dialog (forced); then taps a hex to move the robber; a victim dialog appears only if more than one player can be robbed |
|
||||||
|
| Main turn | arms **Road / Settlement / City** and taps a highlighted spot; **Buy card**; **Play card**; **Trade**; **End turn** |
|
||||||
|
| Road Building | taps highlighted edges, or **Done with roads** |
|
||||||
|
| Offered a trade | **Accept / Decline / Counter…** dialog (forced); *Counter…* opens a picker pre-filled with the mirror of the offer |
|
||||||
|
| Counter-offers received | **Counter-offers** dialog: *Accept* beside the one to take, or *No deal* |
|
||||||
|
| Game over | result dialog |
|
||||||
|
|
||||||
|
Trade offers are free-form (two card multisets), so they are validated with
|
||||||
|
`CatanRules.isLegalMove`, not looked up in the legal list.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Gradle does not run in the agent sandbox; **CI is the compile and test check**
|
||||||
|
(dispatch `ci.yml` on your branch). What is covered:
|
||||||
|
|
||||||
|
- `CatanGeometryTest` — the island fits any box, edges and corners are one radius,
|
||||||
|
hit-testing finds the right vertex/edge/hex.
|
||||||
|
- `CatanInteractionTest` — targets and tap outcomes for setup, building, free
|
||||||
|
roads, the robber (including the victim choice), discards, bank trades, offers,
|
||||||
|
development cards and trade replies, all against real engine states.
|
||||||
|
- `CatanTextTest` — the prompts and tallies.
|
||||||
|
|
||||||
|
Drawing and dialogs are not unit-tested; use the previews in `CatanPreviews.kt`
|
||||||
|
and play a game on a dev build.
|
||||||
|
|
||||||
|
## Accessibility notes
|
||||||
|
|
||||||
|
- Tap targets: vertices and edges accept a tap within `tapReach` (0.7) hex radii,
|
||||||
|
about 23 dp on a phone. If you shrink the board, keep that physical size.
|
||||||
|
- Seat colours are blue, orange, white and purple, each outlined in near-black so
|
||||||
|
they read on any terrain. Colour is the only piece identity today; adding a
|
||||||
|
shape or letter per seat would help colour-blind players (a good first task).
|
||||||
|
- The board has a content description but no per-hex semantics; a screen-reader
|
||||||
|
mode would need one.
|
||||||
|
|
||||||
|
## Cities & Knights on the screen
|
||||||
|
|
||||||
|
The same layers carry the expansion; the screen only adds pieces, and **none of them
|
||||||
|
decides what is legal** — each reads the engine's legal-move list.
|
||||||
|
|
||||||
|
| Layer | What it adds |
|
||||||
|
| --- | --- |
|
||||||
|
| `CatanStyle.kt` | commodity colours, a colour per improvement track, the wall and barbarian colours, the awake-knight halo |
|
||||||
|
| `CatanText.kt` | goods, track, knight and progress-card names and help text; the prompts for every chore |
|
||||||
|
| `CatanInteraction.kt` | `BuildKind.WALL` / `KNIGHT`; chore taps; the moving-knight and armed-card modes; `knightActions`, `progressHand`, `goodsBankTrades` |
|
||||||
|
| `CatanBoardView.kt` | shields for knights (strength dots; faded when asleep, white halo when awake), dashed walls, a pennant for each metropolis, the merchant coin, a faint robber until the first attack |
|
||||||
|
| `CatanPanels.kt` | commodity tiles in the hand, the barbarian strip, the yellow / red / event dice, the extra player-card lines, the Improve and Progress buttons |
|
||||||
|
| `CatanCkDialogs.kt` | goods picker (discard, wedding, harbour), improvements, progress cards, the knight menu, deck / aqueduct / spy / drop choices, goods trade |
|
||||||
|
| `CatanScreen.kt` | the visual state: knight menu, moving knight, armed card, and which chore dialog shows |
|
||||||
|
|
||||||
|
**Interaction model.** Tap one of your own knights (it gets a yellow ring when it can do
|
||||||
|
something) for its menu: wake, promote, move or chase the robber. *Move* arms the board
|
||||||
|
with the crossings the knight can reach. A progress card that needs a place on the board
|
||||||
|
(Engineer, Medicine, Merchant, Diplomat, Intrigue, Inventor) closes the dialog and
|
||||||
|
**arms** the board; the next tap plays it. Chores that are answered with a place — a city
|
||||||
|
to lose, a metropolis site, a driven knight's refuge, a free knight — light the crossings
|
||||||
|
on the board and the prompt line says what to tap; the others open a dialog that cannot
|
||||||
|
be dismissed, because the game is waiting on it.
|
||||||
|
|
||||||
|
**Testing.** `CatanCkInteractionTest` plays whole games and checks that for *every* legal
|
||||||
|
move at *every* position some control (a tap, a button or a dialog choice) produces it, so
|
||||||
|
a legal move with no way to make it — a stuck phone — fails the build.
|
||||||
|
|
||||||
|
## Status and known gaps
|
||||||
|
|
||||||
|
The screen is **playable and feature-complete for the engine's rules**, but is
|
||||||
|
**unverified on two real devices**, so the catalogue entry is `devPreview`
|
||||||
|
(openable on a dev build, hidden from production). To release it: verify
|
||||||
|
two-device play, then in `Catalog.kt` set `available = true` and remove
|
||||||
|
`devPreview = true`, and update `CatalogTest`.
|
||||||
|
|
||||||
|
Not done yet, in rough priority order:
|
||||||
|
|
||||||
|
1. Two-device verification (host + guest over the LAN) and a full game on a phone
|
||||||
|
and a tablet.
|
||||||
|
2. Feedback on events: dice-roll animation, a highlight on hexes that produced,
|
||||||
|
resource-gain flourishes, haptics. The state carries the dice and a text log;
|
||||||
|
it does not say which hexes paid out, so that needs either a diff of two states
|
||||||
|
or an engine addition.
|
||||||
|
3. Art: terrain textures, resource and harbour icons, a real robber pawn.
|
||||||
|
4. A timer or cancel button on offers (the engine has neither).
|
||||||
|
5. A board that shows the *last* move (a faint highlight of what just changed).
|
||||||
|
6. Landscape polish on large tablets; the right-hand panel scrolls but is plain.
|
||||||
Reference in New Issue
Block a user