Merge dev: release 1.1
Verified on dev at 61db79e, the same tree: build, unit tests and Android
lint green, and it published v1.1.62-dev.
This commit is contained in:
@@ -34,7 +34,7 @@ jobs:
|
||||
# Stamp the APK as a dev-channel build exactly when it is a build of the
|
||||
# dev branch, which is exactly when it can be published below. Pull
|
||||
# request builds run against refs/pull/N/merge, so they stay unstamped at
|
||||
# 1.0.0-dev: they are never published and must not claim a channel
|
||||
# <series>.0-dev: they are never published and must not claim a channel
|
||||
# version.
|
||||
#
|
||||
# This condition has to stay in step with the publish steps' own. Stamping
|
||||
@@ -96,7 +96,12 @@ jobs:
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="v1.0.${{ github.run_number }}-dev"
|
||||
# The series comes from gradle.properties, which is the same file the
|
||||
# build read when it stamped the APK — so the tag and what is inside
|
||||
# it cannot name different versions.
|
||||
series="$(sed -n 's/^plgVersionSeries=//p' gradle.properties)"
|
||||
test -n "$series"
|
||||
version="v${series}.${{ github.run_number }}-dev"
|
||||
src="app/build/outputs/apk/debug/app-debug.apk"
|
||||
dest="ProLibertateGames-${version}.apk"
|
||||
cp "$src" "$dest"
|
||||
|
||||
@@ -49,7 +49,12 @@ jobs:
|
||||
id: stage
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="v1.0.${{ github.run_number }}"
|
||||
# The series comes from gradle.properties, which is the same file the
|
||||
# build read when it stamped the APK — so the tag and what is inside
|
||||
# it cannot name different versions.
|
||||
series="$(sed -n 's/^plgVersionSeries=//p' gradle.properties)"
|
||||
test -n "$series"
|
||||
version="v${series}.${{ github.run_number }}"
|
||||
src="app/build/outputs/apk/debug/app-debug.apk"
|
||||
dest="ProLibertateGames-${version}.apk"
|
||||
cp "$src" "$dest"
|
||||
|
||||
@@ -46,7 +46,7 @@ Skipping step 4 leaves a branch whose head was never built and no release.
|
||||
Both long-lived branches publish, so a merge is a release, not just an
|
||||
integration:
|
||||
|
||||
- **`dev`** → `ci.yml` publishes a `v1.0.N-dev` **prerelease**, offered by the
|
||||
- **`dev`** → `ci.yml` publishes a `v<series>.N-dev` **prerelease**, offered by the
|
||||
in-app updater to anyone on the Dev channel.
|
||||
- **`main`** → `release.yml` publishes a **production** release, which the
|
||||
updater installs for everyone on the Production channel.
|
||||
|
||||
@@ -5,20 +5,23 @@ or against other people over Wi-Fi (LAN).
|
||||
|
||||
## What works today
|
||||
|
||||
The app shell is complete and ten games are playable end to end:
|
||||
The app shell is complete and every game in the catalogue is playable end to end:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **Playable** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s, Sequence, Chess, Ta Yü, Nine Men's Morris |
|
||||
| **Listed, not yet implemented** | Mastermind, Backgammon, Checkers, Pirates and Bulgars |
|
||||
| **Cards** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s |
|
||||
| **Board** | Sequence, Chess, Checkers, Backgammon, Mastermind, Nine Men's Morris, Pirates and Bulgars, Ta Yü |
|
||||
|
||||
Unimplemented games appear in the menu marked *Coming soon* **on dev builds
|
||||
only** — a production release lists just the games that can actually be played,
|
||||
so nobody installs it and taps into a dead end. The menu decides this from the
|
||||
installed APK's own version name, so it follows the build rather than the
|
||||
channel selected for future updates. Adding one means
|
||||
writing a `GameRules` implementation, a `GameAi`, and a screen, then flipping
|
||||
`available` in `GameCatalog` — the menu, lobby, networking, settings and OTA
|
||||
Nothing is marked *Coming soon* at the moment, because nothing is waiting. The
|
||||
machinery for it is still there and still tested: a game listed in `GameCatalog`
|
||||
with `available = false` appears in the menu marked *Coming soon* **on dev builds
|
||||
only**, so a production release lists just what can actually be played and nobody
|
||||
installs it and taps into a dead end. The menu decides this from the installed
|
||||
APK's own version name, so it follows the build rather than the channel selected
|
||||
for future updates.
|
||||
|
||||
Adding a game means writing a `GameRules` implementation, a `GameAi` and a
|
||||
screen, then flipping `available` — the menu, lobby, networking, settings and OTA
|
||||
layers are game-agnostic and need no changes.
|
||||
|
||||
## Playing with other people
|
||||
@@ -162,9 +165,10 @@ production package instead.
|
||||
|
||||
- `.github/workflows/ci.yml` — builds, tests and lints on `dev` and on pull
|
||||
requests into `dev` or `main`. Its APK artifact is a dev build, versioned
|
||||
`1.0.0-dev`.
|
||||
`<series>.0-dev` — the series being whatever `plgVersionSeries` says in
|
||||
`gradle.properties`, currently **1.1**.
|
||||
- `.github/workflows/release.yml` — on push to `main`, builds the APK and
|
||||
publishes it as a GitHub Release tagged `v1.0.<run-number>`. The in-app updater
|
||||
publishes it as a GitHub Release tagged `v<series>.<run-number>`. The in-app updater
|
||||
reads that tag, which is why the APK's `versionCode` is the same run number.
|
||||
|
||||
### Installing without the app
|
||||
@@ -183,14 +187,34 @@ prereleases by design, and that is exactly what keeps a production install from
|
||||
being handed a dev build. Dev builds are reached through the in-app updater with
|
||||
the channel set to dev, or from the releases page.
|
||||
|
||||
### The version series
|
||||
|
||||
A version is **`<series>.<run number>`**. The series is set by hand, once, in
|
||||
`gradle.properties`:
|
||||
|
||||
```properties
|
||||
plgVersionSeries=1.1
|
||||
```
|
||||
|
||||
Both workflows read that same property when they name a release tag, and the
|
||||
build reads it when it stamps the APK, so the tag and what is inside it cannot
|
||||
name different versions. Bumping a series is a one-line change to that file and
|
||||
nothing else.
|
||||
|
||||
The number after it is the CI run number and keeps climbing on its own, which is
|
||||
what makes a series bump safe: `versionCode` is the run number *alone*, so
|
||||
going from 1.0 to 1.1 never looks like a downgrade to Android and never
|
||||
interrupts an OTA update. The series is a name for people; the run number is
|
||||
what the machines compare.
|
||||
|
||||
### Update channels
|
||||
|
||||
Both pipelines publish, to two separate channels:
|
||||
|
||||
| Channel | Published by | Tag | Marked |
|
||||
| --- | --- | --- | --- |
|
||||
| Production | `release.yml`, push to `main` | `v1.0.<n>` | release |
|
||||
| Dev | `ci.yml`, any build of `dev` | `v1.0.<n>-dev` | prerelease |
|
||||
| Production | `release.yml`, push to `main` | `v<series>.<n>` | release |
|
||||
| Dev | `ci.yml`, any build of `dev` | `v<series>.<n>-dev` | prerelease |
|
||||
|
||||
Dev builds are published as **prereleases** deliberately: GitHub's "latest
|
||||
release" endpoint skips prereleases, so a production-channel install can never
|
||||
@@ -269,7 +293,14 @@ game:
|
||||
- [Wizard](docs/RULES-wizard.md)
|
||||
- [Crazy 8s](docs/RULES-crazy8s.md)
|
||||
- [Chess](docs/RULES-chess.md)
|
||||
- [Checkers](docs/RULES-checkers.md) — **note the caveat about flying kings**
|
||||
- [Backgammon](docs/RULES-backgammon.md) — **note that there is no doubling cube**
|
||||
- [Mastermind](docs/RULES-mastermind.md) — played as a duel: each player sets
|
||||
the code the other has to break
|
||||
- [Nine Men's Morris](docs/RULES-morris.md)
|
||||
- [Pirates and Bulgars](docs/RULES-pirates.md) — **note the caveats about the
|
||||
reconstruction**: the book the theme comes from could not be obtained, so the
|
||||
document says which rules are attested and which were chosen
|
||||
- [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
|
||||
document says which parts are attested and which were derived
|
||||
@@ -289,6 +320,10 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested
|
||||
crazyeights/ Crazy 8s model, rules, AI
|
||||
chess/ Chess model, FEN, move generation, search
|
||||
morris/ Nine Men's Morris board geometry, mills, rules, search
|
||||
checkers/ Checkers board, compulsory captures, crowning, search
|
||||
backgammon/ Backgammon points, dice, the maximal-roll rule, turn search
|
||||
mastermind/ Mastermind codes, scoring, and a code breaker
|
||||
pirates/ Pirates and Bulgars — the cross board, the hunt, search
|
||||
tayu/ Ta Yü tile geometry, river rules, scoring, AI
|
||||
net/ wire protocol, LAN transport, lobby, match driver
|
||||
score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store
|
||||
|
||||
+12
-4
@@ -16,6 +16,11 @@ val buildChannel = System.getenv("PLG_CHANNEL").orEmpty()
|
||||
val runNumber = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 0
|
||||
val stampVersion = buildChannel.isNotEmpty() && runNumber > 0
|
||||
|
||||
// The version series, from gradle.properties. Both workflows read the same
|
||||
// property when they name a release tag, so the tag and the APK inside it
|
||||
// cannot disagree about which series they belong to.
|
||||
val versionSeries = (project.findProperty("plgVersionSeries") as String?) ?: "1.0"
|
||||
|
||||
// Anything that is not an explicit production build is a dev build: ci.yml
|
||||
// stamps "dev", and a local build with nothing set counts as dev too.
|
||||
//
|
||||
@@ -43,12 +48,15 @@ android {
|
||||
// know its own channel, so it must survive into the shipped APK. It
|
||||
// tracks isDevBuild rather than the stamp alone, so an unstamped local
|
||||
// build cannot end up claiming a channel its applicationId contradicts.
|
||||
// versionCode stays the run number and nothing else, so it keeps
|
||||
// climbing across a change of series: bumping 1.0 to 1.1 must not make
|
||||
// an update look like a downgrade to Android.
|
||||
versionCode = if (stampVersion) runNumber else 1
|
||||
versionName = when {
|
||||
stampVersion && buildChannel == "production" -> "1.0.$runNumber"
|
||||
stampVersion -> "1.0.$runNumber-dev"
|
||||
isDevBuild -> "1.0.0-dev"
|
||||
else -> "1.0.0"
|
||||
stampVersion && buildChannel == "production" -> "$versionSeries.$runNumber"
|
||||
stampVersion -> "$versionSeries.$runNumber-dev"
|
||||
isDevBuild -> "$versionSeries.0-dev"
|
||||
else -> "$versionSeries.0"
|
||||
}
|
||||
|
||||
// The launcher label. With both copies on the phone the app drawer shows
|
||||
|
||||
@@ -40,6 +40,7 @@ object GameCatalog {
|
||||
const val CHECKERS = "checkers"
|
||||
const val TAYU = "tayu"
|
||||
const val MORRIS = "morris"
|
||||
const val PIRATES = "pirates"
|
||||
|
||||
val all: List<GameDescriptor> = listOf(
|
||||
GameDescriptor(
|
||||
@@ -119,8 +120,9 @@ object GameCatalog {
|
||||
minPlayers = 2,
|
||||
maxPlayers = 2,
|
||||
teamBased = false,
|
||||
blurb = "Break the hidden code from black and white peg clues.",
|
||||
available = false,
|
||||
blurb = "Set a code for your opponent and break the one they set for you. " +
|
||||
"Whoever cracks theirs first wins.",
|
||||
available = true,
|
||||
),
|
||||
GameDescriptor(
|
||||
id = BACKGAMMON,
|
||||
@@ -130,7 +132,7 @@ object GameCatalog {
|
||||
maxPlayers = 2,
|
||||
teamBased = false,
|
||||
blurb = "Race your checkers home, and hit your opponent on the way.",
|
||||
available = false,
|
||||
available = true,
|
||||
),
|
||||
GameDescriptor(
|
||||
id = CHESS,
|
||||
@@ -150,7 +152,7 @@ object GameCatalog {
|
||||
maxPlayers = 2,
|
||||
teamBased = false,
|
||||
blurb = "Forced captures, kings, and the long diagonal.",
|
||||
available = false,
|
||||
available = true,
|
||||
),
|
||||
GameDescriptor(
|
||||
id = MORRIS,
|
||||
@@ -162,6 +164,17 @@ object GameCatalog {
|
||||
blurb = "Three in a row takes a piece. Reduce them to two and the game is yours.",
|
||||
available = true,
|
||||
),
|
||||
GameDescriptor(
|
||||
id = PIRATES,
|
||||
title = "Pirates and Bulgars",
|
||||
category = GameCategory.BOARD,
|
||||
minPlayers = 2,
|
||||
maxPlayers = 2,
|
||||
teamBased = false,
|
||||
blurb = "Two pirates hold the stronghold against twenty-four Bulgars. " +
|
||||
"Only one side can take pieces, and it is not the big one.",
|
||||
available = true,
|
||||
),
|
||||
GameDescriptor(
|
||||
id = TAYU,
|
||||
title = "Ta Yü",
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
package org.prolibertate.games.game.backgammon
|
||||
|
||||
import kotlin.random.Random
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
* A backgammon player.
|
||||
*
|
||||
* This is not a search in the sense the chess and checkers players are. There
|
||||
* is no tree to walk: the opponent's reply depends on dice nobody has thrown
|
||||
* yet, so looking further ahead means averaging over twenty-one rolls rather
|
||||
* than choosing the best one. What it does instead is weigh **whole turns** —
|
||||
* every distinct way the roll can be played — and score the board each leaves
|
||||
* behind.
|
||||
*
|
||||
* Weighing turns rather than checkers is the whole point. A backgammon move is
|
||||
* only good in company: splitting the back checkers is right or wrong depending
|
||||
* on what the other die did, and a player that chose one checker at a time
|
||||
* would never see it.
|
||||
*
|
||||
* The interface hands back one checker at a time, so the best turn is worked
|
||||
* out and its first step returned; the rest follows on the moves after, because
|
||||
* the same calculation reaches the same turn.
|
||||
*/
|
||||
class BackgammonAi(private val level: BackgammonLevel? = null) :
|
||||
GameAi<BackgammonState, BackgammonMove> {
|
||||
|
||||
override fun chooseMove(
|
||||
state: BackgammonState,
|
||||
seat: Int,
|
||||
legal: List<BackgammonMove>,
|
||||
): BackgammonMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
val setting = level ?: state.options.level
|
||||
if (legal.size == 1) return legal.first()
|
||||
|
||||
val random = Random(state.rollsMade * 31L + state.moveLog.size)
|
||||
|
||||
if (setting == BackgammonLevel.CASUAL) {
|
||||
// Not thoughtless: it still prefers to hit, to come in off the bar
|
||||
// and to bear off, which is most of what a beginner knows.
|
||||
val scored = legal.map { move -> move to rough(state, seat, move) }
|
||||
val best = scored.maxOf { it.second }
|
||||
val equal = scored.filter { it.second == best }
|
||||
return equal[random.nextInt(equal.size)].first
|
||||
}
|
||||
|
||||
val sequences = BackgammonRules.deepestSequences(state, seat)
|
||||
if (sequences.isEmpty()) return legal.first()
|
||||
|
||||
var best = sequences.first()
|
||||
var bestScore = -Double.MAX_VALUE
|
||||
for (sequence in sequences) {
|
||||
val after = BackgammonRules.playSequence(state, seat, sequence)
|
||||
val score = if (setting == BackgammonLevel.STRONG) {
|
||||
// What the position is worth once the dice have had their say.
|
||||
evaluate(after, seat) - averageReply(after, other(seat))
|
||||
} else {
|
||||
evaluate(after, seat)
|
||||
}
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
best = sequence
|
||||
}
|
||||
}
|
||||
return best.first()
|
||||
}
|
||||
|
||||
/** A quick opinion of one checker, for the level that does not think. */
|
||||
private fun rough(state: BackgammonState, seat: Int, move: BackgammonMove): Int {
|
||||
val landing = BackgammonRules.destinationOf(seat, move.from, move.die)
|
||||
var score = 0
|
||||
if (move.from == BAR) score += 50
|
||||
if (landing == null) score += 40
|
||||
if (landing != null && state.countOn(landing, other(seat)) == 1) score += 60
|
||||
if (landing != null && state.countOn(landing, seat) >= 1) score += 15
|
||||
return score
|
||||
}
|
||||
|
||||
/**
|
||||
* What the opponent is likely to make of this position, averaged over the
|
||||
* twenty-one distinct rolls of two dice.
|
||||
*
|
||||
* Doubles come up half as often as a mixed pair — 6-5 can be thrown two
|
||||
* ways and 5-5 only one — so each roll is weighted rather than counted.
|
||||
* Only the opponent's best answer to each roll is taken: they will not
|
||||
* play a bad one on purpose.
|
||||
*/
|
||||
private fun averageReply(state: BackgammonState, opponent: Int): Double {
|
||||
if (state.phase == BackgammonPhase.GAME_OVER) return 0.0
|
||||
|
||||
var total = 0.0
|
||||
var weight = 0.0
|
||||
for (high in 1..6) {
|
||||
for (low in 1..high) {
|
||||
val chance = if (high == low) 1.0 else 2.0
|
||||
val dice = if (high == low) List(4) { high } else listOf(high, low)
|
||||
val rolled = state.copy(turn = opponent, dice = dice, roll = listOf(high, low))
|
||||
val replies = BackgammonRules.deepestSequences(rolled, opponent)
|
||||
val bestReply = replies.maxOfOrNull { sequence ->
|
||||
evaluate(BackgammonRules.playSequence(rolled, opponent, sequence), opponent)
|
||||
} ?: evaluate(rolled, opponent)
|
||||
total += chance * bestReply
|
||||
weight += chance
|
||||
}
|
||||
}
|
||||
return total / weight
|
||||
}
|
||||
|
||||
/**
|
||||
* The position from [seat]'s point of view.
|
||||
*
|
||||
* The pip count is the backbone of it — backgammon is a race, and the
|
||||
* player who is ahead in the race wants to keep it simple. Everything else
|
||||
* is about what happens when it is not simple: **a blot is only a liability
|
||||
* if it can be hit**, so exposure is weighted by how far away the enemy
|
||||
* checkers are, and a made point in your own home board is worth having
|
||||
* because it is one more number your opponent cannot come in on.
|
||||
*/
|
||||
private fun evaluate(state: BackgammonState, seat: Int): Double {
|
||||
val enemy = other(seat)
|
||||
|
||||
if (state.phase == BackgammonPhase.GAME_OVER) {
|
||||
val won = state.off[seat] >= CHECKERS
|
||||
return if (won) WIN else -WIN
|
||||
}
|
||||
|
||||
var score = 0.0
|
||||
|
||||
// The race. Being ahead in pips is the plainest good thing there is.
|
||||
score += (state.pipCount(enemy) - state.pipCount(seat)) * PIP
|
||||
|
||||
// Checkers home and dry, and checkers sent back to the beginning.
|
||||
score += state.off[seat] * BORNE_OFF
|
||||
score -= state.off[enemy] * BORNE_OFF
|
||||
score -= state.bar[seat] * ON_BAR
|
||||
score += state.bar[enemy] * ON_BAR
|
||||
|
||||
for (point in 0 until POINTS) {
|
||||
val mine = state.countOn(point, seat)
|
||||
if (mine == 0) continue
|
||||
|
||||
if (mine >= 2) {
|
||||
score += POINT_MADE
|
||||
// A point in your own home board is a door shut in the face of
|
||||
// anyone you send to the bar.
|
||||
if (point in homeOf(seat)) score += HOME_POINT
|
||||
// And an anchor deep in theirs is somewhere safe to come back to.
|
||||
if (point in homeOf(enemy)) score += ANCHOR
|
||||
} else {
|
||||
// A blot, weighted by how easily it can be reached: something
|
||||
// six away is in real danger, something twenty away is not.
|
||||
val distance = shortestEnemyDistance(state, seat, point)
|
||||
score -= when {
|
||||
distance <= 0 -> 0.0
|
||||
distance <= 6 -> BLOT_NEAR
|
||||
distance <= 12 -> BLOT_FAR
|
||||
else -> BLOT_DISTANT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
score += primeLength(state, seat) * PRIME
|
||||
return score
|
||||
}
|
||||
|
||||
/** How far the nearest enemy checker is from reaching [point], in pips. */
|
||||
private fun shortestEnemyDistance(state: BackgammonState, seat: Int, point: Int): Int {
|
||||
val enemy = other(seat)
|
||||
// Anything on the bar comes in at the far end and is a threat to the
|
||||
// whole board on its way round.
|
||||
if (state.bar[enemy] > 0) {
|
||||
val fromBar = if (enemy == WHITE_SEAT) 24 - point else point + 1
|
||||
if (fromBar in 1..6) return fromBar
|
||||
}
|
||||
var best = Int.MAX_VALUE
|
||||
for (from in 0 until POINTS) {
|
||||
if (state.countOn(from, enemy) == 0) continue
|
||||
val gap = (point - from) * directionOf(enemy)
|
||||
if (gap in 1..24 && gap < best) best = gap
|
||||
}
|
||||
return if (best == Int.MAX_VALUE) 0 else best
|
||||
}
|
||||
|
||||
/** The longest run of consecutive points held, which is what traps a checker. */
|
||||
private fun primeLength(state: BackgammonState, seat: Int): Int {
|
||||
var best = 0
|
||||
var run = 0
|
||||
for (point in 0 until POINTS) {
|
||||
if (state.countOn(point, seat) >= 2) {
|
||||
run++
|
||||
if (run > best) best = run
|
||||
} else {
|
||||
run = 0
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val WIN = 10_000.0
|
||||
|
||||
const val PIP = 1.0
|
||||
const val BORNE_OFF = 12.0
|
||||
const val ON_BAR = 25.0
|
||||
const val POINT_MADE = 4.0
|
||||
const val HOME_POINT = 8.0
|
||||
const val ANCHOR = 6.0
|
||||
const val PRIME = 5.0
|
||||
const val BLOT_NEAR = 14.0
|
||||
const val BLOT_FAR = 6.0
|
||||
const val BLOT_DISTANT = 2.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package org.prolibertate.games.game.backgammon
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The board is twenty-four points, 0 to 23.
|
||||
*
|
||||
* **White runs down the numbers and bears off past 0; Black runs up them and
|
||||
* bears off past 23.** White's home board is points 0–5, Black's is 18–23, and
|
||||
* each enters from the bar into the *other* player's home: White at 24 minus
|
||||
* the die, Black at the die minus one.
|
||||
*
|
||||
* A point holds a positive number of White checkers or a negative number of
|
||||
* Black ones, never both — which is the rule that a point belongs to whoever
|
||||
* has two or more on it, expressed as arithmetic rather than as a check.
|
||||
*/
|
||||
const val POINTS = 24
|
||||
|
||||
const val CHECKERS = 15
|
||||
|
||||
const val WHITE_SEAT = 0
|
||||
const val BLACK_SEAT = 1
|
||||
|
||||
/** Where a checker sits when it has been hit, as a [BackgammonMove.from]. */
|
||||
const val BAR = 24
|
||||
|
||||
fun other(seat: Int): Int = 1 - seat
|
||||
|
||||
fun seatName(seat: Int): String = if (seat == WHITE_SEAT) "White" else "Black"
|
||||
|
||||
/** Which way this seat's checkers travel along the numbering. */
|
||||
fun directionOf(seat: Int): Int = if (seat == WHITE_SEAT) -1 else 1
|
||||
|
||||
/** The six points this seat bears off from. */
|
||||
fun homeOf(seat: Int): IntRange = if (seat == WHITE_SEAT) 0..5 else 18..23
|
||||
|
||||
/** Where a checker coming off the bar lands for a given die. */
|
||||
fun entryPoint(seat: Int, die: Int): Int = if (seat == WHITE_SEAT) 24 - die else die - 1
|
||||
|
||||
/**
|
||||
* How far a point is from being borne off, counted in pips: 1 for the last
|
||||
* point before home, 24 for the far corner. The pip count is the sum of these
|
||||
* over a player's checkers and is the single most useful number in the game.
|
||||
*/
|
||||
fun pipsFrom(seat: Int, point: Int): Int = if (seat == WHITE_SEAT) point + 1 else 24 - point
|
||||
|
||||
@Serializable
|
||||
enum class BackgammonPhase { PLAYING, GAME_OVER }
|
||||
|
||||
/**
|
||||
* How a game ended. Backgammon scores by how badly the loser lost: a plain win
|
||||
* is worth one point, a gammon two — the loser bore nothing off — and a
|
||||
* backgammon three, where the loser still had a checker on the bar or in the
|
||||
* winner's home when it finished.
|
||||
*/
|
||||
@Serializable
|
||||
enum class BackgammonOutcome(val label: String, val points: Int) {
|
||||
WHITE_SINGLE("White wins", 1),
|
||||
WHITE_GAMMON("White wins a gammon — Black bore nothing off", 2),
|
||||
WHITE_BACKGAMMON("White wins a backgammon — Black was still stranded", 3),
|
||||
BLACK_SINGLE("Black wins", 1),
|
||||
BLACK_GAMMON("Black wins a gammon — White bore nothing off", 2),
|
||||
BLACK_BACKGAMMON("Black wins a backgammon — White was still stranded", 3),
|
||||
}
|
||||
|
||||
/** How hard the computer thinks about it. */
|
||||
@Serializable
|
||||
enum class BackgammonLevel(val label: String) {
|
||||
/** Takes the first playable thing it finds a reason for. */
|
||||
CASUAL("Casual"),
|
||||
|
||||
/** Weighs every way the turn could be played and takes the best. */
|
||||
CLUB("Club"),
|
||||
|
||||
/**
|
||||
* The same, and then weighs what the dice might do to it next — every one
|
||||
* of the twenty-one distinct rolls, and the best reply to each.
|
||||
*/
|
||||
STRONG("Strong"),
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class BackgammonOptions(
|
||||
val level: BackgammonLevel = BackgammonLevel.CLUB,
|
||||
/**
|
||||
* Score a gammon as two and a backgammon as three, rather than calling
|
||||
* every win a win. It changes nothing about the moves and everything about
|
||||
* whether a hopeless game is worth playing out.
|
||||
*/
|
||||
val countGammons: Boolean = true,
|
||||
)
|
||||
|
||||
/**
|
||||
* One checker moving one die's worth.
|
||||
*
|
||||
* A turn is two moves, or four on a double, and each is submitted on its own —
|
||||
* which is how a person plays it, one checker at a time. What keeps that honest
|
||||
* is that the legal moves are recomputed after each one: a move that would
|
||||
* strand the rest of the turn is never offered, because a move is only legal if
|
||||
* it starts a way of playing the whole roll.
|
||||
*/
|
||||
@Serializable
|
||||
data class BackgammonMove(val from: Int, val die: Int) {
|
||||
override fun toString(): String {
|
||||
val origin = if (from == BAR) "bar" else "${from + 1}"
|
||||
return "$origin/$die"
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class BackgammonState(
|
||||
val options: BackgammonOptions,
|
||||
/**
|
||||
* The table's own seed, which every die of the game is thrown from
|
||||
* together with [rollsMade]. It is part of the state rather than kept
|
||||
* beside it so that a game is reproducible from what crosses the wire, and
|
||||
* so two games running at once cannot get hold of each other's dice.
|
||||
*
|
||||
* Nothing is given away by sending it: backgammon has nothing hidden, and
|
||||
* the host is authoritative about every roll in any case.
|
||||
*/
|
||||
val seed: Long,
|
||||
/** Positive is that many White checkers, negative that many Black ones. */
|
||||
val points: List<Int>,
|
||||
/** Checkers waiting to come back on, by seat. */
|
||||
val bar: List<Int>,
|
||||
/** Checkers safely borne off, by seat. */
|
||||
val off: List<Int>,
|
||||
val turn: Int,
|
||||
/** The dice still to be played this turn. A double is four of them. */
|
||||
val dice: List<Int>,
|
||||
/** The pair as it was rolled, kept so the screen can show it whole. */
|
||||
val roll: List<Int>,
|
||||
/** How many rolls have been made, which is what makes the next one. */
|
||||
val rollsMade: Int,
|
||||
val phase: BackgammonPhase,
|
||||
val outcome: BackgammonOutcome?,
|
||||
val lastMove: BackgammonMove?,
|
||||
val moveLog: List<String>,
|
||||
) {
|
||||
fun countOn(point: Int, seat: Int): Int {
|
||||
val value = points[point]
|
||||
return if (seat == WHITE_SEAT) maxOf(value, 0) else maxOf(-value, 0)
|
||||
}
|
||||
|
||||
fun ownerOf(point: Int): Int? = when {
|
||||
points[point] > 0 -> WHITE_SEAT
|
||||
points[point] < 0 -> BLACK_SEAT
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** A single checker, which can be hit and sent back to the bar. */
|
||||
fun isBlot(point: Int): Boolean = points[point] == 1 || points[point] == -1
|
||||
|
||||
/** Two or more of the enemy's: nowhere to land. */
|
||||
fun isBlockedFor(point: Int, seat: Int): Boolean = countOn(point, other(seat)) >= 2
|
||||
|
||||
/** Everything home or borne off, which is what bearing off waits for. */
|
||||
fun canBearOff(seat: Int): Boolean {
|
||||
if (bar[seat] > 0) return false
|
||||
val home = homeOf(seat)
|
||||
return (0 until POINTS).none { it !in home && countOn(it, seat) > 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* The pip count: how many pips this seat still has to travel. A checker on
|
||||
* the bar has the whole board to cross, which is why it costs 25.
|
||||
*/
|
||||
fun pipCount(seat: Int): Int {
|
||||
var pips = bar[seat] * 25
|
||||
for (point in 0 until POINTS) pips += countOn(point, seat) * pipsFrom(seat, point)
|
||||
return pips
|
||||
}
|
||||
|
||||
fun checkersLeft(seat: Int): Int = CHECKERS - off[seat]
|
||||
}
|
||||
|
||||
/**
|
||||
* The standard opening array, from White's point of view: two on the 24, five
|
||||
* on the 13, three on the 8 and five on the 6 — mirrored for Black.
|
||||
*/
|
||||
fun startingPoints(): List<Int> {
|
||||
val points = IntArray(POINTS)
|
||||
points[23] = 2
|
||||
points[12] = 5
|
||||
points[7] = 3
|
||||
points[5] = 5
|
||||
points[0] = -2
|
||||
points[11] = -5
|
||||
points[16] = -3
|
||||
points[18] = -5
|
||||
return points.toList()
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
package org.prolibertate.games.game.backgammon
|
||||
|
||||
import kotlin.random.Random
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.engine.GameRules
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
|
||||
/**
|
||||
* Backgammon as a pure state machine. See RULES-backgammon.md.
|
||||
*
|
||||
* The hard rule in backgammon is not how a checker moves, it is that **a player
|
||||
* must use as much of the roll as they can**. It cannot be checked one move at
|
||||
* a time, because whether a move is legal depends on what is left afterwards: a
|
||||
* move that looks fine can be illegal precisely because playing it strands the
|
||||
* other die.
|
||||
*
|
||||
* So the generator works backwards from whole turns. It searches out every way
|
||||
* the roll could be played, keeps only the longest, and offers their first
|
||||
* steps. Recomputing that after each checker means the rule is enforced by
|
||||
* construction — a player physically cannot reach a position where the rest of
|
||||
* their roll is unplayable when it need not have been.
|
||||
*
|
||||
* The dice are rolled from the table's own seed and a count of the rolls made,
|
||||
* so a game is reproducible from the config it started with and a host and a
|
||||
* client cannot disagree about what was thrown.
|
||||
*/
|
||||
object BackgammonRules : GameRules<BackgammonState, BackgammonMove> {
|
||||
|
||||
override val gameId: String = GameCatalog.BACKGAMMON
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
override fun initialState(config: TableConfig): BackgammonState {
|
||||
require(config.seats.size == 2) { "Backgammon is a two-handed game" }
|
||||
val options = json.decodeFromString<BackgammonOptions>(config.optionsJson)
|
||||
return initialState(options, config.seed)
|
||||
}
|
||||
|
||||
fun initialState(
|
||||
options: BackgammonOptions = BackgammonOptions(),
|
||||
seed: Long = 0L,
|
||||
): BackgammonState {
|
||||
// The opening roll is one die each and the higher starts, playing both
|
||||
// — so a double is thrown again rather than played.
|
||||
var rolls = 0
|
||||
var white: Int
|
||||
var black: Int
|
||||
do {
|
||||
white = die(seed, rolls++)
|
||||
black = die(seed, rolls++)
|
||||
} while (white == black)
|
||||
|
||||
val opener = if (white > black) WHITE_SEAT else BLACK_SEAT
|
||||
val roll = listOf(white, black).sortedDescending()
|
||||
|
||||
return BackgammonState(
|
||||
options = options,
|
||||
seed = seed,
|
||||
points = startingPoints(),
|
||||
bar = listOf(0, 0),
|
||||
off = listOf(0, 0),
|
||||
turn = opener,
|
||||
dice = roll,
|
||||
roll = roll,
|
||||
rollsMade = rolls,
|
||||
phase = BackgammonPhase.PLAYING,
|
||||
outcome = null,
|
||||
lastMove = null,
|
||||
moveLog = emptyList(),
|
||||
).skipIfStuck()
|
||||
}
|
||||
|
||||
private fun die(seed: Long, index: Int): Int = Random(seed * 1_000_003L + index).nextInt(6) + 1
|
||||
|
||||
override fun currentSeat(state: BackgammonState): Int? =
|
||||
if (state.phase == BackgammonPhase.GAME_OVER) null else state.turn
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Moving one checker
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Where a checker from [from] lands with [die], or null if it runs off the board. */
|
||||
fun destinationOf(seat: Int, from: Int, die: Int): Int? {
|
||||
if (from == BAR) return entryPoint(seat, die)
|
||||
val landing = from + directionOf(seat) * die
|
||||
return landing.takeIf { it in 0 until POINTS }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether one checker may be played, ignoring what it leaves behind. The
|
||||
* "use as much of the roll as you can" rule is applied by [legalMoves] on
|
||||
* top of this.
|
||||
*/
|
||||
private fun canPlay(state: BackgammonState, seat: Int, move: BackgammonMove): Boolean {
|
||||
if (move.die !in state.dice) return false
|
||||
|
||||
// Anything on the bar comes back on first; nothing else may move.
|
||||
if (state.bar[seat] > 0 && move.from != BAR) return false
|
||||
if (move.from == BAR) {
|
||||
if (state.bar[seat] <= 0) return false
|
||||
val entry = entryPoint(seat, move.die)
|
||||
return !state.isBlockedFor(entry, seat)
|
||||
}
|
||||
|
||||
if (state.countOn(move.from, seat) <= 0) return false
|
||||
|
||||
val landing = destinationOf(seat, move.from, move.die)
|
||||
if (landing != null) return !state.isBlockedFor(landing, seat)
|
||||
|
||||
// Off the end of the board is bearing off, which has rules of its own.
|
||||
return canBearOffFrom(state, seat, move.from, move.die)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearing off: the exact die takes a checker from the point that needs it,
|
||||
* and a bigger die may take one from the highest occupied point when
|
||||
* nothing sits further back. An overshooting die may never take a checker
|
||||
* that a smaller one could have reached.
|
||||
*/
|
||||
private fun canBearOffFrom(
|
||||
state: BackgammonState,
|
||||
seat: Int,
|
||||
from: Int,
|
||||
die: Int,
|
||||
): Boolean {
|
||||
if (!state.canBearOff(seat)) return false
|
||||
val needed = pipsFrom(seat, from)
|
||||
if (die == needed) return true
|
||||
if (die < needed) return false
|
||||
// The die overshoots, so it is only allowed if this is the furthest
|
||||
// checker from home.
|
||||
return (0 until POINTS).none { point ->
|
||||
state.countOn(point, seat) > 0 && pipsFrom(seat, point) > needed
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies one checker's move to the board, hitting a blot if there is one. */
|
||||
private fun place(state: BackgammonState, seat: Int, move: BackgammonMove): BackgammonState {
|
||||
val points = state.points.toMutableList()
|
||||
val bar = state.bar.toMutableList()
|
||||
val off = state.off.toMutableList()
|
||||
val sign = if (seat == WHITE_SEAT) 1 else -1
|
||||
|
||||
if (move.from == BAR) {
|
||||
bar[seat] = bar[seat] - 1
|
||||
} else {
|
||||
points[move.from] = points[move.from] - sign
|
||||
}
|
||||
|
||||
val landing = destinationOf(seat, move.from, move.die)
|
||||
if (landing == null) {
|
||||
off[seat] = off[seat] + 1
|
||||
} else {
|
||||
// A lone enemy checker on the landing point is hit and starts again.
|
||||
if (state.countOn(landing, other(seat)) == 1) {
|
||||
points[landing] = 0
|
||||
bar[other(seat)] = bar[other(seat)] + 1
|
||||
}
|
||||
points[landing] = points[landing] + sign
|
||||
}
|
||||
|
||||
val dice = state.dice.toMutableList()
|
||||
dice.remove(move.die)
|
||||
|
||||
return state.copy(points = points, bar = bar, off = off, dice = dice, lastMove = move)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Moving a whole turn
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fun legalMoves(state: BackgammonState): List<BackgammonMove> = legalMoves(state, state.turn)
|
||||
|
||||
/**
|
||||
* The first steps of every way of playing as much of the roll as possible.
|
||||
*
|
||||
* Two ways of playing that both use every die are equally legal however
|
||||
* they differ; one that uses a single die when both could have been used is
|
||||
* not a legal turn at all. And when only one die can be played, it must be
|
||||
* the higher — which is the one part of the rule that does not fall out of
|
||||
* counting alone, so it is applied by hand at the end.
|
||||
*/
|
||||
override fun legalMoves(state: BackgammonState, seat: Int): List<BackgammonMove> {
|
||||
if (state.phase == BackgammonPhase.GAME_OVER || state.turn != seat) return emptyList()
|
||||
if (state.dice.isEmpty()) return emptyList()
|
||||
|
||||
val memo = mutableMapOf<String, Int>()
|
||||
val target = depthOf(state, seat, memo)
|
||||
if (target == 0) return emptyList()
|
||||
|
||||
val moves = candidates(state, seat).filter { move ->
|
||||
1 + depthOf(place(state, seat, move), seat, memo) == target
|
||||
}.distinct()
|
||||
|
||||
if (target == 1) {
|
||||
val highest = moves.maxOf { it.die }
|
||||
return moves.filter { it.die == highest }
|
||||
}
|
||||
return moves
|
||||
}
|
||||
|
||||
/**
|
||||
* How many of the remaining dice can still be played, at best.
|
||||
*
|
||||
* Memoised on the position rather than on the path to it: the four orders a
|
||||
* double can be played in mostly reach the same boards, and without the
|
||||
* memo the search would walk every one of them separately. The cut-off when
|
||||
* the best possible depth is reached matters just as much — most turns find
|
||||
* a full-length play immediately and never look at the rest.
|
||||
*/
|
||||
private fun depthOf(
|
||||
state: BackgammonState,
|
||||
seat: Int,
|
||||
memo: MutableMap<String, Int>,
|
||||
): Int {
|
||||
if (state.dice.isEmpty()) return 0
|
||||
val key = turnKey(state)
|
||||
memo[key]?.let { return it }
|
||||
|
||||
var best = 0
|
||||
for (move in candidates(state, seat)) {
|
||||
val depth = 1 + depthOf(place(state, seat, move), seat, memo)
|
||||
if (depth > best) best = depth
|
||||
if (best == state.dice.size) break
|
||||
}
|
||||
memo[key] = best
|
||||
return best
|
||||
}
|
||||
|
||||
/** What a half-played turn amounts to: the board, the bar, and the dice left. */
|
||||
private fun turnKey(state: BackgammonState): String =
|
||||
state.points.joinToString(",") + "|" + state.bar.joinToString(",") +
|
||||
"|" + state.off.joinToString(",") + "|" + state.dice.sorted().joinToString(",")
|
||||
|
||||
/**
|
||||
* Every longest way of playing what is left of the roll.
|
||||
*
|
||||
* Only for the computer, which wants to weigh whole turns rather than
|
||||
* single checkers. Branches that cannot reach full length are cut before
|
||||
* they are walked, and the list is capped: a double with checkers spread
|
||||
* across the board has more orderings than are worth looking at, and they
|
||||
* are mostly the same play in a different order anyway.
|
||||
*/
|
||||
fun deepestSequences(state: BackgammonState, seat: Int): List<List<BackgammonMove>> {
|
||||
val memo = mutableMapOf<String, Int>()
|
||||
val target = depthOf(state, seat, memo)
|
||||
if (target == 0) return emptyList()
|
||||
|
||||
val found = mutableListOf<List<BackgammonMove>>()
|
||||
val seen = mutableSetOf<String>()
|
||||
|
||||
fun walk(current: BackgammonState, played: List<BackgammonMove>) {
|
||||
if (found.size >= SEQUENCE_CAP) return
|
||||
if (played.size == target) {
|
||||
// Two orderings that end on the same board are one play.
|
||||
if (seen.add(turnKey(current))) found += played
|
||||
return
|
||||
}
|
||||
for (move in candidates(current, seat)) {
|
||||
val next = place(current, seat, move)
|
||||
if (played.size + 1 + depthOf(next, seat, memo) == target) {
|
||||
walk(next, played + move)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(state, emptyList())
|
||||
|
||||
if (target == 1 && found.isNotEmpty()) {
|
||||
val highest = found.maxOf { it.single().die }
|
||||
return found.filter { it.single().die == highest }
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/** The checkers that could move with one of the dice still in hand. */
|
||||
private fun candidates(state: BackgammonState, seat: Int): List<BackgammonMove> {
|
||||
if (state.dice.isEmpty()) return emptyList()
|
||||
val dice = state.dice.distinct()
|
||||
val origins = if (state.bar[seat] > 0) {
|
||||
listOf(BAR)
|
||||
} else {
|
||||
(0 until POINTS).filter { state.countOn(it, seat) > 0 }
|
||||
}
|
||||
return origins.flatMap { from ->
|
||||
dice.mapNotNull { die ->
|
||||
BackgammonMove(from, die).takeIf { canPlay(state, seat, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun applyMove(
|
||||
state: BackgammonState,
|
||||
seat: Int,
|
||||
move: BackgammonMove,
|
||||
): BackgammonState {
|
||||
require(state.phase != BackgammonPhase.GAME_OVER) { "The game is over" }
|
||||
require(state.turn == seat) { "It is not seat $seat's turn" }
|
||||
require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" }
|
||||
|
||||
val played = place(state, seat, move)
|
||||
.copy(moveLog = state.moveLog + "${seatName(seat)} $move")
|
||||
|
||||
val finished = played.withTerminalCheck()
|
||||
if (finished.phase == BackgammonPhase.GAME_OVER) return finished
|
||||
|
||||
// The turn ends when the dice run out or nothing more can be played.
|
||||
return if (played.dice.isEmpty() || legalMoves(played, seat).isEmpty()) {
|
||||
played.passTurn()
|
||||
} else {
|
||||
played
|
||||
}
|
||||
}
|
||||
|
||||
/** Hands the dice to the other player and throws them. */
|
||||
private fun BackgammonState.passTurn(): BackgammonState {
|
||||
val next = other(turn)
|
||||
val first = die(seed, rollsMade)
|
||||
val second = die(seed, rollsMade + 1)
|
||||
// A double is four moves of the same number, which is the one piece of
|
||||
// luck in backgammon big enough to decide a game on its own.
|
||||
val thrown = if (first == second) List(4) { first } else listOf(first, second)
|
||||
return copy(
|
||||
turn = next,
|
||||
dice = thrown,
|
||||
roll = listOf(first, second),
|
||||
rollsMade = rollsMade + 2,
|
||||
).skipIfStuck()
|
||||
}
|
||||
|
||||
/**
|
||||
* A player with nothing they can play loses the turn.
|
||||
*
|
||||
* Usually this is somebody stuck on the bar against a closed board. It can
|
||||
* happen several times over, so it keeps rolling until somebody can move —
|
||||
* with a cap, because a board that closed against a player who has nothing
|
||||
* else on it would otherwise roll for ever.
|
||||
*/
|
||||
private fun BackgammonState.skipIfStuck(): BackgammonState {
|
||||
var state = this
|
||||
var skipped = 0
|
||||
while (state.phase != BackgammonPhase.GAME_OVER &&
|
||||
legalMoves(state, state.turn).isEmpty() &&
|
||||
skipped < MAX_SKIPS
|
||||
) {
|
||||
val next = other(state.turn)
|
||||
val first = die(state.seed, state.rollsMade)
|
||||
val second = die(state.seed, state.rollsMade + 1)
|
||||
val thrown = if (first == second) List(4) { first } else listOf(first, second)
|
||||
state = state.copy(
|
||||
turn = next,
|
||||
dice = thrown,
|
||||
roll = listOf(first, second),
|
||||
rollsMade = state.rollsMade + 2,
|
||||
moveLog = state.moveLog + "${seatName(state.turn)} cannot move",
|
||||
)
|
||||
skipped++
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Fifteen checkers off is the game. How much it is worth depends on how the
|
||||
* loser was left: nothing borne off is a gammon, and still being on the bar
|
||||
* or in the winner's home on top of that is a backgammon.
|
||||
*/
|
||||
private fun BackgammonState.withTerminalCheck(): BackgammonState {
|
||||
val winner = (0..1).firstOrNull { off[it] >= CHECKERS } ?: return this
|
||||
val loser = other(winner)
|
||||
|
||||
val stranded = bar[loser] > 0 || homeOf(winner).any { countOn(it, loser) > 0 }
|
||||
val severity = when {
|
||||
!options.countGammons || off[loser] > 0 -> 0
|
||||
stranded -> 2
|
||||
else -> 1
|
||||
}
|
||||
|
||||
val outcome = if (winner == WHITE_SEAT) {
|
||||
when (severity) {
|
||||
2 -> BackgammonOutcome.WHITE_BACKGAMMON
|
||||
1 -> BackgammonOutcome.WHITE_GAMMON
|
||||
else -> BackgammonOutcome.WHITE_SINGLE
|
||||
}
|
||||
} else {
|
||||
when (severity) {
|
||||
2 -> BackgammonOutcome.BLACK_BACKGAMMON
|
||||
1 -> BackgammonOutcome.BLACK_GAMMON
|
||||
else -> BackgammonOutcome.BLACK_SINGLE
|
||||
}
|
||||
}
|
||||
return copy(phase = BackgammonPhase.GAME_OVER, outcome = outcome, dice = emptyList())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Engine contract
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
override fun isFinished(state: BackgammonState): Boolean =
|
||||
state.phase == BackgammonPhase.GAME_OVER
|
||||
|
||||
override fun summary(state: BackgammonState): String = state.outcome?.label ?: buildString {
|
||||
append(seatName(state.turn))
|
||||
append(" to play ")
|
||||
append(state.roll.joinToString("-"))
|
||||
if (state.dice.size < state.roll.size) append(" (${state.dice.size} left)")
|
||||
}
|
||||
|
||||
/** Backgammon hides nothing: the board and the dice are on the table. */
|
||||
override fun viewFor(state: BackgammonState, seat: Int): BackgammonState = state
|
||||
|
||||
override fun encodeState(state: BackgammonState): String = json.encodeToString(state)
|
||||
override fun decodeState(json: String): BackgammonState = this.json.decodeFromString(json)
|
||||
override fun encodeMove(move: BackgammonMove): String = json.encodeToString(move)
|
||||
override fun decodeMove(json: String): BackgammonMove = this.json.decodeFromString(json)
|
||||
|
||||
/**
|
||||
* Plays a whole turn's worth of moves in one go, for the search and the
|
||||
* tests. Anything that ends the turn stops it early.
|
||||
*/
|
||||
fun playSequence(
|
||||
state: BackgammonState,
|
||||
seat: Int,
|
||||
sequence: List<BackgammonMove>,
|
||||
): BackgammonState {
|
||||
var current = state
|
||||
for (move in sequence) {
|
||||
if (current.phase == BackgammonPhase.GAME_OVER || current.turn != seat) break
|
||||
current = applyMove(current, seat, move)
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
private const val MAX_SKIPS = 8
|
||||
|
||||
/** How many distinct ways of playing one roll the computer will weigh. */
|
||||
private const val SEQUENCE_CAP = 300
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package org.prolibertate.games.game.checkers
|
||||
|
||||
import kotlin.random.Random
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
* A negamax search with alpha-beta pruning and iterative deepening.
|
||||
*
|
||||
* Checkers can be searched deeper than chess for the same money: captures are
|
||||
* compulsory, so most positions offer a handful of moves rather than thirty,
|
||||
* and a forced capture costs nothing to search. That is why the levels here go
|
||||
* to seven ply where the chess ones stop at four.
|
||||
*
|
||||
* There is no quiescence tail. The usual reason for one is a search stopping in
|
||||
* the middle of an exchange, and compulsory capture takes care of that by
|
||||
* itself: a position where a piece has just been taken almost always has the
|
||||
* recapture as its only legal move, so the search plays it whether it wanted to
|
||||
* or not.
|
||||
*/
|
||||
class CheckersAi(private val level: CheckersLevel? = null) :
|
||||
GameAi<CheckersState, CheckersMove> {
|
||||
|
||||
private var nodeBudget = 0
|
||||
private var nodesUsed = 0
|
||||
|
||||
override fun chooseMove(
|
||||
state: CheckersState,
|
||||
seat: Int,
|
||||
legal: List<CheckersMove>,
|
||||
): CheckersMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
val setting = level ?: state.options.level
|
||||
if (legal.size == 1) return legal.first()
|
||||
|
||||
nodeBudget = NODE_BUDGET
|
||||
nodesUsed = 0
|
||||
|
||||
val random = Random(state.moveLog.size * 31L + state.positionKey().hashCode())
|
||||
|
||||
var best = legal.first()
|
||||
var scored = legal.map { it to 0 }
|
||||
|
||||
for (depth in 1..setting.depth) {
|
||||
val results = mutableListOf<Pair<CheckersMove, Int>>()
|
||||
val ordered = order(legal, preferred = best)
|
||||
for (move in ordered) {
|
||||
val score = -negamax(
|
||||
state = CheckersRules.advanced(state, move),
|
||||
depth = depth - 1,
|
||||
alpha = -INFINITY,
|
||||
beta = INFINITY,
|
||||
ply = 1,
|
||||
)
|
||||
results += move to score
|
||||
if (nodesUsed >= nodeBudget) break
|
||||
}
|
||||
if (nodesUsed >= nodeBudget && results.size < ordered.size) break
|
||||
scored = results
|
||||
best = results.maxBy { it.second }.first
|
||||
}
|
||||
|
||||
val bestScore = scored.maxOf { it.second }
|
||||
val acceptable = scored.filter { it.second >= bestScore - setting.slack() }
|
||||
return acceptable[random.nextInt(acceptable.size)].first
|
||||
}
|
||||
|
||||
private fun CheckersLevel.slack(): Int = when (this) {
|
||||
CheckersLevel.CASUAL -> 80
|
||||
CheckersLevel.CLUB -> 20
|
||||
CheckersLevel.STRONG -> 0
|
||||
}
|
||||
|
||||
private fun negamax(state: CheckersState, depth: Int, alpha: Int, beta: Int, ply: Int): Int {
|
||||
nodesUsed++
|
||||
|
||||
if (state.phase == CheckersPhase.GAME_OVER) {
|
||||
// The rules leave the loser on the clock, so a finished position is
|
||||
// always a loss for the side to move — or a draw.
|
||||
return when (state.outcome) {
|
||||
CheckersOutcome.DRAW_NO_PROGRESS, CheckersOutcome.DRAW_REPETITION, null -> 0
|
||||
else -> -(WIN - ply)
|
||||
}
|
||||
}
|
||||
if (depth <= 0) return evaluate(state)
|
||||
|
||||
val moves = CheckersRules.legalMoves(state)
|
||||
if (moves.isEmpty()) return -(WIN - ply)
|
||||
|
||||
var localAlpha = alpha
|
||||
for (move in order(moves, preferred = null)) {
|
||||
val score = -negamax(
|
||||
state = CheckersRules.advanced(state, move),
|
||||
depth = depth - 1,
|
||||
alpha = -beta,
|
||||
beta = -localAlpha,
|
||||
ply = ply + 1,
|
||||
)
|
||||
if (score >= beta) return beta
|
||||
if (score > localAlpha) localAlpha = score
|
||||
if (nodesUsed >= nodeBudget) break
|
||||
}
|
||||
return localAlpha
|
||||
}
|
||||
|
||||
/** Longest captures first: they take the most and they cut the most. */
|
||||
private fun order(moves: List<CheckersMove>, preferred: CheckersMove?): List<CheckersMove> =
|
||||
moves.sortedByDescending { move ->
|
||||
when {
|
||||
move == preferred -> 1_000
|
||||
else -> move.steps.size * 10
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The position from the point of view of the side to move.
|
||||
*
|
||||
* Material first, then two things that decide checkers games far more than
|
||||
* beginners expect: **kings are worth much more than men**, and **men are
|
||||
* worth more the closer they are to being crowned**, so a search with
|
||||
* nothing else to choose between will push its men up the board rather than
|
||||
* shuffle along the back.
|
||||
*
|
||||
* The back row is worth holding for the opposite reason — an empty back row
|
||||
* is a road to a crown for the other side — so the two pull against each
|
||||
* other, which is about right.
|
||||
*/
|
||||
private fun evaluate(state: CheckersState): Int {
|
||||
val us = state.turn
|
||||
val them = other(us)
|
||||
var score = 0
|
||||
|
||||
for (square in 0 until SQUARES) {
|
||||
val piece = state.board[square] ?: continue
|
||||
val sign = if (piece.seat == us) 1 else -1
|
||||
score += sign * if (piece.king) KING else MAN
|
||||
|
||||
if (!piece.king) {
|
||||
// How many rows it has come, out of the seven it needs.
|
||||
val advanced = if (piece.seat == BLACK_SEAT) rowOf(square) else 7 - rowOf(square)
|
||||
score += sign * advanced * ADVANCE
|
||||
}
|
||||
|
||||
// Sitting on the edge cannot be jumped, which is worth a little in
|
||||
// a game whose entire tactic is being jumped.
|
||||
if (columnOf(square) == 0 || columnOf(square) == 7) score += sign * EDGE
|
||||
|
||||
if (rowOf(square) == crownRowOf(other(piece.seat))) score += sign * BACK_ROW
|
||||
}
|
||||
|
||||
score += MOBILITY * (mobility(state, us) - mobility(state, them))
|
||||
return score
|
||||
}
|
||||
|
||||
/**
|
||||
* Somewhere to go, counted as piece-to-empty-square steps rather than as
|
||||
* legal moves: the legal list is only ever the captures when a capture
|
||||
* exists, and a position is not immobile for being obliged to take.
|
||||
*/
|
||||
private fun mobility(state: CheckersState, seat: Int): Int {
|
||||
var count = 0
|
||||
for (square in 0 until SQUARES) {
|
||||
val piece = state.board[square] ?: continue
|
||||
if (piece.seat != seat) continue
|
||||
val directions = if (piece.king) {
|
||||
listOf(1 to 1, 1 to -1, -1 to 1, -1 to -1)
|
||||
} else {
|
||||
listOf(forwardOf(seat) to 1, forwardOf(seat) to -1)
|
||||
}
|
||||
for ((rowStep, columnStep) in directions) {
|
||||
val target = squareAt(rowOf(square) + rowStep, columnOf(square) + columnStep)
|
||||
if (target != null && state.board[target] == null) count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val INFINITY = 1_000_000
|
||||
const val WIN = 100_000
|
||||
const val NODE_BUDGET = 200_000
|
||||
|
||||
const val MAN = 100
|
||||
const val KING = 175
|
||||
const val ADVANCE = 6
|
||||
const val EDGE = 4
|
||||
const val BACK_ROW = 5
|
||||
const val MOBILITY = 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package org.prolibertate.games.game.checkers
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The board is the thirty-two dark squares, numbered 1–32 the way checkers has
|
||||
* always been written down: square 1 at Black's left-hand end of the top row,
|
||||
* running left to right and down the board to 32.
|
||||
*
|
||||
* Only dark squares are ever played on, so numbering them alone means a move is
|
||||
* two small numbers rather than two coordinates, the notation the game is
|
||||
* recorded in falls out for free, and there is no such thing as an illegal
|
||||
* light square to have to rule out.
|
||||
*
|
||||
* Internally the squares are 0-based; [squareName] adds the one.
|
||||
*/
|
||||
const val SQUARES = 32
|
||||
|
||||
/** The row a square sits on, 0 at the top of the board. */
|
||||
fun rowOf(square: Int): Int = square / 4
|
||||
|
||||
/**
|
||||
* The column, 0 at the left. Dark squares alternate which half of a pair of
|
||||
* columns they fall in, which is what the odd-row offset is doing.
|
||||
*/
|
||||
fun columnOf(square: Int): Int {
|
||||
val row = rowOf(square)
|
||||
val within = square % 4
|
||||
return within * 2 + if (row % 2 == 0) 1 else 0
|
||||
}
|
||||
|
||||
/** The dark square at a row and column, or null if that square is light. */
|
||||
fun squareAt(row: Int, column: Int): Int? {
|
||||
if (row !in 0..7 || column !in 0..7) return null
|
||||
val offset = if (row % 2 == 0) 1 else 0
|
||||
if ((column - offset) % 2 != 0) return null
|
||||
return row * 4 + (column - offset) / 2
|
||||
}
|
||||
|
||||
fun squareName(square: Int): String = "${square + 1}"
|
||||
|
||||
@Serializable
|
||||
enum class CheckersPhase { PLAYING, GAME_OVER }
|
||||
|
||||
@Serializable
|
||||
enum class CheckersOutcome(val label: String) {
|
||||
BLACK_WINS("Black wins"),
|
||||
WHITE_WINS("White wins"),
|
||||
DRAW_NO_PROGRESS("A draw — nothing has been taken or crowned for a long time"),
|
||||
DRAW_REPETITION("A draw by threefold repetition"),
|
||||
}
|
||||
|
||||
/** How hard the computer thinks about it. */
|
||||
@Serializable
|
||||
enum class CheckersLevel(val label: String, val depth: Int) {
|
||||
CASUAL("Casual", depth = 3),
|
||||
CLUB("Club", depth = 5),
|
||||
STRONG("Strong", depth = 7),
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class CheckersOptions(
|
||||
val level: CheckersLevel = CheckersLevel.CLUB,
|
||||
/**
|
||||
* A king slides any distance along a diagonal and may take from a
|
||||
* distance, as in international draughts. Off by default: the English game
|
||||
* this board and notation come from has kings that move one square like
|
||||
* anybody else, only in both directions.
|
||||
*/
|
||||
val flyingKings: Boolean = false,
|
||||
/**
|
||||
* A man that reaches the far row is crowned and the turn ends there, even
|
||||
* with another jump available. This is the English rule; the international
|
||||
* one lets the jump continue and crowns only if the piece finishes there.
|
||||
*/
|
||||
val crowningEndsTheTurn: Boolean = true,
|
||||
val threefoldRepetition: Boolean = true,
|
||||
/** Plies with no capture and no crowning before the game is called a draw. */
|
||||
val plyLimitWithoutProgress: Int = 80,
|
||||
)
|
||||
|
||||
/**
|
||||
* Black moves down the board from row 0, White moves up from row 7. Black plays
|
||||
* first, as in every published checkers rulebook.
|
||||
*/
|
||||
const val BLACK_SEAT = 0
|
||||
const val WHITE_SEAT = 1
|
||||
|
||||
fun other(seat: Int): Int = 1 - seat
|
||||
|
||||
fun seatName(seat: Int): String = if (seat == BLACK_SEAT) "Black" else "White"
|
||||
|
||||
/** Which way a man of this seat advances: down the rows for Black, up for White. */
|
||||
fun forwardOf(seat: Int): Int = if (seat == BLACK_SEAT) 1 else -1
|
||||
|
||||
/** The row a man of this seat is crowned on. */
|
||||
fun crownRowOf(seat: Int): Int = if (seat == BLACK_SEAT) 7 else 0
|
||||
|
||||
@Serializable
|
||||
data class Piece(val seat: Int, val king: Boolean = false) {
|
||||
val glyph: String get() = when {
|
||||
seat == BLACK_SEAT && king -> "BK"
|
||||
seat == BLACK_SEAT -> "B"
|
||||
king -> "WK"
|
||||
else -> "W"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A move: where the piece started, and the squares it landed on in order.
|
||||
*
|
||||
* A quiet move has one landing square. A jump has one per hop, so a triple
|
||||
* capture is one move with three of them rather than three moves — a
|
||||
* multiple jump is one turn, and splitting it up would let a player stop
|
||||
* halfway, which the rules do not allow.
|
||||
*/
|
||||
@Serializable
|
||||
data class CheckersMove(val from: Int, val steps: List<Int>) {
|
||||
val to: Int get() = steps.last()
|
||||
|
||||
val isJump: Boolean get() = steps.isNotEmpty() && jumped(from, steps.first()) != null
|
||||
|
||||
override fun toString(): String {
|
||||
val separator = if (isJump) "x" else "-"
|
||||
return (listOf(from) + steps).joinToString(separator) { squareName(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The square hopped over going from [from] to [to], or null when the two are
|
||||
* not two squares apart on a diagonal.
|
||||
*/
|
||||
fun jumped(from: Int, to: Int): Int? {
|
||||
val rowStep = rowOf(to) - rowOf(from)
|
||||
val columnStep = columnOf(to) - columnOf(from)
|
||||
if (kotlin.math.abs(rowStep) != 2 || kotlin.math.abs(columnStep) != 2) return null
|
||||
return squareAt(rowOf(from) + rowStep / 2, columnOf(from) + columnStep / 2)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class CheckersState(
|
||||
val options: CheckersOptions,
|
||||
/** Thirty-two dark squares; null is empty. */
|
||||
val board: List<Piece?>,
|
||||
val turn: Int,
|
||||
val phase: CheckersPhase,
|
||||
val outcome: CheckersOutcome?,
|
||||
val lastMove: CheckersMove?,
|
||||
/** Plies since anything was taken or crowned. */
|
||||
val pliesSinceProgress: Int,
|
||||
val repetitionKeys: List<String>,
|
||||
val moveLog: List<String>,
|
||||
) {
|
||||
fun count(seat: Int): Int = board.count { it?.seat == seat }
|
||||
|
||||
fun kings(seat: Int): Int = board.count { it?.seat == seat && it.king }
|
||||
|
||||
fun positionKey(): String = buildString {
|
||||
for (square in 0 until SQUARES) {
|
||||
append(
|
||||
when (val piece = board[square]) {
|
||||
null -> '.'
|
||||
else -> when {
|
||||
piece.seat == BLACK_SEAT && piece.king -> 'K'
|
||||
piece.seat == BLACK_SEAT -> 'b'
|
||||
piece.king -> 'Q'
|
||||
else -> 'w'
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
append(' ').append(turn)
|
||||
}
|
||||
}
|
||||
|
||||
/** The opening array: twelve a side on the three rows nearest each player. */
|
||||
fun startingBoard(): List<Piece?> = (0 until SQUARES).map { square ->
|
||||
when (rowOf(square)) {
|
||||
0, 1, 2 -> Piece(BLACK_SEAT)
|
||||
5, 6, 7 -> Piece(WHITE_SEAT)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
package org.prolibertate.games.game.checkers
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.engine.GameRules
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
|
||||
/**
|
||||
* Checkers — English draughts — as a pure state machine. See RULES-checkers.md.
|
||||
*
|
||||
* Two rules shape everything else here. **Captures are compulsory**, so the
|
||||
* move generator produces the jumps and throws the quiet moves away whenever
|
||||
* there is a jump to be had; and **a multiple jump is one turn**, so a
|
||||
* sequence of hops is one move object rather than several. A player cannot stop
|
||||
* halfway through a capture, and building it as one move is what makes that
|
||||
* true by construction rather than by a rule somewhere that has to be enforced.
|
||||
*/
|
||||
object CheckersRules : GameRules<CheckersState, CheckersMove> {
|
||||
|
||||
override val gameId: String = GameCatalog.CHECKERS
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private val DIAGONALS = listOf(1 to 1, 1 to -1, -1 to 1, -1 to -1)
|
||||
|
||||
override fun initialState(config: TableConfig): CheckersState {
|
||||
require(config.seats.size == 2) { "Checkers is a two-handed game" }
|
||||
val options = json.decodeFromString<CheckersOptions>(config.optionsJson)
|
||||
return initialState(options)
|
||||
}
|
||||
|
||||
fun initialState(options: CheckersOptions = CheckersOptions()): CheckersState = CheckersState(
|
||||
options = options,
|
||||
board = startingBoard(),
|
||||
turn = BLACK_SEAT,
|
||||
phase = CheckersPhase.PLAYING,
|
||||
outcome = null,
|
||||
lastMove = null,
|
||||
pliesSinceProgress = 0,
|
||||
repetitionKeys = emptyList(),
|
||||
moveLog = emptyList(),
|
||||
).let { start -> start.copy(repetitionKeys = listOf(start.positionKey())) }
|
||||
|
||||
override fun currentSeat(state: CheckersState): Int? =
|
||||
if (state.phase == CheckersPhase.GAME_OVER) null else state.turn
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Moves
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fun legalMoves(state: CheckersState): List<CheckersMove> = legalMoves(state, state.turn)
|
||||
|
||||
override fun legalMoves(state: CheckersState, seat: Int): List<CheckersMove> {
|
||||
if (state.phase == CheckersPhase.GAME_OVER || state.turn != seat) return emptyList()
|
||||
|
||||
val mine = (0 until SQUARES).filter { state.board[it]?.seat == seat }
|
||||
|
||||
val jumps = mine.flatMap { square ->
|
||||
val piece = state.board[square]!!
|
||||
// The piece is lifted before its own jumps are worked out, so the
|
||||
// square it came from counts as empty for the rest of the sequence
|
||||
// — a jump can and often does end back where it started.
|
||||
val board = state.board.toMutableList()
|
||||
board[square] = null
|
||||
jumpsFrom(state.options, board, square, piece).map {
|
||||
CheckersMove(from = square, steps = it)
|
||||
}
|
||||
}
|
||||
|
||||
// Compulsory capture: a quiet move is only a move when nothing at all
|
||||
// can be taken, anywhere on the board.
|
||||
if (jumps.isNotEmpty()) return jumps
|
||||
|
||||
return mine.flatMap { square ->
|
||||
slidesFrom(state.options, state.board, square, state.board[square]!!).map {
|
||||
CheckersMove(from = square, steps = listOf(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Which diagonals a piece may travel along: both ways for a king, forwards for a man. */
|
||||
private fun directionsFor(piece: Piece): List<Pair<Int, Int>> =
|
||||
if (piece.king) DIAGONALS else DIAGONALS.filter { it.first == forwardOf(piece.seat) }
|
||||
|
||||
/** Quiet moves — one square, or any distance for a flying king. */
|
||||
private fun slidesFrom(
|
||||
options: CheckersOptions,
|
||||
board: List<Piece?>,
|
||||
from: Int,
|
||||
piece: Piece,
|
||||
): List<Int> = directionsFor(piece).flatMap { (rowStep, columnStep) ->
|
||||
val reach = if (piece.king && options.flyingKings) 7 else 1
|
||||
val landings = mutableListOf<Int>()
|
||||
for (distance in 1..reach) {
|
||||
val square = squareAt(
|
||||
rowOf(from) + rowStep * distance,
|
||||
columnOf(from) + columnStep * distance,
|
||||
) ?: break
|
||||
if (board[square] != null) break
|
||||
landings += square
|
||||
}
|
||||
landings
|
||||
}
|
||||
|
||||
/**
|
||||
* Every complete jump sequence from [at], as lists of landing squares.
|
||||
*
|
||||
* A sequence only ends where no further jump is available: a player may not
|
||||
* stop a capture halfway, so a shorter prefix of a longer jump is never
|
||||
* offered as a move of its own.
|
||||
*
|
||||
* [board] is mutated as it recurses and put back afterwards, which is what
|
||||
* stops the same piece being taken twice in one turn.
|
||||
*/
|
||||
private fun jumpsFrom(
|
||||
options: CheckersOptions,
|
||||
board: MutableList<Piece?>,
|
||||
at: Int,
|
||||
piece: Piece,
|
||||
): List<List<Int>> {
|
||||
val sequences = mutableListOf<List<Int>>()
|
||||
|
||||
for ((rowStep, columnStep) in directionsFor(piece)) {
|
||||
val hops = if (piece.king && options.flyingKings) {
|
||||
longJumps(board, at, piece, rowStep, columnStep)
|
||||
} else {
|
||||
shortJump(board, at, piece, rowStep, columnStep)
|
||||
}
|
||||
|
||||
for ((over, landing) in hops) {
|
||||
val taken = board[over]
|
||||
board[over] = null
|
||||
|
||||
// A man reaching the crown row stops there under the English
|
||||
// rule and is crowned; under the other it carries on as a man
|
||||
// and is only crowned if it finishes there.
|
||||
val crowning = !piece.king && rowOf(landing) == crownRowOf(piece.seat)
|
||||
val continuations = if (crowning && options.crowningEndsTheTurn) {
|
||||
emptyList()
|
||||
} else {
|
||||
jumpsFrom(options, board, landing, piece)
|
||||
}
|
||||
|
||||
board[over] = taken
|
||||
|
||||
if (continuations.isEmpty()) {
|
||||
sequences += listOf(landing)
|
||||
} else {
|
||||
continuations.forEach { sequences += listOf(landing) + it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sequences
|
||||
}
|
||||
|
||||
/** The one hop a man or a short king can make in a direction, if any. */
|
||||
private fun shortJump(
|
||||
board: List<Piece?>,
|
||||
at: Int,
|
||||
piece: Piece,
|
||||
rowStep: Int,
|
||||
columnStep: Int,
|
||||
): List<Pair<Int, Int>> {
|
||||
val over = squareAt(rowOf(at) + rowStep, columnOf(at) + columnStep) ?: return emptyList()
|
||||
val landing = squareAt(rowOf(at) + rowStep * 2, columnOf(at) + columnStep * 2)
|
||||
?: return emptyList()
|
||||
val victim = board[over] ?: return emptyList()
|
||||
if (victim.seat == piece.seat) return emptyList()
|
||||
if (board[landing] != null) return emptyList()
|
||||
return listOf(over to landing)
|
||||
}
|
||||
|
||||
/**
|
||||
* A flying king's captures: the first piece along the diagonal, if it is an
|
||||
* enemy, taken by landing on any empty square beyond it.
|
||||
*
|
||||
* Note that a captured piece is lifted at once rather than left standing
|
||||
* until the turn ends. International draughts leaves it there as an
|
||||
* obstacle, which occasionally forbids a sequence this allows; the
|
||||
* difference is written down in the rules document rather than pretended
|
||||
* away.
|
||||
*/
|
||||
private fun longJumps(
|
||||
board: List<Piece?>,
|
||||
at: Int,
|
||||
piece: Piece,
|
||||
rowStep: Int,
|
||||
columnStep: Int,
|
||||
): List<Pair<Int, Int>> {
|
||||
var distance = 1
|
||||
val over: Int
|
||||
while (true) {
|
||||
val square = squareAt(
|
||||
rowOf(at) + rowStep * distance,
|
||||
columnOf(at) + columnStep * distance,
|
||||
) ?: return emptyList()
|
||||
val occupant = board[square]
|
||||
if (occupant != null) {
|
||||
if (occupant.seat == piece.seat) return emptyList()
|
||||
over = square
|
||||
break
|
||||
}
|
||||
distance++
|
||||
}
|
||||
|
||||
val landings = mutableListOf<Pair<Int, Int>>()
|
||||
var beyond = distance + 1
|
||||
while (true) {
|
||||
val square = squareAt(
|
||||
rowOf(at) + rowStep * beyond,
|
||||
columnOf(at) + columnStep * beyond,
|
||||
) ?: break
|
||||
if (board[square] != null) break
|
||||
landings += over to square
|
||||
beyond++
|
||||
}
|
||||
return landings
|
||||
}
|
||||
|
||||
override fun applyMove(state: CheckersState, seat: Int, move: CheckersMove): CheckersState {
|
||||
require(state.phase != CheckersPhase.GAME_OVER) { "The game is over" }
|
||||
require(state.turn == seat) { "It is not seat $seat's turn" }
|
||||
require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" }
|
||||
|
||||
val next = make(state, seat, move)
|
||||
return next
|
||||
.copy(
|
||||
repetitionKeys = state.repetitionKeys + next.positionKey(),
|
||||
moveLog = state.moveLog + "${seatName(seat)} $move",
|
||||
)
|
||||
.withTerminalCheck(includeDraws = true)
|
||||
}
|
||||
|
||||
/** The position after [move], with the draw bookkeeping left off, for the search. */
|
||||
fun advanced(state: CheckersState, move: CheckersMove): CheckersState =
|
||||
make(state, state.turn, move).withTerminalCheck(includeDraws = false)
|
||||
|
||||
private fun make(state: CheckersState, seat: Int, move: CheckersMove): CheckersState {
|
||||
val board = state.board.toMutableList()
|
||||
val piece = requireNotNull(board[move.from]) { "no piece on ${squareName(move.from)}" }
|
||||
board[move.from] = null
|
||||
|
||||
var taken = 0
|
||||
var at = move.from
|
||||
for (landing in move.steps) {
|
||||
jumped(at, landing)?.let { over ->
|
||||
if (board[over] != null) {
|
||||
board[over] = null
|
||||
taken++
|
||||
}
|
||||
} ?: run {
|
||||
// A flying king's capture is further than a single hop, so the
|
||||
// square it took is found by walking the line rather than by
|
||||
// halving the distance.
|
||||
if (state.options.flyingKings && piece.king) {
|
||||
victimBetween(board, at, landing)?.let { over ->
|
||||
board[over] = null
|
||||
taken++
|
||||
}
|
||||
}
|
||||
}
|
||||
at = landing
|
||||
}
|
||||
|
||||
val crowned = !piece.king && rowOf(move.to) == crownRowOf(seat)
|
||||
board[move.to] = if (crowned) piece.copy(king = true) else piece
|
||||
|
||||
return state.copy(
|
||||
board = board,
|
||||
turn = other(seat),
|
||||
lastMove = move,
|
||||
// Taking a piece or crowning one is progress; shuffling kings
|
||||
// around is what the draw rule is there to stop.
|
||||
pliesSinceProgress = if (taken > 0 || crowned) 0 else state.pliesSinceProgress + 1,
|
||||
)
|
||||
}
|
||||
|
||||
/** The single enemy piece standing between two squares on a diagonal. */
|
||||
private fun victimBetween(board: List<Piece?>, from: Int, to: Int): Int? {
|
||||
val rowStep = (rowOf(to) - rowOf(from)).let { if (it > 0) 1 else -1 }
|
||||
val columnStep = (columnOf(to) - columnOf(from)).let { if (it > 0) 1 else -1 }
|
||||
var distance = 1
|
||||
while (true) {
|
||||
val square = squareAt(
|
||||
rowOf(from) + rowStep * distance,
|
||||
columnOf(from) + columnStep * distance,
|
||||
) ?: return null
|
||||
if (square == to) return null
|
||||
if (board[square] != null) return square
|
||||
distance++
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the game has just ended, from the point of view of the player who
|
||||
* is now to move: they are the only one who can have lost pieces or run out
|
||||
* of squares on the ply just played.
|
||||
*/
|
||||
private fun CheckersState.withTerminalCheck(includeDraws: Boolean): CheckersState {
|
||||
val loser = turn
|
||||
|
||||
// No pieces, or no move: both are a loss, and they are the same
|
||||
// condition as far as the rules are concerned.
|
||||
if (legalMoves(this, loser).isEmpty()) {
|
||||
return copy(
|
||||
phase = CheckersPhase.GAME_OVER,
|
||||
outcome = if (loser == BLACK_SEAT) {
|
||||
CheckersOutcome.WHITE_WINS
|
||||
} else {
|
||||
CheckersOutcome.BLACK_WINS
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (!includeDraws) return this
|
||||
|
||||
if (options.plyLimitWithoutProgress > 0 &&
|
||||
pliesSinceProgress >= options.plyLimitWithoutProgress
|
||||
) {
|
||||
return copy(
|
||||
phase = CheckersPhase.GAME_OVER,
|
||||
outcome = CheckersOutcome.DRAW_NO_PROGRESS,
|
||||
)
|
||||
}
|
||||
|
||||
if (options.threefoldRepetition) {
|
||||
val here = repetitionKeys.lastOrNull()
|
||||
if (here != null && repetitionKeys.count { it == here } >= 3) {
|
||||
return copy(
|
||||
phase = CheckersPhase.GAME_OVER,
|
||||
outcome = CheckersOutcome.DRAW_REPETITION,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Engine contract
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
override fun isFinished(state: CheckersState): Boolean =
|
||||
state.phase == CheckersPhase.GAME_OVER
|
||||
|
||||
override fun summary(state: CheckersState): String = state.outcome?.label
|
||||
?: "${seatName(state.turn)} to move"
|
||||
|
||||
/** Checkers hides nothing: both players see the same board. */
|
||||
override fun viewFor(state: CheckersState, seat: Int): CheckersState = state
|
||||
|
||||
override fun encodeState(state: CheckersState): String = json.encodeToString(state)
|
||||
override fun decodeState(json: String): CheckersState = this.json.decodeFromString(json)
|
||||
override fun encodeMove(move: CheckersMove): String = json.encodeToString(move)
|
||||
override fun decodeMove(json: String): CheckersMove = this.json.decodeFromString(json)
|
||||
|
||||
/** Leaf nodes at [depth]. The single most useful check on a move generator. */
|
||||
fun perft(state: CheckersState, depth: Int): Long {
|
||||
if (depth == 0) return 1L
|
||||
val moves = legalMoves(state)
|
||||
if (depth == 1) return moves.size.toLong()
|
||||
return moves.sumOf { move ->
|
||||
val next = advanced(state, move)
|
||||
if (next.phase == CheckersPhase.GAME_OVER) 1L else perft(next, depth - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package org.prolibertate.games.game.mastermind
|
||||
|
||||
import kotlin.random.Random
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
* A code setter and a code breaker.
|
||||
*
|
||||
* There is no tree to search here — the opponent makes no reply, and a guess
|
||||
* cannot be answered badly — so the whole game is in choosing which code to try
|
||||
* next. Three ways of choosing it, in rising order of how much it thinks:
|
||||
*
|
||||
* - **Casual** takes anything untried, ruled out or not.
|
||||
* - **Club** never guesses a code the answers have already eliminated. That one
|
||||
* rule is most of the strength.
|
||||
* - **Strong** is Knuth's idea one ply deep: among the codes still standing, it
|
||||
* plays the one whose *worst* possible answer leaves the smallest number of
|
||||
* candidates behind, so it cannot be given a reply that tells it little.
|
||||
*/
|
||||
class MastermindAi(private val level: MastermindLevel? = null) :
|
||||
GameAi<MastermindState, MastermindMove> {
|
||||
|
||||
override fun chooseMove(
|
||||
state: MastermindState,
|
||||
seat: Int,
|
||||
legal: List<MastermindMove>,
|
||||
): MastermindMove {
|
||||
require(legal.isNotEmpty()) { "No legal guess for seat $seat" }
|
||||
val setting = level ?: state.options.level
|
||||
|
||||
// Deterministic for a given position, so a host and a client watching
|
||||
// the same game agree on what the computer did.
|
||||
val random = Random(seat * 7919L + state.guesses[seat].size * 31L + state.options.hashCode())
|
||||
|
||||
// Choosing its own code, which is a different job from breaking one.
|
||||
//
|
||||
// Two deliberate things here. It does not try to be clever — a code
|
||||
// picked to beat a particular solver is a code with a pattern in it,
|
||||
// and a pattern is the one thing a code should not have. And it is the
|
||||
// only choice in this file drawn from a source nobody else can see:
|
||||
// every other random here is seeded from the position so a host and a
|
||||
// client agree, but a code seeded from anything on the wire could be
|
||||
// worked out by the player who is supposed to be guessing it.
|
||||
if (state.phase == MastermindPhase.SETTING) {
|
||||
return MastermindMove(MastermindRules.randomCode(state.options, Random.Default))
|
||||
}
|
||||
|
||||
if (setting == MastermindLevel.CASUAL) return legal[random.nextInt(legal.size)]
|
||||
|
||||
val standing = MastermindRules.consistentCodes(state, seat)
|
||||
.filterNot { code -> state.guesses[seat].any { it.code == code } }
|
||||
// Nothing consistent left means the answers contradict each other,
|
||||
// which cannot happen in a game this code scored — but a guess still
|
||||
// has to be returned rather than an exception.
|
||||
if (standing.isEmpty()) return legal[random.nextInt(legal.size)]
|
||||
if (standing.size == 1) return MastermindMove(standing.single())
|
||||
|
||||
if (setting == MastermindLevel.CLUB) {
|
||||
return MastermindMove(standing[random.nextInt(standing.size)])
|
||||
}
|
||||
|
||||
return MastermindMove(minimax(standing, random))
|
||||
}
|
||||
|
||||
/**
|
||||
* The candidate whose worst reply leaves the fewest codes standing.
|
||||
*
|
||||
* Both lists are sampled when they are large. The full calculation is every
|
||||
* candidate against every survivor, which on a fresh six-colour game is
|
||||
* more than a million scorings — a second or two of a phone's time for a
|
||||
* choice that a sample of them makes just as well.
|
||||
*/
|
||||
private fun minimax(standing: List<List<Int>>, random: Random): List<Int> {
|
||||
val candidates = sample(standing, CANDIDATES, random)
|
||||
val against = sample(standing, OPPONENTS, random)
|
||||
|
||||
var best = candidates.first()
|
||||
var bestWorst = Int.MAX_VALUE
|
||||
for (candidate in candidates) {
|
||||
val partitions = mutableMapOf<Feedback, Int>()
|
||||
for (possible in against) {
|
||||
val answer = scoreGuess(possible, candidate)
|
||||
partitions[answer] = (partitions[answer] ?: 0) + 1
|
||||
}
|
||||
val worst = partitions.values.max()
|
||||
if (worst < bestWorst) {
|
||||
bestWorst = worst
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
private fun sample(codes: List<List<Int>>, limit: Int, random: Random): List<List<Int>> {
|
||||
if (codes.size <= limit) return codes
|
||||
val picked = LinkedHashSet<Int>()
|
||||
while (picked.size < limit) picked += random.nextInt(codes.size)
|
||||
return picked.map { codes[it] }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CANDIDATES = 80
|
||||
const val OPPONENTS = 400
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package org.prolibertate.games.game.mastermind
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Mastermind as a duel.
|
||||
*
|
||||
* The pencil-and-paper game is one person setting a code and another breaking
|
||||
* it, which makes a poor two-handed game: one player would sit and watch. Here
|
||||
* **each player sets the code their opponent will have to break**, and then
|
||||
* both are breaking one, a guess each in turn. Whoever cracks theirs first
|
||||
* wins.
|
||||
*
|
||||
* Setting your own code is the half of Mastermind a generated code takes away.
|
||||
* Choosing it is a real decision — whether to repeat a colour, whether to leave
|
||||
* one out — and it is a decision made against a particular opponent, which a
|
||||
* random number generator cannot do.
|
||||
*
|
||||
* The symmetry is what makes it fair to end the game at the foot of a round
|
||||
* rather than the moment somebody gets it: if the first player cracks the code
|
||||
* on their sixth guess, the second player still gets a sixth guess to equal it,
|
||||
* and equalling it is a draw.
|
||||
*/
|
||||
@Serializable
|
||||
data class MastermindOptions(
|
||||
/** How many colours the code is drawn from. */
|
||||
val colours: Int = 6,
|
||||
/** How many pegs long the code is. */
|
||||
val length: Int = 4,
|
||||
/** Whether a colour may appear more than once in the code. */
|
||||
val allowDuplicates: Boolean = true,
|
||||
/** Guesses each. Running out with neither code broken is a draw. */
|
||||
val maxGuesses: Int = 10,
|
||||
val level: MastermindLevel = MastermindLevel.CLUB,
|
||||
) {
|
||||
/** How many codes are possible, which is what a guess is chosen from. */
|
||||
fun codeSpace(): Int = if (allowDuplicates) {
|
||||
var total = 1
|
||||
repeat(length) { total *= colours }
|
||||
total
|
||||
} else {
|
||||
var total = 1
|
||||
for (step in 0 until length) total *= (colours - step).coerceAtLeast(0)
|
||||
total
|
||||
}
|
||||
}
|
||||
|
||||
/** How hard the computer thinks about it. */
|
||||
@Serializable
|
||||
enum class MastermindLevel(val label: String) {
|
||||
/**
|
||||
* Guesses anything it has not tried, whether or not the answers so far rule
|
||||
* it out. Beatable by anybody who is paying attention.
|
||||
*/
|
||||
CASUAL("Casual"),
|
||||
|
||||
/**
|
||||
* Never guesses a code the answers have already ruled out. That alone is a
|
||||
* respectable player and cracks a four-peg, six-colour code in about five.
|
||||
*/
|
||||
CLUB("Club"),
|
||||
|
||||
/**
|
||||
* Consistent, and among the consistent codes it picks the one whose worst
|
||||
* answer leaves the fewest possibilities standing — a one-ply minimax over
|
||||
* the replies, which is Knuth's idea without the full lookahead.
|
||||
*/
|
||||
STRONG("Strong"),
|
||||
}
|
||||
|
||||
/**
|
||||
* The answer to a guess: how many pegs are the right colour in the right place,
|
||||
* and how many are the right colour somewhere else.
|
||||
*
|
||||
* The two counts never double-count a peg. That is the whole subtlety of
|
||||
* scoring Mastermind, and it is why [scoreGuess] counts colours rather than
|
||||
* walking pairs.
|
||||
*/
|
||||
@Serializable
|
||||
data class Feedback(val exact: Int, val misplaced: Int) {
|
||||
override fun toString(): String = "$exact·$misplaced"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Guess(val code: List<Int>, val feedback: Feedback)
|
||||
|
||||
/**
|
||||
* Scores [guess] against [secret].
|
||||
*
|
||||
* Exact matches are counted first and taken out; what is left is compared by
|
||||
* colour count, so three reds guessed against one red can only ever be worth
|
||||
* one peg however they are arranged.
|
||||
*/
|
||||
fun scoreGuess(secret: List<Int>, guess: List<Int>): Feedback {
|
||||
require(secret.size == guess.size) { "a guess must be as long as the code" }
|
||||
var exact = 0
|
||||
val secretLeft = mutableListOf<Int>()
|
||||
val guessLeft = mutableListOf<Int>()
|
||||
for (index in secret.indices) {
|
||||
if (secret[index] == guess[index]) {
|
||||
exact++
|
||||
} else {
|
||||
secretLeft += secret[index]
|
||||
guessLeft += guess[index]
|
||||
}
|
||||
}
|
||||
val counts = secretLeft.groupingBy { it }.eachCount().toMutableMap()
|
||||
var misplaced = 0
|
||||
for (colour in guessLeft) {
|
||||
val left = counts[colour] ?: 0
|
||||
if (left > 0) {
|
||||
misplaced++
|
||||
counts[colour] = left - 1
|
||||
}
|
||||
}
|
||||
return Feedback(exact = exact, misplaced = misplaced)
|
||||
}
|
||||
|
||||
/** Every code the options allow, in a fixed order. */
|
||||
fun allCodes(options: MastermindOptions): List<List<Int>> {
|
||||
var codes = listOf(emptyList<Int>())
|
||||
repeat(options.length) {
|
||||
codes = codes.flatMap { prefix ->
|
||||
(0 until options.colours)
|
||||
.filter { options.allowDuplicates || it !in prefix }
|
||||
.map { prefix + it }
|
||||
}
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class MastermindPhase {
|
||||
/** Each player is choosing the code their opponent will have to break. */
|
||||
SETTING,
|
||||
|
||||
/** Both codes are set and the guessing has started. */
|
||||
GUESSING,
|
||||
GAME_OVER,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class MastermindOutcome(val label: String) {
|
||||
FIRST_WINS("Broken first by the player who opened"),
|
||||
SECOND_WINS("Broken first by the player who replied"),
|
||||
DRAW_BOTH("A draw — both codes broken in the same number of guesses"),
|
||||
DRAW_NEITHER("A draw — neither code was broken in time"),
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class MastermindState(
|
||||
val options: MastermindOptions,
|
||||
/**
|
||||
* The code each seat is guarding, which is the one their opponent is trying
|
||||
* to break. Redacted before this ever leaves the host: see
|
||||
* [MastermindRules.viewFor].
|
||||
*
|
||||
* Empty means one of two quite different things — not chosen yet, or
|
||||
* chosen and hidden from the device this copy was sent to — which is what
|
||||
* [declared] is for. Without it a screen could not tell "they are still
|
||||
* thinking" from "they are ready and you cannot see it".
|
||||
*/
|
||||
val secrets: List<List<Int>>,
|
||||
/** Whether each seat has settled on a code. */
|
||||
val declared: List<Boolean>,
|
||||
/** What each seat has guessed at their opponent's code, oldest first. */
|
||||
val guesses: List<List<Guess>>,
|
||||
val turn: Int,
|
||||
val phase: MastermindPhase,
|
||||
val outcome: MastermindOutcome?,
|
||||
) {
|
||||
fun broke(seat: Int): Boolean =
|
||||
guesses[seat].any { it.feedback.exact == options.length }
|
||||
|
||||
/** How many guesses it took, or null if this seat has not cracked it. */
|
||||
fun brokeIn(seat: Int): Int? = guesses[seat]
|
||||
.indexOfFirst { it.feedback.exact == options.length }
|
||||
.takeIf { it >= 0 }
|
||||
?.plus(1)
|
||||
|
||||
fun guessesLeft(seat: Int): Int = options.maxGuesses - guesses[seat].size
|
||||
|
||||
/** True where a code has been set but stripped out of this copy. */
|
||||
fun isHidden(seat: Int): Boolean = declared[seat] && secrets[seat].isEmpty()
|
||||
|
||||
/** True once this seat has chosen the code their opponent must break. */
|
||||
fun hasSet(seat: Int): Boolean = declared[seat]
|
||||
}
|
||||
|
||||
const val FIRST_SEAT = 0
|
||||
const val SECOND_SEAT = 1
|
||||
|
||||
fun other(seat: Int): Int = 1 - seat
|
||||
@@ -0,0 +1,234 @@
|
||||
package org.prolibertate.games.game.mastermind
|
||||
|
||||
import kotlin.random.Random
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.engine.GameRules
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
|
||||
/**
|
||||
* A row of pegs.
|
||||
*
|
||||
* The same move does both jobs the game asks for: while the codes are being
|
||||
* set it *is* your code, and after that it is a guess at theirs. Which one it
|
||||
* means is decided by the phase rather than by a flag on the move, because the
|
||||
* phase is the thing that actually knows — a flag could disagree with it, and
|
||||
* then one of the two would be wrong.
|
||||
*/
|
||||
@Serializable
|
||||
data class MastermindMove(val code: List<Int>) {
|
||||
override fun toString(): String = code.joinToString("") { ('A' + it).toString() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Mastermind as a state machine. See RULES-mastermind.md.
|
||||
*
|
||||
* A game runs in two phases. First each player chooses the code their opponent
|
||||
* will have to break, one after the other; then both players guess, a row each
|
||||
* in turn, until somebody cracks one.
|
||||
*
|
||||
* This is the one game here with anything to hide, so [viewFor] does real work:
|
||||
* the host holds both codes and sends each device only its own, which means an
|
||||
* opponent's code is never on the wire in the first place. A code set on a
|
||||
* guest's device travels to the host as a move and stops there — moves are
|
||||
* intents sent to the host and are never relayed to the other player, so the
|
||||
* only copy that reaches an opponent's screen is the redacted one.
|
||||
*
|
||||
* Nothing else in this file may leak a code either: note that [summary] talks
|
||||
* about counts rather than colours.
|
||||
*/
|
||||
object MastermindRules : GameRules<MastermindState, MastermindMove> {
|
||||
|
||||
override val gameId: String = GameCatalog.MASTERMIND
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
override fun initialState(config: TableConfig): MastermindState {
|
||||
require(config.seats.size == 2) { "Mastermind is a two-handed game" }
|
||||
val options = json.decodeFromString<MastermindOptions>(config.optionsJson)
|
||||
return initialState(options)
|
||||
}
|
||||
|
||||
fun initialState(options: MastermindOptions = MastermindOptions()): MastermindState {
|
||||
require(options.length >= 1) { "a code needs at least one peg" }
|
||||
require(options.colours >= 2) { "a code needs at least two colours" }
|
||||
require(options.allowDuplicates || options.colours >= options.length) {
|
||||
"${options.length} different colours cannot be drawn from ${options.colours}"
|
||||
}
|
||||
return MastermindState(
|
||||
options = options,
|
||||
// Nobody has chosen anything yet. The codes are the players' to
|
||||
// set, which is half of what makes it a game rather than a puzzle.
|
||||
secrets = listOf(emptyList(), emptyList()),
|
||||
declared = listOf(false, false),
|
||||
guesses = listOf(emptyList(), emptyList()),
|
||||
turn = FIRST_SEAT,
|
||||
phase = MastermindPhase.SETTING,
|
||||
outcome = null,
|
||||
)
|
||||
}
|
||||
|
||||
/** A code drawn at random, which is how the computer sets its own. */
|
||||
fun randomCode(options: MastermindOptions, random: Random): List<Int> {
|
||||
if (options.allowDuplicates) {
|
||||
return List(options.length) { random.nextInt(options.colours) }
|
||||
}
|
||||
val bag = (0 until options.colours).toMutableList()
|
||||
return List(options.length) { bag.removeAt(random.nextInt(bag.size)) }
|
||||
}
|
||||
|
||||
/** Whether a row of pegs is a code this table allows at all. */
|
||||
fun isWellFormed(options: MastermindOptions, code: List<Int>): Boolean =
|
||||
code.size == options.length &&
|
||||
code.all { it in 0 until options.colours } &&
|
||||
(options.allowDuplicates || code.distinct().size == code.size)
|
||||
|
||||
override fun currentSeat(state: MastermindState): Int? =
|
||||
if (state.phase == MastermindPhase.GAME_OVER) null else state.turn
|
||||
|
||||
/**
|
||||
* While the codes are being set, every code this table allows. After that,
|
||||
* every code this seat has not already guessed.
|
||||
*
|
||||
* Repeating a guess is not illegal by the rules of the game, but it can
|
||||
* only ever waste a turn, and leaving it out keeps the computer from
|
||||
* wasting one.
|
||||
*/
|
||||
override fun legalMoves(state: MastermindState, seat: Int): List<MastermindMove> {
|
||||
if (state.phase == MastermindPhase.GAME_OVER || state.turn != seat) return emptyList()
|
||||
if (state.phase == MastermindPhase.SETTING) {
|
||||
return allCodes(state.options).map { MastermindMove(it) }
|
||||
}
|
||||
val tried = state.guesses[seat].map { it.code }.toSet()
|
||||
return allCodes(state.options).filterNot { it in tried }.map { MastermindMove(it) }
|
||||
}
|
||||
|
||||
override fun applyMove(
|
||||
state: MastermindState,
|
||||
seat: Int,
|
||||
move: MastermindMove,
|
||||
): MastermindState {
|
||||
require(state.phase != MastermindPhase.GAME_OVER) { "The game is over" }
|
||||
require(state.turn == seat) { "It is not seat $seat's turn" }
|
||||
require(isWellFormed(state.options, move.code)) {
|
||||
"a code is ${state.options.length} pegs from ${state.options.colours} colours" +
|
||||
if (state.options.allowDuplicates) "" else ", all different"
|
||||
}
|
||||
|
||||
if (state.phase == MastermindPhase.SETTING) return declare(state, seat, move.code)
|
||||
|
||||
require(state.guesses[seat].none { it.code == move.code }) {
|
||||
"that guess has been made already"
|
||||
}
|
||||
val secret = state.secrets[other(seat)]
|
||||
require(secret.isNotEmpty()) {
|
||||
"this device does not hold that code — a guess is scored by the host"
|
||||
}
|
||||
|
||||
val scored = Guess(code = move.code, feedback = scoreGuess(secret, move.code))
|
||||
val guesses = state.guesses.toMutableList()
|
||||
guesses[seat] = guesses[seat] + scored
|
||||
|
||||
return state.copy(guesses = guesses, turn = other(seat)).withTerminalCheck()
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes down the code this seat will be guarding.
|
||||
*
|
||||
* The two players choose one after the other rather than at the same time,
|
||||
* because the engine only ever has one seat on the clock. It costs nothing:
|
||||
* neither can see the other's, so going second is not an advantage — and
|
||||
* once both are down, the guessing starts with the player who set first.
|
||||
*/
|
||||
private fun declare(
|
||||
state: MastermindState,
|
||||
seat: Int,
|
||||
code: List<Int>,
|
||||
): MastermindState {
|
||||
require(!state.declared[seat]) { "seat $seat has already set a code" }
|
||||
|
||||
val secrets = state.secrets.toMutableList()
|
||||
secrets[seat] = code
|
||||
val declared = state.declared.toMutableList()
|
||||
declared[seat] = true
|
||||
|
||||
val ready = declared.all { it }
|
||||
return state.copy(
|
||||
secrets = secrets,
|
||||
declared = declared,
|
||||
phase = if (ready) MastermindPhase.GUESSING else MastermindPhase.SETTING,
|
||||
turn = if (ready) FIRST_SEAT else other(seat),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The game ends at the foot of a round, never in the middle of one.
|
||||
*
|
||||
* Both players have had the same number of guesses only when the turn has
|
||||
* come back round to the opener, so that is the only place it is fair to
|
||||
* compare them — otherwise opening would be worth half a guess.
|
||||
*/
|
||||
private fun MastermindState.withTerminalCheck(): MastermindState {
|
||||
if (turn != FIRST_SEAT) return this
|
||||
|
||||
val first = broke(FIRST_SEAT)
|
||||
val second = broke(SECOND_SEAT)
|
||||
val outcome = when {
|
||||
first && second -> MastermindOutcome.DRAW_BOTH
|
||||
first -> MastermindOutcome.FIRST_WINS
|
||||
second -> MastermindOutcome.SECOND_WINS
|
||||
guessesLeft(FIRST_SEAT) <= 0 && guessesLeft(SECOND_SEAT) <= 0 ->
|
||||
MastermindOutcome.DRAW_NEITHER
|
||||
|
||||
else -> return this
|
||||
}
|
||||
return copy(phase = MastermindPhase.GAME_OVER, outcome = outcome)
|
||||
}
|
||||
|
||||
override fun isFinished(state: MastermindState): Boolean =
|
||||
state.phase == MastermindPhase.GAME_OVER
|
||||
|
||||
override fun summary(state: MastermindState): String = state.outcome?.label ?: when {
|
||||
state.phase == MastermindPhase.SETTING ->
|
||||
"Setting the codes — ${state.declared.count { it }} of 2 chosen"
|
||||
|
||||
else -> "Guess ${state.guesses[state.turn].size + 1} of ${state.options.maxGuesses}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips out the code this seat is not entitled to see.
|
||||
*
|
||||
* A seat keeps its own code — they chose it, and they are guarding it — and
|
||||
* loses the one they are trying to break. Whether the opponent has chosen
|
||||
* yet is *not* stripped: that is not a secret, and a screen has to be able
|
||||
* to say "waiting for them" rather than sitting blank.
|
||||
*
|
||||
* Once the game is finished both codes are shown: there is nothing left to
|
||||
* protect, and a code breaker wants to see what they were up against.
|
||||
*/
|
||||
override fun viewFor(state: MastermindState, seat: Int): MastermindState {
|
||||
if (state.phase == MastermindPhase.GAME_OVER) return state
|
||||
val secrets = state.secrets.mapIndexed { index, code ->
|
||||
if (index == seat) code else emptyList()
|
||||
}
|
||||
return state.copy(secrets = secrets)
|
||||
}
|
||||
|
||||
override fun encodeState(state: MastermindState): String = json.encodeToString(state)
|
||||
override fun decodeState(json: String): MastermindState = this.json.decodeFromString(json)
|
||||
override fun encodeMove(move: MastermindMove): String = json.encodeToString(move)
|
||||
override fun decodeMove(json: String): MastermindMove = this.json.decodeFromString(json)
|
||||
|
||||
/** Codes still consistent with everything [seat] has been told so far. */
|
||||
fun consistentCodes(state: MastermindState, seat: Int): List<List<Int>> {
|
||||
val answers = state.guesses[seat]
|
||||
return allCodes(state.options).filter { candidate ->
|
||||
answers.all { scoreGuess(candidate, it.code) == it.feedback }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package org.prolibertate.games.game.pirates
|
||||
|
||||
import kotlin.random.Random
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
* A negamax search with alpha-beta pruning and iterative deepening, over the
|
||||
* same rules engine the game runs on.
|
||||
*
|
||||
* The interesting part of a hunt game is that the two sides want opposite
|
||||
* shapes out of the same board, and one evaluation has to speak for both. The
|
||||
* pirates want Bulgars gone and room to move; the Bulgars want to be many, to
|
||||
* be forward, and above all not to be jumpable. The terms below are written
|
||||
* from the Bulgars' side and negated for the pirates, so the two cannot drift
|
||||
* apart and start valuing the same position differently.
|
||||
*/
|
||||
class PiratesAi(private val level: PiratesLevel? = null) : GameAi<PiratesState, PiratesMove> {
|
||||
|
||||
private var nodeBudget = 0
|
||||
private var nodesUsed = 0
|
||||
|
||||
override fun chooseMove(state: PiratesState, seat: Int, legal: List<PiratesMove>): PiratesMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
val setting = level ?: state.options.level
|
||||
if (legal.size == 1) return legal.first()
|
||||
|
||||
nodeBudget = NODE_BUDGET
|
||||
nodesUsed = 0
|
||||
|
||||
val random = Random(state.moveLog.size * 31L + state.positionKey().hashCode())
|
||||
|
||||
var best = legal.first()
|
||||
var scored = legal.map { it to 0 }
|
||||
|
||||
for (depth in 1..setting.depth) {
|
||||
val results = mutableListOf<Pair<PiratesMove, Int>>()
|
||||
val ordered = order(legal, preferred = best)
|
||||
for (move in ordered) {
|
||||
val score = -negamax(
|
||||
state = PiratesRules.advanced(state, move),
|
||||
depth = depth - 1,
|
||||
alpha = -INFINITY,
|
||||
beta = INFINITY,
|
||||
ply = 1,
|
||||
)
|
||||
results += move to score
|
||||
if (nodesUsed >= nodeBudget) break
|
||||
}
|
||||
if (nodesUsed >= nodeBudget && results.size < ordered.size) break
|
||||
scored = results
|
||||
best = results.maxBy { it.second }.first
|
||||
}
|
||||
|
||||
val bestScore = scored.maxOf { it.second }
|
||||
val acceptable = scored.filter { it.second >= bestScore - setting.slack() }
|
||||
return acceptable[random.nextInt(acceptable.size)].first
|
||||
}
|
||||
|
||||
private fun PiratesLevel.slack(): Int = when (this) {
|
||||
PiratesLevel.CASUAL -> 60
|
||||
PiratesLevel.CLUB -> 15
|
||||
PiratesLevel.STRONG -> 0
|
||||
}
|
||||
|
||||
private fun negamax(state: PiratesState, depth: Int, alpha: Int, beta: Int, ply: Int): Int {
|
||||
nodesUsed++
|
||||
|
||||
if (state.phase == PiratesPhase.GAME_OVER) {
|
||||
return when (state.outcome) {
|
||||
PiratesOutcome.DRAW_STALEMATE, null -> 0
|
||||
// A finished game leaves the loser on the clock in two of the
|
||||
// three endings; the third — the stronghold filled — is a win
|
||||
// for the side that has just moved, so it is read off the
|
||||
// outcome rather than assumed.
|
||||
PiratesOutcome.BULGARS_STORM ->
|
||||
if (state.turn == BULGAR_SEAT) WIN - ply else -(WIN - ply)
|
||||
|
||||
else -> -(WIN - ply)
|
||||
}
|
||||
}
|
||||
if (depth <= 0) return evaluate(state)
|
||||
|
||||
val moves = PiratesRules.legalMoves(state)
|
||||
if (moves.isEmpty()) return -(WIN - ply)
|
||||
|
||||
var localAlpha = alpha
|
||||
for (move in order(moves, preferred = null)) {
|
||||
val score = -negamax(
|
||||
state = PiratesRules.advanced(state, move),
|
||||
depth = depth - 1,
|
||||
alpha = -beta,
|
||||
beta = -localAlpha,
|
||||
ply = ply + 1,
|
||||
)
|
||||
if (score >= beta) return beta
|
||||
if (score > localAlpha) localAlpha = score
|
||||
if (nodesUsed >= nodeBudget) break
|
||||
}
|
||||
return localAlpha
|
||||
}
|
||||
|
||||
/** Captures first, then moves into the stronghold: the two things that change anything. */
|
||||
private fun order(moves: List<PiratesMove>, preferred: PiratesMove?): List<PiratesMove> =
|
||||
moves.sortedByDescending { move ->
|
||||
when {
|
||||
move == preferred -> 1_000
|
||||
move.steps.size > 1 -> 200
|
||||
isJumpFrom(move.from, move.steps.first()) -> 100
|
||||
isStronghold(move.to) -> 20
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
/** The position from the point of view of the side to move. */
|
||||
private fun evaluate(state: PiratesState): Int {
|
||||
var bulgarView = 0
|
||||
|
||||
bulgarView += state.count(BULGAR_SEAT) * BULGAR
|
||||
bulgarView += state.stronghold() * IN_STRONGHOLD
|
||||
|
||||
// How far up the board the crowd has come. The stronghold is at the
|
||||
// top, so a low row number is ground gained.
|
||||
for (point in 0 until POINTS) {
|
||||
if (state.board[point] != BULGAR_SEAT) continue
|
||||
bulgarView += (GRID - 1 - rowOf(point)) * ADVANCE
|
||||
// A Bulgar with an empty point directly behind it on a line is one
|
||||
// a pirate can jump. Being unjumpable is most of staying alive.
|
||||
if (isExposed(state, point)) bulgarView -= EXPOSED
|
||||
}
|
||||
|
||||
// Room for the pirates to work in, which is what the Bulgars are
|
||||
// slowly taking away.
|
||||
bulgarView -= pirateMobility(state) * PIRATE_ROOM
|
||||
|
||||
return if (state.turn == BULGAR_SEAT) bulgarView else -bulgarView
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a pirate could jump this Bulgar: a pirate on one side of it along
|
||||
* a line, and an empty point on the other.
|
||||
*/
|
||||
private fun isExposed(state: PiratesState, point: Int): Boolean {
|
||||
for (neighbour in ADJACENCY[point]) {
|
||||
if (state.board[neighbour] != PIRATE_SEAT) continue
|
||||
val landing = pointAt(
|
||||
rowOf(point) + (rowOf(point) - rowOf(neighbour)),
|
||||
columnOf(point) + (columnOf(point) - columnOf(neighbour)),
|
||||
) ?: continue
|
||||
if (landing in ADJACENCY[point] && state.board[landing] == null) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun pirateMobility(state: PiratesState): Int =
|
||||
(0 until POINTS)
|
||||
.filter { state.board[it] == PIRATE_SEAT }
|
||||
.sumOf { point -> ADJACENCY[point].count { state.board[it] == null } }
|
||||
|
||||
private companion object {
|
||||
const val INFINITY = 1_000_000
|
||||
const val WIN = 100_000
|
||||
const val NODE_BUDGET = 150_000
|
||||
|
||||
const val BULGAR = 100
|
||||
const val IN_STRONGHOLD = 55
|
||||
const val ADVANCE = 4
|
||||
const val EXPOSED = 22
|
||||
const val PIRATE_ROOM = 3
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package org.prolibertate.games.game.pirates
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Dalmatian Pirates and the Volga Bulgars — Sid Sackson's name for the hunt
|
||||
* game usually called **Asalto**, and before that a cousin of Fox and Geese.
|
||||
*
|
||||
* Two pirates hold a stronghold at the head of a cross-shaped board. Twenty-four
|
||||
* Bulgars come at it from the other three arms, and cannot take anything: their
|
||||
* whole method is weight of numbers. The pirates cannot be driven off by force
|
||||
* either — they are taken only by being penned in — so each side is trying to
|
||||
* do something the other cannot do back.
|
||||
*
|
||||
* See RULES-pirates.md, which is honest about which parts of this are attested
|
||||
* and which had to be chosen: the book the theme comes from could not be
|
||||
* obtained, so the mechanics here are Asalto's.
|
||||
*
|
||||
* The board is a seven-by-seven grid with the four corners cut away, leaving
|
||||
* thirty-three points. Points are numbered in reading order.
|
||||
*/
|
||||
const val GRID = 7
|
||||
|
||||
/** True where the cross-shaped board has a point at all. */
|
||||
fun isOnBoard(row: Int, column: Int): Boolean =
|
||||
row in 0 until GRID && column in 0 until GRID && (row in 2..4 || column in 2..4)
|
||||
|
||||
/** The thirty-three points, in reading order. */
|
||||
val CELLS: List<Pair<Int, Int>> = (0 until GRID).flatMap { row ->
|
||||
(0 until GRID).mapNotNull { column -> (row to column).takeIf { isOnBoard(row, column) } }
|
||||
}
|
||||
|
||||
val POINTS = CELLS.size
|
||||
|
||||
fun rowOf(point: Int): Int = CELLS[point].first
|
||||
|
||||
fun columnOf(point: Int): Int = CELLS[point].second
|
||||
|
||||
fun pointAt(row: Int, column: Int): Int? =
|
||||
if (isOnBoard(row, column)) CELLS.indexOf(row to column).takeIf { it >= 0 } else null
|
||||
|
||||
/**
|
||||
* The stronghold: the nine points of the top arm.
|
||||
*
|
||||
* Twenty-four points are left outside it, which is exactly the number of
|
||||
* Bulgars — the board and the pieces are cut to each other, and the game opens
|
||||
* with every point occupied but the seven the pirates are not standing on.
|
||||
*/
|
||||
val STRONGHOLD: List<Int> = (0 until POINTS).filter { rowOf(it) <= 2 && columnOf(it) in 2..4 }
|
||||
|
||||
fun isStronghold(point: Int): Boolean = point in STRONGHOLD
|
||||
|
||||
/**
|
||||
* The lines drawn on the board.
|
||||
*
|
||||
* Orthogonal neighbours are always joined. Diagonals are drawn only where the
|
||||
* row and column add to an even number, which is the pattern printed on every
|
||||
* board of this family — it is why the centre of the board has eight lines out
|
||||
* of it and the point beside it has four.
|
||||
*/
|
||||
val ADJACENCY: List<List<Int>> = (0 until POINTS).map { point ->
|
||||
val row = rowOf(point)
|
||||
val column = columnOf(point)
|
||||
val steps = if ((row + column) % 2 == 0) {
|
||||
listOf(0 to 1, 0 to -1, 1 to 0, -1 to 0, 1 to 1, 1 to -1, -1 to 1, -1 to -1)
|
||||
} else {
|
||||
listOf(0 to 1, 0 to -1, 1 to 0, -1 to 0)
|
||||
}
|
||||
steps.mapNotNull { (rowStep, columnStep) -> pointAt(row + rowStep, column + columnStep) }
|
||||
}
|
||||
|
||||
/** Seat 0 is the crowd, seat 1 the pair. */
|
||||
const val BULGAR_SEAT = 0
|
||||
const val PIRATE_SEAT = 1
|
||||
|
||||
fun other(seat: Int): Int = 1 - seat
|
||||
|
||||
fun seatName(seat: Int): String = if (seat == BULGAR_SEAT) "the Bulgars" else "the pirates"
|
||||
|
||||
/** The point's name: files a–g left to right, ranks 7–1 top to bottom. */
|
||||
fun pointName(point: Int): String = "${'a' + columnOf(point)}${GRID - rowOf(point)}"
|
||||
|
||||
fun pointFromName(name: String): Int? =
|
||||
pointAt(GRID - (name[1] - '0'), name[0] - 'a')
|
||||
|
||||
@Serializable
|
||||
enum class PiratesPhase { PLAYING, GAME_OVER }
|
||||
|
||||
@Serializable
|
||||
enum class PiratesOutcome(val label: String) {
|
||||
BULGARS_STORM("The Bulgars win — the stronghold is full of them"),
|
||||
BULGARS_PEN_IN("The Bulgars win — the pirates have nowhere left to go"),
|
||||
PIRATES_CUT_DOWN("The pirates win — too few Bulgars are left to fill the stronghold"),
|
||||
DRAW_STALEMATE("A draw — neither side has got anywhere in a long time"),
|
||||
}
|
||||
|
||||
/** How hard the computer thinks about it. */
|
||||
@Serializable
|
||||
enum class PiratesLevel(val label: String, val depth: Int) {
|
||||
CASUAL("Casual", depth = 2),
|
||||
CLUB("Club", depth = 4),
|
||||
STRONG("Strong", depth = 6),
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class PiratesOptions(
|
||||
val level: PiratesLevel = PiratesLevel.CLUB,
|
||||
/**
|
||||
* A pirate who can take must take. The traditional way of enforcing this is
|
||||
* *huffing* — the other player removes a pirate that failed to capture —
|
||||
* which is the same rule with an extra step and a way to forget to use it.
|
||||
*/
|
||||
val captureIsCompulsory: Boolean = true,
|
||||
/**
|
||||
* Bulgars may not retreat: they move towards the stronghold or across, and
|
||||
* never back down the board. Turning this off makes them far harder to
|
||||
* pin, and much less like the game.
|
||||
*/
|
||||
val bulgarsMayNotRetreat: Boolean = true,
|
||||
/** Plies with nothing taken and no ground gained before it is called a draw. */
|
||||
val plyLimitWithoutProgress: Int = 60,
|
||||
)
|
||||
|
||||
/**
|
||||
* A move: where a piece started and where it landed, one entry per hop.
|
||||
*
|
||||
* A Bulgar's move is always a single step. A pirate's may be a chain of jumps,
|
||||
* which is one turn and one move object — a capture cannot be abandoned
|
||||
* halfway.
|
||||
*/
|
||||
@Serializable
|
||||
data class PiratesMove(val from: Int, val steps: List<Int>) {
|
||||
val to: Int get() = steps.last()
|
||||
|
||||
override fun toString(): String {
|
||||
val separator = if (steps.size > 1 || isJumpFrom(from, steps.first())) "x" else "-"
|
||||
return (listOf(from) + steps).joinToString(separator) { pointName(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether two points are a jump apart: two steps along one line. */
|
||||
fun isJumpFrom(from: Int, to: Int): Boolean = jumpedBetween(from, to) != null
|
||||
|
||||
/** The point hopped over, or null when the two are not two steps apart on a line. */
|
||||
fun jumpedBetween(from: Int, to: Int): Int? {
|
||||
val rowStep = rowOf(to) - rowOf(from)
|
||||
val columnStep = columnOf(to) - columnOf(from)
|
||||
if (kotlin.math.abs(rowStep) > 2 || kotlin.math.abs(columnStep) > 2) return null
|
||||
if (kotlin.math.abs(rowStep) != 2 && kotlin.math.abs(columnStep) != 2) return null
|
||||
// A jump must be along a line, so the two halves have to be steps of one.
|
||||
if (rowStep % 2 != 0 || columnStep % 2 != 0) return null
|
||||
val middle = pointAt(rowOf(from) + rowStep / 2, columnOf(from) + columnStep / 2) ?: return null
|
||||
return middle.takeIf { middle in ADJACENCY[from] && to in ADJACENCY[middle] }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class PiratesState(
|
||||
val options: PiratesOptions,
|
||||
/** Thirty-three points; null is empty, otherwise the seat standing there. */
|
||||
val board: List<Int?>,
|
||||
val turn: Int,
|
||||
val phase: PiratesPhase,
|
||||
val outcome: PiratesOutcome?,
|
||||
val lastMove: PiratesMove?,
|
||||
/** Plies since a pirate took something or a Bulgar entered the stronghold. */
|
||||
val pliesSinceProgress: Int,
|
||||
val moveLog: List<String>,
|
||||
) {
|
||||
fun count(seat: Int): Int = board.count { it == seat }
|
||||
|
||||
/** How many of the stronghold's nine points the Bulgars are standing on. */
|
||||
fun stronghold(): Int = STRONGHOLD.count { board[it] == BULGAR_SEAT }
|
||||
|
||||
fun positionKey(): String = buildString {
|
||||
for (point in 0 until POINTS) {
|
||||
append(
|
||||
when (board[point]) {
|
||||
BULGAR_SEAT -> 'b'
|
||||
PIRATE_SEAT -> 'P'
|
||||
else -> '.'
|
||||
}
|
||||
)
|
||||
}
|
||||
append(' ').append(turn)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The opening array: Bulgars on every point outside the stronghold, and the two
|
||||
* pirates on the stronghold's back corners — the deepest points they have, and
|
||||
* the ones a Bulgar has to come furthest to reach.
|
||||
*/
|
||||
fun startingBoard(): List<Int?> {
|
||||
val board = arrayOfNulls<Int>(POINTS)
|
||||
(0 until POINTS).forEach { point ->
|
||||
if (!isStronghold(point)) board[point] = BULGAR_SEAT
|
||||
}
|
||||
val corners = STRONGHOLD.filter { rowOf(it) == 0 && columnOf(it) != 3 }
|
||||
corners.forEach { board[it] = PIRATE_SEAT }
|
||||
return board.toList()
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package org.prolibertate.games.game.pirates
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.engine.GameRules
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
|
||||
/**
|
||||
* Pirates and Bulgars — Asalto — as a pure state machine. See RULES-pirates.md.
|
||||
*
|
||||
* The two sides do not play the same game, and almost every line here is one
|
||||
* side or the other rather than both. Pirates go anywhere along a line and take
|
||||
* by jumping, in chains, compulsorily. Bulgars go one step and never backwards,
|
||||
* and cannot take anything at all: they win by arriving.
|
||||
*
|
||||
* That asymmetry is the reason the win conditions are counted rather than
|
||||
* shared. Bulgars win by filling the stronghold or by leaving the pirates
|
||||
* nowhere to go; pirates win by cutting the Bulgars below the nine it takes to
|
||||
* fill it — at which point the attack cannot succeed however long it goes on.
|
||||
*/
|
||||
object PiratesRules : GameRules<PiratesState, PiratesMove> {
|
||||
|
||||
override val gameId: String = GameCatalog.PIRATES
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
override fun initialState(config: TableConfig): PiratesState {
|
||||
require(config.seats.size == 2) { "Pirates and Bulgars is a two-handed game" }
|
||||
val options = json.decodeFromString<PiratesOptions>(config.optionsJson)
|
||||
return initialState(options)
|
||||
}
|
||||
|
||||
fun initialState(options: PiratesOptions = PiratesOptions()): PiratesState = PiratesState(
|
||||
options = options,
|
||||
board = startingBoard(),
|
||||
// The attackers move first: the pirates are already where they want to be.
|
||||
turn = BULGAR_SEAT,
|
||||
phase = PiratesPhase.PLAYING,
|
||||
outcome = null,
|
||||
lastMove = null,
|
||||
pliesSinceProgress = 0,
|
||||
moveLog = emptyList(),
|
||||
)
|
||||
|
||||
override fun currentSeat(state: PiratesState): Int? =
|
||||
if (state.phase == PiratesPhase.GAME_OVER) null else state.turn
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Moves
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fun legalMoves(state: PiratesState): List<PiratesMove> = legalMoves(state, state.turn)
|
||||
|
||||
override fun legalMoves(state: PiratesState, seat: Int): List<PiratesMove> {
|
||||
if (state.phase == PiratesPhase.GAME_OVER || state.turn != seat) return emptyList()
|
||||
val mine = (0 until POINTS).filter { state.board[it] == seat }
|
||||
|
||||
if (seat == PIRATE_SEAT) {
|
||||
val jumps = mine.flatMap { from ->
|
||||
val board = state.board.toMutableList()
|
||||
board[from] = null
|
||||
jumpsFrom(board, from).map { PiratesMove(from = from, steps = it) }
|
||||
}
|
||||
// A pirate who can take must take — the rule huffing exists to
|
||||
// enforce, applied directly.
|
||||
if (jumps.isNotEmpty() && state.options.captureIsCompulsory) return jumps
|
||||
|
||||
val steps = mine.flatMap { from ->
|
||||
ADJACENCY[from].filter { state.board[it] == null }
|
||||
.map { PiratesMove(from = from, steps = listOf(it)) }
|
||||
}
|
||||
return jumps + steps
|
||||
}
|
||||
|
||||
return mine.flatMap { from ->
|
||||
ADJACENCY[from]
|
||||
.filter { state.board[it] == null && bulgarMayGo(state, from, it) }
|
||||
.map { PiratesMove(from = from, steps = listOf(it)) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a Bulgar may make this step.
|
||||
*
|
||||
* They press towards the stronghold and never retreat, which is what makes
|
||||
* the attack a commitment: a Bulgar that walks past a gap cannot come back
|
||||
* for it. The stronghold is at the top of the board, so "forwards" is up
|
||||
* the rows and "across" is along one.
|
||||
*/
|
||||
private fun bulgarMayGo(state: PiratesState, from: Int, to: Int): Boolean {
|
||||
if (!state.options.bulgarsMayNotRetreat) return true
|
||||
return rowOf(to) <= rowOf(from)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every complete chain of jumps from [at], as lists of landing points.
|
||||
*
|
||||
* [board] arrives with the jumping pirate already lifted, and is mutated as
|
||||
* the search recurses so a Bulgar cannot be jumped twice in one turn. A
|
||||
* chain only ends where no further jump exists, so a shorter prefix of a
|
||||
* longer capture is never offered on its own.
|
||||
*/
|
||||
private fun jumpsFrom(board: MutableList<Int?>, at: Int): List<List<Int>> {
|
||||
val sequences = mutableListOf<List<Int>>()
|
||||
|
||||
for (over in ADJACENCY[at]) {
|
||||
if (board[over] != BULGAR_SEAT) continue
|
||||
val landing = beyond(at, over) ?: continue
|
||||
if (board[landing] != null) continue
|
||||
|
||||
board[over] = null
|
||||
val continuations = jumpsFrom(board, landing)
|
||||
board[over] = BULGAR_SEAT
|
||||
|
||||
if (continuations.isEmpty()) {
|
||||
sequences += listOf(landing)
|
||||
} else {
|
||||
continuations.forEach { sequences += listOf(landing) + it }
|
||||
}
|
||||
}
|
||||
|
||||
return sequences
|
||||
}
|
||||
|
||||
/** The point directly past [over] as seen from [from], if the line runs on. */
|
||||
private fun beyond(from: Int, over: Int): Int? {
|
||||
val landing = pointAt(
|
||||
rowOf(over) + (rowOf(over) - rowOf(from)),
|
||||
columnOf(over) + (columnOf(over) - columnOf(from)),
|
||||
) ?: return null
|
||||
return landing.takeIf { it in ADJACENCY[over] }
|
||||
}
|
||||
|
||||
override fun applyMove(state: PiratesState, seat: Int, move: PiratesMove): PiratesState {
|
||||
require(state.phase != PiratesPhase.GAME_OVER) { "The game is over" }
|
||||
require(state.turn == seat) { "It is not seat $seat's turn" }
|
||||
require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" }
|
||||
|
||||
return make(state, seat, move)
|
||||
.copy(moveLog = state.moveLog + "${seatName(seat)}: $move")
|
||||
.withTerminalCheck(includeDraws = true)
|
||||
}
|
||||
|
||||
/** The position after [move] with the draw clock left off, for the search. */
|
||||
fun advanced(state: PiratesState, move: PiratesMove): PiratesState =
|
||||
make(state, state.turn, move).withTerminalCheck(includeDraws = false)
|
||||
|
||||
private fun make(state: PiratesState, seat: Int, move: PiratesMove): PiratesState {
|
||||
val board = state.board.toMutableList()
|
||||
board[move.from] = null
|
||||
|
||||
var taken = 0
|
||||
var at = move.from
|
||||
for (landing in move.steps) {
|
||||
jumpedBetween(at, landing)?.let { over ->
|
||||
if (board[over] != null) {
|
||||
board[over] = null
|
||||
taken++
|
||||
}
|
||||
}
|
||||
at = landing
|
||||
}
|
||||
board[move.to] = seat
|
||||
|
||||
// Progress is a pirate taking somebody, or a Bulgar getting into the
|
||||
// stronghold. Everything else is the two sides circling each other.
|
||||
val before = state.stronghold()
|
||||
val next = state.copy(board = board, turn = other(seat), lastMove = move)
|
||||
val gained = next.stronghold() > before
|
||||
|
||||
return next.copy(
|
||||
pliesSinceProgress = if (taken > 0 || gained) 0 else state.pliesSinceProgress + 1,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the game has ended.
|
||||
*
|
||||
* Order matters. Filling the stronghold is checked first because it is an
|
||||
* arrival rather than an absence — a board where the Bulgars have just
|
||||
* walked into the last square is a Bulgar win even if the pirates have also
|
||||
* run out of moves on it.
|
||||
*/
|
||||
private fun PiratesState.withTerminalCheck(includeDraws: Boolean): PiratesState {
|
||||
if (stronghold() == STRONGHOLD.size) {
|
||||
return copy(phase = PiratesPhase.GAME_OVER, outcome = PiratesOutcome.BULGARS_STORM)
|
||||
}
|
||||
|
||||
// Too few Bulgars left to fill it, so the assault can never succeed
|
||||
// however long the game runs.
|
||||
if (count(BULGAR_SEAT) < STRONGHOLD.size) {
|
||||
return copy(phase = PiratesPhase.GAME_OVER, outcome = PiratesOutcome.PIRATES_CUT_DOWN)
|
||||
}
|
||||
|
||||
// Whoever is on the clock with nothing to play has lost — which for the
|
||||
// pirates is being penned in, and for the Bulgars means they have run
|
||||
// themselves out of forward moves.
|
||||
if (legalMoves(this, turn).isEmpty()) {
|
||||
return copy(
|
||||
phase = PiratesPhase.GAME_OVER,
|
||||
outcome = if (turn == PIRATE_SEAT) {
|
||||
PiratesOutcome.BULGARS_PEN_IN
|
||||
} else {
|
||||
PiratesOutcome.PIRATES_CUT_DOWN
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (!includeDraws) return this
|
||||
|
||||
if (options.plyLimitWithoutProgress > 0 &&
|
||||
pliesSinceProgress >= options.plyLimitWithoutProgress
|
||||
) {
|
||||
return copy(phase = PiratesPhase.GAME_OVER, outcome = PiratesOutcome.DRAW_STALEMATE)
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Engine contract
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
override fun isFinished(state: PiratesState): Boolean = state.phase == PiratesPhase.GAME_OVER
|
||||
|
||||
override fun summary(state: PiratesState): String = state.outcome?.label ?: buildString {
|
||||
append(if (state.turn == BULGAR_SEAT) "The Bulgars" else "The pirates")
|
||||
append(" to move · ")
|
||||
append("${state.stronghold()} of ${STRONGHOLD.size} in the stronghold, ")
|
||||
append("${state.count(BULGAR_SEAT)} Bulgars left")
|
||||
}
|
||||
|
||||
/** Nothing is hidden: the whole board is on the table. */
|
||||
override fun viewFor(state: PiratesState, seat: Int): PiratesState = state
|
||||
|
||||
override fun encodeState(state: PiratesState): String = json.encodeToString(state)
|
||||
override fun decodeState(json: String): PiratesState = this.json.decodeFromString(json)
|
||||
override fun encodeMove(move: PiratesMove): String = json.encodeToString(move)
|
||||
override fun decodeMove(json: String): PiratesMove = this.json.decodeFromString(json)
|
||||
|
||||
/** Leaf nodes at [depth], as a check on the move generator. */
|
||||
fun perft(state: PiratesState, depth: Int): Long {
|
||||
if (depth == 0) return 1L
|
||||
val moves = legalMoves(state)
|
||||
if (depth == 1) return moves.size.toLong()
|
||||
return moves.sumOf { move ->
|
||||
val next = advanced(state, move)
|
||||
if (next.phase == PiratesPhase.GAME_OVER) 1L else perft(next, depth - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,14 +30,22 @@ import org.prolibertate.games.game.GameDescriptor
|
||||
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.game.backgammon.BackgammonLevel
|
||||
import org.prolibertate.games.game.backgammon.BackgammonOptions
|
||||
import org.prolibertate.games.game.checkers.CheckersLevel
|
||||
import org.prolibertate.games.game.checkers.CheckersOptions
|
||||
import org.prolibertate.games.game.chess.ChessLevel
|
||||
import org.prolibertate.games.game.chess.ChessOptions
|
||||
import org.prolibertate.games.game.crazyeights.CrazyEightsOptions
|
||||
import org.prolibertate.games.game.euchre.EuchreOptions
|
||||
import org.prolibertate.games.game.golf.GolfOptions
|
||||
import org.prolibertate.games.game.kaiser.KaiserOptions
|
||||
import org.prolibertate.games.game.mastermind.MastermindLevel
|
||||
import org.prolibertate.games.game.mastermind.MastermindOptions
|
||||
import org.prolibertate.games.game.morris.MorrisLevel
|
||||
import org.prolibertate.games.game.morris.MorrisOptions
|
||||
import org.prolibertate.games.game.pirates.PiratesLevel
|
||||
import org.prolibertate.games.game.pirates.PiratesOptions
|
||||
import org.prolibertate.games.game.president.PresidentOptions
|
||||
import org.prolibertate.games.game.sequence.SequenceOptions
|
||||
import org.prolibertate.games.game.tayu.TayuLevel
|
||||
@@ -74,9 +82,16 @@ fun GameSetupScreen(
|
||||
var chess by remember { mutableStateOf(ChessOptions()) }
|
||||
var tayu by remember { mutableStateOf(TayuOptions()) }
|
||||
var morris by remember { mutableStateOf(MorrisOptions()) }
|
||||
var checkers by remember { mutableStateOf(CheckersOptions()) }
|
||||
var backgammon by remember { mutableStateOf(BackgammonOptions()) }
|
||||
var mastermind by remember { mutableStateOf(MastermindOptions()) }
|
||||
var pirates by remember { mutableStateOf(PiratesOptions()) }
|
||||
// Not an engine option: which seat the local player takes. Seat 0 is
|
||||
// always White, so choosing Black means sitting in seat 1.
|
||||
var playWhite by remember { mutableStateOf(true) }
|
||||
// Pirates and Bulgars is not symmetrical, so which side you take is the
|
||||
// first thing about the game rather than a detail of it.
|
||||
var playPirates by remember { mutableStateOf(false) }
|
||||
|
||||
val optionsJson = when (descriptor.id) {
|
||||
GameCatalog.EUCHRE -> setupJson.encodeToString(euchre)
|
||||
@@ -89,6 +104,10 @@ fun GameSetupScreen(
|
||||
GameCatalog.CHESS -> setupJson.encodeToString(chess)
|
||||
GameCatalog.TAYU -> setupJson.encodeToString(tayu)
|
||||
GameCatalog.MORRIS -> setupJson.encodeToString(morris)
|
||||
GameCatalog.CHECKERS -> setupJson.encodeToString(checkers)
|
||||
GameCatalog.BACKGAMMON -> setupJson.encodeToString(backgammon)
|
||||
GameCatalog.MASTERMIND -> setupJson.encodeToString(mastermind)
|
||||
GameCatalog.PIRATES -> setupJson.encodeToString(pirates)
|
||||
else -> "{}"
|
||||
}
|
||||
val seatCount = seatCountFor(descriptor.id, optionsJson)
|
||||
@@ -125,6 +144,19 @@ fun GameSetupScreen(
|
||||
|
||||
GameCatalog.MORRIS -> MorrisOptionsEditor(morris) { morris = it }
|
||||
|
||||
GameCatalog.CHECKERS -> CheckersOptionsEditor(checkers) { checkers = it }
|
||||
|
||||
GameCatalog.BACKGAMMON -> BackgammonOptionsEditor(backgammon) { backgammon = it }
|
||||
|
||||
GameCatalog.MASTERMIND -> MastermindOptionsEditor(mastermind) { mastermind = it }
|
||||
|
||||
GameCatalog.PIRATES -> PiratesOptionsEditor(
|
||||
options = pirates,
|
||||
playPirates = playPirates,
|
||||
onChange = { pirates = it },
|
||||
onSide = { playPirates = it },
|
||||
)
|
||||
|
||||
else -> Text("No options yet for this game.")
|
||||
}
|
||||
|
||||
@@ -137,9 +169,14 @@ fun GameSetupScreen(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
// Chess is the only game so far where the local player picks a
|
||||
// seat rather than always taking the first one.
|
||||
val localSeat = if (descriptor.id == GameCatalog.CHESS && !playWhite) 1 else 0
|
||||
// Two games let the local player pick a seat rather than always
|
||||
// taking the first one: chess by colour, and Pirates and Bulgars by
|
||||
// which side of a very lopsided fight they fancy.
|
||||
val localSeat = when {
|
||||
descriptor.id == GameCatalog.CHESS && !playWhite -> 1
|
||||
descriptor.id == GameCatalog.PIRATES && playPirates -> 1
|
||||
else -> 0
|
||||
}
|
||||
PrimaryAction(text = "Play against the computer") {
|
||||
onPlayOffline(
|
||||
offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat),
|
||||
@@ -202,7 +239,8 @@ fun seatCountFor(gameId: String, optionsJson: String): Int {
|
||||
GameCatalog.WIZARD ->
|
||||
setupJson.decodeFromString<WizardOptions>(optionsJson).playerCount
|
||||
|
||||
GameCatalog.CHESS, GameCatalog.MORRIS -> 2
|
||||
GameCatalog.CHESS, GameCatalog.MORRIS, GameCatalog.CHECKERS,
|
||||
GameCatalog.BACKGAMMON, GameCatalog.MASTERMIND, GameCatalog.PIRATES -> 2
|
||||
|
||||
GameCatalog.TAYU ->
|
||||
setupJson.decodeFromString<TayuOptions>(optionsJson).playerCount
|
||||
@@ -537,6 +575,178 @@ private fun ChessOptionsEditor(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CheckersOptionsEditor(options: CheckersOptions, onChange: (CheckersOptions) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = CheckersLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Flying kings",
|
||||
subtitle = "A king slides the length of a diagonal and takes from a distance, " +
|
||||
"as in the international game",
|
||||
checked = options.flyingKings,
|
||||
onChange = { onChange(options.copy(flyingKings = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Crowning ends the turn",
|
||||
subtitle = "A man jumping into the back row stops there. Turn this off and it " +
|
||||
"carries on jumping as a man",
|
||||
checked = options.crowningEndsTheTurn,
|
||||
onChange = { onChange(options.copy(crowningEndsTheTurn = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Threefold repetition",
|
||||
subtitle = "The same position three times over is a draw",
|
||||
checked = options.threefoldRepetition,
|
||||
onChange = { onChange(options.copy(threefoldRepetition = it)) },
|
||||
)
|
||||
Text(
|
||||
text = "Captures are always compulsory, and a multiple jump is one turn — " +
|
||||
"there is no version of this game where you may stop halfway.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackgammonOptionsEditor(
|
||||
options: BackgammonOptions,
|
||||
onChange: (BackgammonOptions) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = BackgammonLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Count gammons",
|
||||
subtitle = "A loser who bore nothing off has lost double, and one still on the " +
|
||||
"bar has lost treble",
|
||||
checked = options.countGammons,
|
||||
onChange = { onChange(options.copy(countGammons = it)) },
|
||||
)
|
||||
Text(
|
||||
text = "One game rather than a match, and no doubling cube: there is nothing " +
|
||||
"to double when nothing is being kept.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MastermindOptionsEditor(
|
||||
options: MastermindOptions,
|
||||
onChange: (MastermindOptions) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = MastermindLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ChipRow(
|
||||
label = "Colours",
|
||||
values = listOf(6, 8),
|
||||
selected = options.colours,
|
||||
display = { "$it" },
|
||||
onSelect = { colours ->
|
||||
onChange(
|
||||
options.copy(
|
||||
colours = colours,
|
||||
// A code of distinct colours cannot be longer than the
|
||||
// number of colours there are.
|
||||
length = if (options.allowDuplicates) {
|
||||
options.length
|
||||
} else {
|
||||
options.length.coerceAtMost(colours)
|
||||
},
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
ChipRow(
|
||||
label = "Pegs",
|
||||
values = listOf(3, 4, 5),
|
||||
selected = options.length,
|
||||
display = { "$it" },
|
||||
onSelect = { onChange(options.copy(length = it)) },
|
||||
)
|
||||
ChipRow(
|
||||
label = "Guesses each",
|
||||
values = listOf(8, 10, 12),
|
||||
selected = options.maxGuesses,
|
||||
display = { "$it" },
|
||||
onSelect = { onChange(options.copy(maxGuesses = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Repeated colours",
|
||||
subtitle = "A colour may appear more than once in the code",
|
||||
checked = options.allowDuplicates,
|
||||
onChange = { onChange(options.copy(allowDuplicates = it)) },
|
||||
)
|
||||
Text(
|
||||
text = "${options.codeSpace()} possible codes. Both players are set one and " +
|
||||
"both are breaking one, so cracking it in the same round as your " +
|
||||
"opponent is a draw.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PiratesOptionsEditor(
|
||||
options: PiratesOptions,
|
||||
playPirates: Boolean,
|
||||
onChange: (PiratesOptions) -> Unit,
|
||||
onSide: (Boolean) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "You play",
|
||||
values = listOf(false, true),
|
||||
selected = playPirates,
|
||||
display = { if (it) "the two pirates" else "the twenty-four Bulgars" },
|
||||
onSelect = onSide,
|
||||
)
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = PiratesLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "A pirate who can take must take",
|
||||
subtitle = "The rule that huffing exists to enforce, applied directly",
|
||||
checked = options.captureIsCompulsory,
|
||||
onChange = { onChange(options.copy(captureIsCompulsory = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Bulgars may not retreat",
|
||||
subtitle = "They press towards the stronghold or across, and never back",
|
||||
checked = options.bulgarsMayNotRetreat,
|
||||
onChange = { onChange(options.copy(bulgarsMayNotRetreat = it)) },
|
||||
)
|
||||
Text(
|
||||
text = "The two sides are not playing the same game. The pirates take by " +
|
||||
"jumping and win by cutting the Bulgars below the nine it takes to fill " +
|
||||
"the stronghold; the Bulgars cannot take anything at all, and win by " +
|
||||
"filling it or by leaving the pirates nowhere to go.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MorrisOptionsEditor(options: MorrisOptions, onChange: (MorrisOptions) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
|
||||
@@ -31,10 +31,26 @@ import org.prolibertate.games.game.kaiser.KaiserMove
|
||||
import org.prolibertate.games.game.kaiser.KaiserPhase
|
||||
import org.prolibertate.games.game.kaiser.KaiserRules
|
||||
import org.prolibertate.games.game.kaiser.KaiserState
|
||||
import org.prolibertate.games.game.backgammon.BackgammonAi
|
||||
import org.prolibertate.games.game.backgammon.BackgammonMove
|
||||
import org.prolibertate.games.game.backgammon.BackgammonRules
|
||||
import org.prolibertate.games.game.backgammon.BackgammonState
|
||||
import org.prolibertate.games.game.checkers.CheckersAi
|
||||
import org.prolibertate.games.game.checkers.CheckersMove
|
||||
import org.prolibertate.games.game.checkers.CheckersRules
|
||||
import org.prolibertate.games.game.checkers.CheckersState
|
||||
import org.prolibertate.games.game.mastermind.MastermindAi
|
||||
import org.prolibertate.games.game.mastermind.MastermindMove
|
||||
import org.prolibertate.games.game.mastermind.MastermindRules
|
||||
import org.prolibertate.games.game.mastermind.MastermindState
|
||||
import org.prolibertate.games.game.morris.MorrisAi
|
||||
import org.prolibertate.games.game.morris.MorrisMove
|
||||
import org.prolibertate.games.game.morris.MorrisRules
|
||||
import org.prolibertate.games.game.morris.MorrisState
|
||||
import org.prolibertate.games.game.pirates.PiratesAi
|
||||
import org.prolibertate.games.game.pirates.PiratesMove
|
||||
import org.prolibertate.games.game.pirates.PiratesRules
|
||||
import org.prolibertate.games.game.pirates.PiratesState
|
||||
import org.prolibertate.games.game.president.PresidentAi
|
||||
import org.prolibertate.games.game.president.PresidentMove
|
||||
import org.prolibertate.games.game.president.PresidentPhase
|
||||
@@ -55,12 +71,16 @@ import org.prolibertate.games.game.wizard.WizardRules
|
||||
import org.prolibertate.games.game.wizard.WizardState
|
||||
import org.prolibertate.games.net.MatchController
|
||||
import org.prolibertate.games.settings.Settings
|
||||
import org.prolibertate.games.ui.game.BackgammonScreen
|
||||
import org.prolibertate.games.ui.game.CheckersScreen
|
||||
import org.prolibertate.games.ui.game.ChessScreen
|
||||
import org.prolibertate.games.ui.game.CrazyEightsScreen
|
||||
import org.prolibertate.games.ui.game.EuchreScreen
|
||||
import org.prolibertate.games.ui.game.GolfScreen
|
||||
import org.prolibertate.games.ui.game.KaiserScreen
|
||||
import org.prolibertate.games.ui.game.MastermindScreen
|
||||
import org.prolibertate.games.ui.game.MorrisScreen
|
||||
import org.prolibertate.games.ui.game.PiratesScreen
|
||||
import org.prolibertate.games.ui.game.PresidentScreen
|
||||
import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS
|
||||
import org.prolibertate.games.ui.game.SequenceScreen
|
||||
@@ -307,6 +327,76 @@ fun PlayScreen(
|
||||
TayuScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||
}
|
||||
|
||||
GameCatalog.CHECKERS -> {
|
||||
val controller = remember(route) {
|
||||
MatchController<CheckersState, CheckersMove>(
|
||||
rules = CheckersRules,
|
||||
ai = CheckersAi(),
|
||||
config = route.config,
|
||||
scope = scope,
|
||||
role = role,
|
||||
localSeats = setOf(route.localSeat),
|
||||
primarySeat = route.localSeat,
|
||||
aiThinkingMillis = { settings.scaled(250L) },
|
||||
)
|
||||
}
|
||||
StartMatch(env, controller, route)
|
||||
CheckersScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||
}
|
||||
|
||||
GameCatalog.BACKGAMMON -> {
|
||||
val controller = remember(route) {
|
||||
MatchController<BackgammonState, BackgammonMove>(
|
||||
rules = BackgammonRules,
|
||||
ai = BackgammonAi(),
|
||||
config = route.config,
|
||||
scope = scope,
|
||||
role = role,
|
||||
localSeats = setOf(route.localSeat),
|
||||
primarySeat = route.localSeat,
|
||||
// A checker at a time, so the pause is per checker rather
|
||||
// than per turn — half of it, or a double would take an age.
|
||||
aiThinkingMillis = { settings.scaled(350L) },
|
||||
)
|
||||
}
|
||||
StartMatch(env, controller, route)
|
||||
BackgammonScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||
}
|
||||
|
||||
GameCatalog.MASTERMIND -> {
|
||||
val controller = remember(route) {
|
||||
MatchController<MastermindState, MastermindMove>(
|
||||
rules = MastermindRules,
|
||||
ai = MastermindAi(),
|
||||
config = route.config,
|
||||
scope = scope,
|
||||
role = role,
|
||||
localSeats = setOf(route.localSeat),
|
||||
primarySeat = route.localSeat,
|
||||
aiThinkingMillis = { settings.scaled(900L) },
|
||||
)
|
||||
}
|
||||
StartMatch(env, controller, route)
|
||||
MastermindScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||
}
|
||||
|
||||
GameCatalog.PIRATES -> {
|
||||
val controller = remember(route) {
|
||||
MatchController<PiratesState, PiratesMove>(
|
||||
rules = PiratesRules,
|
||||
ai = PiratesAi(),
|
||||
config = route.config,
|
||||
scope = scope,
|
||||
role = role,
|
||||
localSeats = setOf(route.localSeat),
|
||||
primarySeat = route.localSeat,
|
||||
aiThinkingMillis = { settings.scaled(250L) },
|
||||
)
|
||||
}
|
||||
StartMatch(env, controller, route)
|
||||
PiratesScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||
}
|
||||
|
||||
GameCatalog.MORRIS -> {
|
||||
val controller = remember(route) {
|
||||
MatchController<MorrisState, MorrisMove>(
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
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.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.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 androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.prolibertate.games.game.backgammon.BAR
|
||||
import org.prolibertate.games.game.backgammon.BackgammonMove
|
||||
import org.prolibertate.games.game.backgammon.BackgammonPhase
|
||||
import org.prolibertate.games.game.backgammon.BackgammonState
|
||||
import org.prolibertate.games.game.backgammon.CHECKERS
|
||||
import org.prolibertate.games.game.backgammon.WHITE_SEAT
|
||||
import org.prolibertate.games.game.backgammon.other
|
||||
import org.prolibertate.games.game.backgammon.seatName
|
||||
import org.prolibertate.games.net.MatchController
|
||||
import org.prolibertate.games.ui.ScreenScaffold
|
||||
|
||||
private val BoardField = Color(0xFF7A5230)
|
||||
private val PointLight = Color(0xFFE8CFA9)
|
||||
private val PointDark = Color(0xFF9C3B2E)
|
||||
private val WhiteChecker = Color(0xFFF7F2E7)
|
||||
private val BlackChecker = Color(0xFF23201C)
|
||||
private val CheckerEdge = Color(0x66000000)
|
||||
private val SelectedPoint = Color(0x8830C030)
|
||||
private val TargetPoint = Color(0x66FFC107)
|
||||
|
||||
/**
|
||||
* The backgammon board, drawn as two banks of twelve points with the bar down
|
||||
* the middle.
|
||||
*
|
||||
* Tap a point to pick a checker up and tap where it goes; if there is only one
|
||||
* thing that checker can do, tapping it plays it. The dice left to play are
|
||||
* shown above the board and go out as they are used.
|
||||
*
|
||||
* Points are drawn in the engine's own numbering — 1 to 24 with White running
|
||||
* down — rather than being flipped for whoever is sitting there. A backgammon
|
||||
* board is symmetrical and both players read the same numbers off it.
|
||||
*/
|
||||
@Composable
|
||||
fun BackgammonScreen(
|
||||
controller: MatchController<BackgammonState, BackgammonMove>,
|
||||
localSeat: Int,
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val legal by controller.legalMoves.collectAsState()
|
||||
val abandoned by controller.abandoned.collectAsState()
|
||||
|
||||
var selected by remember { mutableStateOf<Int?>(null) }
|
||||
var confirmingEnd by remember { mutableStateOf(false) }
|
||||
|
||||
val endGameAction: @Composable () -> Unit = {
|
||||
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
|
||||
}
|
||||
|
||||
val current = state
|
||||
if (current == null) {
|
||||
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||
Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") }
|
||||
abandoned?.let { LeftBoardDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) LeaveDialog(onExit) { confirmingEnd = false }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val fromSelected = legal.filter { it.from == selected }
|
||||
val targets = fromSelected.mapNotNull { move ->
|
||||
org.prolibertate.games.game.backgammon.BackgammonRules
|
||||
.destinationOf(current.turn, move.from, move.die)
|
||||
}.toSet()
|
||||
|
||||
val onPointTapped: (Int) -> Unit = { point ->
|
||||
val landing = fromSelected.firstOrNull { move ->
|
||||
org.prolibertate.games.game.backgammon.BackgammonRules
|
||||
.destinationOf(current.turn, move.from, move.die) == point
|
||||
}
|
||||
when {
|
||||
landing != null -> {
|
||||
controller.submit(landing)
|
||||
selected = null
|
||||
}
|
||||
|
||||
legal.any { it.from == point } -> {
|
||||
val fromHere = legal.filter { it.from == point }
|
||||
// Nothing to choose between means nothing to ask about.
|
||||
if (fromHere.size == 1) {
|
||||
controller.submit(fromHere.single())
|
||||
selected = null
|
||||
} else {
|
||||
selected = point
|
||||
}
|
||||
}
|
||||
|
||||
else -> selected = null
|
||||
}
|
||||
}
|
||||
|
||||
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Status(current, localSeat)
|
||||
DiceRow(current)
|
||||
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
||||
val boardWidth = minOf(maxWidth, maxHeight * 1.25f)
|
||||
Board(
|
||||
state = current,
|
||||
width = boardWidth,
|
||||
height = minOf(maxHeight, boardWidth * 0.8f),
|
||||
selected = selected,
|
||||
targets = targets,
|
||||
onTap = onPointTapped,
|
||||
)
|
||||
}
|
||||
|
||||
OffAndBar(current, localSeat) { onPointTapped(BAR) }
|
||||
}
|
||||
|
||||
val over = abandoned
|
||||
when {
|
||||
over != null -> LeftBoardDialog(over, onExit, controller::dismissAbandoned)
|
||||
confirmingEnd -> LeaveDialog(onExit) { confirmingEnd = false }
|
||||
current.phase == BackgammonPhase.GAME_OVER -> ResultDialog(current, onExit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val TITLE = "Backgammon"
|
||||
|
||||
@Composable
|
||||
private fun Status(state: BackgammonState, localSeat: Int) {
|
||||
val yours = state.turn == localSeat && state.phase == BackgammonPhase.PLAYING
|
||||
Column {
|
||||
Text(
|
||||
text = state.outcome?.label ?: when {
|
||||
yours && state.bar[localSeat] > 0 -> "Come in off the bar"
|
||||
yours -> "Your roll to play"
|
||||
else -> "Thinking…"
|
||||
},
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = "You play ${seatName(localSeat)} · " +
|
||||
"${state.pipCount(localSeat)} pips against ${state.pipCount(other(localSeat))}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The dice, with the ones already played greyed out rather than removed. */
|
||||
@Composable
|
||||
private fun DiceRow(state: BackgammonState) {
|
||||
val left = state.dice.toMutableList()
|
||||
val shown = if (state.roll.size == 2 && state.roll[0] == state.roll[1]) {
|
||||
List(4) { state.roll[0] }
|
||||
} else {
|
||||
state.roll
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
shown.forEach { die ->
|
||||
val unplayed = left.remove(die)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(30.dp)
|
||||
.background(if (unplayed) PointLight else Color(0x33808080))
|
||||
.border(1.dp, CheckerEdge),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "$die",
|
||||
color = Color(0xFF23201C),
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Board(
|
||||
state: BackgammonState,
|
||||
width: Dp,
|
||||
height: Dp,
|
||||
selected: Int?,
|
||||
targets: Set<Int>,
|
||||
onTap: (Int) -> Unit,
|
||||
) {
|
||||
val columnWidth = width / 13
|
||||
val bankHeight = height / 2
|
||||
|
||||
Column(modifier = Modifier.width(width).height(height).background(BoardField)) {
|
||||
// The top bank runs 13 to 24 left to right, the bottom 12 down to 1 —
|
||||
// the standard layout, with White's home at the bottom right.
|
||||
Bank(
|
||||
points = (12..23).toList(),
|
||||
state = state,
|
||||
columnWidth = columnWidth,
|
||||
bankHeight = bankHeight,
|
||||
pointingDown = true,
|
||||
selected = selected,
|
||||
targets = targets,
|
||||
onTap = onTap,
|
||||
)
|
||||
Bank(
|
||||
points = (11 downTo 0).toList(),
|
||||
state = state,
|
||||
columnWidth = columnWidth,
|
||||
bankHeight = bankHeight,
|
||||
pointingDown = false,
|
||||
selected = selected,
|
||||
targets = targets,
|
||||
onTap = onTap,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Bank(
|
||||
points: List<Int>,
|
||||
state: BackgammonState,
|
||||
columnWidth: Dp,
|
||||
bankHeight: Dp,
|
||||
pointingDown: Boolean,
|
||||
selected: Int?,
|
||||
targets: Set<Int>,
|
||||
onTap: (Int) -> Unit,
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth().height(bankHeight)) {
|
||||
points.forEachIndexed { index, point ->
|
||||
// The bar sits between the two halves of each bank.
|
||||
if (index == 6) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(columnWidth)
|
||||
.fillMaxHeight()
|
||||
.background(BoardField)
|
||||
.border(1.dp, Color(0x33000000))
|
||||
)
|
||||
}
|
||||
PointColumn(
|
||||
point = point,
|
||||
state = state,
|
||||
width = columnWidth,
|
||||
height = bankHeight,
|
||||
light = index % 2 == 0,
|
||||
pointingDown = pointingDown,
|
||||
selected = point == selected,
|
||||
target = point in targets,
|
||||
onTap = { onTap(point) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PointColumn(
|
||||
point: Int,
|
||||
state: BackgammonState,
|
||||
width: Dp,
|
||||
height: Dp,
|
||||
light: Boolean,
|
||||
pointingDown: Boolean,
|
||||
selected: Boolean,
|
||||
target: Boolean,
|
||||
onTap: () -> Unit,
|
||||
) {
|
||||
val white = state.countOn(point, WHITE_SEAT)
|
||||
val black = state.countOn(point, 1 - WHITE_SEAT)
|
||||
val count = white + black
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(width)
|
||||
.height(height)
|
||||
.background(if (light) PointLight.copy(alpha = 0.35f) else PointDark.copy(alpha = 0.5f))
|
||||
.then(if (selected) Modifier.background(SelectedPoint) else Modifier)
|
||||
.then(if (target) Modifier.background(TargetPoint) else Modifier)
|
||||
.border(1.dp, Color(0x22000000))
|
||||
.clickable { onTap() },
|
||||
contentAlignment = if (pointingDown) Alignment.TopCenter else Alignment.BottomCenter,
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = if (pointingDown) Arrangement.Top else Arrangement.Bottom,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
// Five checkers is as many as fit; anything beyond that is written
|
||||
// on the last one, which is what a crowded board does anyway.
|
||||
val drawn = minOf(count, 5)
|
||||
repeat(drawn) { index ->
|
||||
val isLast = index == drawn - 1
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width * 0.82f)
|
||||
.clip(CircleShape)
|
||||
.background(if (white > 0) WhiteChecker else BlackChecker)
|
||||
.border(1.dp, CheckerEdge, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (isLast && count > 5) {
|
||||
Text(
|
||||
text = "$count",
|
||||
color = if (white > 0) Color(0xFF23201C) else Color(0xFFF7F2E7),
|
||||
fontSize = (width.value * 0.36f).sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${point + 1}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color(0x99000000),
|
||||
modifier = Modifier
|
||||
.align(if (pointingDown) Alignment.BottomCenter else Alignment.TopCenter)
|
||||
.padding(1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** What is on the bar and what is safely off, with the bar tappable to come in. */
|
||||
@Composable
|
||||
private fun OffAndBar(state: BackgammonState, localSeat: Int, onBarTapped: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(onClick = onBarTapped, enabled = state.bar[localSeat] > 0) {
|
||||
Text("Bar: ${state.bar[localSeat]}")
|
||||
}
|
||||
Text(
|
||||
text = "Borne off — you ${state.off[localSeat]} of $CHECKERS, " +
|
||||
"them ${state.off[other(localSeat)]}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LeaveDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onKeepPlaying,
|
||||
title = { Text("End this game?") },
|
||||
text = {
|
||||
Text(
|
||||
"This game ends here and the position is not saved. Anyone else " +
|
||||
"at the table is told you have left."
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LeftBoardDialog(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("Leave the game") } },
|
||||
dismissButton = { TextButton(onClick = onStay) { Text("Look at the board") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResultDialog(state: BackgammonState, onExit: () -> Unit) {
|
||||
var dismissed by remember(state.outcome) { mutableStateOf(false) }
|
||||
if (dismissed) return
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { dismissed = true },
|
||||
title = { Text("Game over") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(state.outcome?.label.orEmpty(), fontWeight = FontWeight.Bold)
|
||||
state.outcome?.let {
|
||||
Text(
|
||||
text = "Worth ${it.points} point${if (it.points == 1) "" else "s"}.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } },
|
||||
dismissButton = { TextButton(onClick = { dismissed = true }) { Text("Look at the board") } },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
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.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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.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 androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.prolibertate.games.game.checkers.BLACK_SEAT
|
||||
import org.prolibertate.games.game.checkers.CheckersMove
|
||||
import org.prolibertate.games.game.checkers.CheckersPhase
|
||||
import org.prolibertate.games.game.checkers.CheckersState
|
||||
import org.prolibertate.games.game.checkers.seatName
|
||||
import org.prolibertate.games.game.checkers.squareAt
|
||||
import org.prolibertate.games.net.MatchController
|
||||
import org.prolibertate.games.ui.ScreenScaffold
|
||||
|
||||
private val LightSquare = Color(0xFFEEDAB4)
|
||||
private val DarkSquare = Color(0xFF9A7248)
|
||||
private val BlackPiece = Color(0xFF1E1B18)
|
||||
private val WhitePiece = Color(0xFFF7F2E7)
|
||||
private val PieceEdge = Color(0x66000000)
|
||||
private val SelectedSquare = Color(0x8830C030)
|
||||
private val PathSquare = Color(0x66FFC107)
|
||||
private val MoveDot = Color(0x66000000)
|
||||
private val CrownInk = Color(0xFFFFC107)
|
||||
|
||||
/**
|
||||
* The checkers board.
|
||||
*
|
||||
* Tap a piece, then tap where it goes. A multiple jump is one move as far as
|
||||
* the rules are concerned, so it is tapped out one hop at a time and sent when
|
||||
* the chain is complete — a player cannot stop in the middle of it, and the
|
||||
* board will not let them try.
|
||||
*
|
||||
* The board is drawn from the local player's side, so a game as White is played
|
||||
* up the screen the way it would be across a real table.
|
||||
*/
|
||||
@Composable
|
||||
fun CheckersScreen(
|
||||
controller: MatchController<CheckersState, CheckersMove>,
|
||||
localSeat: Int,
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val legal by controller.legalMoves.collectAsState()
|
||||
val abandoned by controller.abandoned.collectAsState()
|
||||
|
||||
var selected by remember { mutableStateOf<Int?>(null) }
|
||||
// The hops tapped out so far of a jump that is not finished.
|
||||
var path by remember { mutableStateOf(emptyList<Int>()) }
|
||||
var confirmingEnd by remember { mutableStateOf(false) }
|
||||
|
||||
val endGameAction: @Composable () -> Unit = {
|
||||
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
|
||||
}
|
||||
|
||||
val current = state
|
||||
if (current == null) {
|
||||
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||
Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") }
|
||||
abandoned?.let { LeftTableDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) EndGameDialog(onExit) { confirmingEnd = false }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Every move still possible given the piece picked up and the hops tapped.
|
||||
val following = legal.filter { move ->
|
||||
move.from == selected && move.steps.take(path.size) == path
|
||||
}
|
||||
val nextHops = following.mapNotNull { it.steps.getOrNull(path.size) }.toSet()
|
||||
|
||||
val onSquareTapped: (Int) -> Unit = { square ->
|
||||
when {
|
||||
square in nextHops -> {
|
||||
val extended = path + square
|
||||
val done = following.firstOrNull { it.steps == extended }
|
||||
if (done != null) {
|
||||
controller.submit(done)
|
||||
selected = null
|
||||
path = emptyList()
|
||||
} else {
|
||||
path = extended
|
||||
}
|
||||
}
|
||||
|
||||
// Picking up a different piece, which is what a misplaced tap means.
|
||||
legal.any { it.from == square } -> {
|
||||
selected = square
|
||||
path = emptyList()
|
||||
}
|
||||
|
||||
else -> {
|
||||
selected = null
|
||||
path = emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
val wide = maxWidth > maxHeight
|
||||
val side = if (wide) minOf(maxHeight, maxWidth * 0.62f) else minOf(maxWidth, maxHeight * 0.72f)
|
||||
|
||||
val board: @Composable () -> Unit = {
|
||||
Board(
|
||||
state = current,
|
||||
flipped = localSeat != BLACK_SEAT,
|
||||
side = side,
|
||||
selected = selected,
|
||||
path = path,
|
||||
destinations = nextHops,
|
||||
onTap = onSquareTapped,
|
||||
)
|
||||
}
|
||||
val panel: @Composable (Modifier) -> Unit = { panelModifier ->
|
||||
Column(modifier = panelModifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Status(current, localSeat, path.isNotEmpty())
|
||||
MoveLog(current, modifier = Modifier.weight(1f, fill = false))
|
||||
}
|
||||
}
|
||||
|
||||
if (wide) {
|
||||
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
board()
|
||||
panel(Modifier.weight(1f).fillMaxSize())
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
board()
|
||||
panel(Modifier.fillMaxWidth().weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
val over = abandoned
|
||||
when {
|
||||
over != null -> LeftTableDialog(over, onExit, controller::dismissAbandoned)
|
||||
confirmingEnd -> EndGameDialog(onExit) { confirmingEnd = false }
|
||||
current.phase == CheckersPhase.GAME_OVER -> ResultDialog(current, onExit)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val TITLE = "Checkers"
|
||||
|
||||
@Composable
|
||||
private fun Board(
|
||||
state: CheckersState,
|
||||
flipped: Boolean,
|
||||
side: Dp,
|
||||
selected: Int?,
|
||||
path: List<Int>,
|
||||
destinations: Set<Int>,
|
||||
onTap: (Int) -> Unit,
|
||||
) {
|
||||
val cell = side / 8
|
||||
Column(modifier = Modifier.size(side).border(2.dp, DarkSquare)) {
|
||||
for (displayRow in 0 until 8) {
|
||||
Row(modifier = Modifier.weight(1f)) {
|
||||
for (displayColumn in 0 until 8) {
|
||||
// Turning the board round is one subtraction, so nothing
|
||||
// below this has to know which way up it is drawn.
|
||||
val row = if (flipped) 7 - displayRow else displayRow
|
||||
val column = if (flipped) 7 - displayColumn else displayColumn
|
||||
val square = squareAt(row, column)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxSize()
|
||||
.background(if (square == null) LightSquare else DarkSquare)
|
||||
.then(
|
||||
if (square != null && square in path) {
|
||||
Modifier.background(PathSquare)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (square != null && square == selected) {
|
||||
Modifier.background(SelectedSquare)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (square != null) Modifier.clickable { onTap(square) } else Modifier
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val piece = square?.let { state.board[it] }
|
||||
if (piece != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(cell * 0.74f)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (piece.seat == BLACK_SEAT) BlackPiece else WhitePiece
|
||||
)
|
||||
.border(1.dp, PieceEdge, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// A crown rather than a second disc: two discs
|
||||
// stacked is what a wooden set does and what a
|
||||
// small screen cannot show.
|
||||
if (piece.king) {
|
||||
Text(
|
||||
text = "♛",
|
||||
color = CrownInk,
|
||||
fontSize = (cell.value * 0.42f).sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (square != null && square in destinations) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(cell * 0.28f)
|
||||
.clip(CircleShape)
|
||||
.background(MoveDot)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Status(state: CheckersState, localSeat: Int, midJump: Boolean) {
|
||||
val yours = state.turn == localSeat && state.phase == CheckersPhase.PLAYING
|
||||
Column {
|
||||
Text(
|
||||
text = state.outcome?.label ?: when {
|
||||
midJump -> "Keep jumping — the capture has to be finished"
|
||||
yours -> "Your move"
|
||||
else -> "Thinking…"
|
||||
},
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = "You play ${seatName(localSeat)} · " +
|
||||
"${state.count(localSeat)} left (${state.kings(localSeat)} crowned) " +
|
||||
"against ${state.count(1 - localSeat)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
if (state.phase == CheckersPhase.PLAYING) {
|
||||
Text(
|
||||
text = "A capture must be taken when one is on the board.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MoveLog(state: CheckersState, modifier: Modifier = Modifier) {
|
||||
if (state.moveLog.isEmpty()) {
|
||||
Text("No moves yet.", style = MaterialTheme.typography.bodySmall, modifier = modifier)
|
||||
return
|
||||
}
|
||||
Column(modifier = modifier.verticalScroll(rememberScrollState())) {
|
||||
state.moveLog.chunked(2).forEachIndexed { index, pair ->
|
||||
Text(
|
||||
text = "${index + 1}. ${pair.joinToString(" ")}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EndGameDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onKeepPlaying,
|
||||
title = { Text("End this game?") },
|
||||
text = {
|
||||
Text(
|
||||
"This game ends here and the position is not saved. Anyone else " +
|
||||
"at the table is told you have left."
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LeftTableDialog(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("Leave the game") } },
|
||||
dismissButton = { TextButton(onClick = onStay) { Text("Look at the board") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResultDialog(state: CheckersState, onExit: () -> Unit) {
|
||||
var dismissed by remember(state.outcome) { mutableStateOf(false) }
|
||||
if (dismissed) return
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { dismissed = true },
|
||||
title = { Text("Game over") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(state.outcome?.label.orEmpty(), fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
text = "${state.moveLog.size} moves played.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } },
|
||||
dismissButton = { TextButton(onClick = { dismissed = true }) { Text("Look at the board") } },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.prolibertate.games.game.mastermind.Feedback
|
||||
import org.prolibertate.games.game.mastermind.Guess
|
||||
import org.prolibertate.games.game.mastermind.MastermindMove
|
||||
import org.prolibertate.games.game.mastermind.MastermindPhase
|
||||
import org.prolibertate.games.game.mastermind.MastermindRules
|
||||
import org.prolibertate.games.game.mastermind.MastermindState
|
||||
import org.prolibertate.games.game.mastermind.other
|
||||
import org.prolibertate.games.net.MatchController
|
||||
import org.prolibertate.games.ui.PrimaryAction
|
||||
import org.prolibertate.games.ui.ScreenScaffold
|
||||
|
||||
/**
|
||||
* The eight code pegs.
|
||||
*
|
||||
* Kept well apart in hue *and* in lightness, because a code you cannot read is
|
||||
* not a puzzle, it is an eye test — and one player in twelve cannot tell the
|
||||
* red from the green.
|
||||
*/
|
||||
private val PegColours = listOf(
|
||||
Color(0xFFD32F2F),
|
||||
Color(0xFF1976D2),
|
||||
Color(0xFFFBC02D),
|
||||
Color(0xFF388E3C),
|
||||
Color(0xFFF57C00),
|
||||
Color(0xFF7B1FA2),
|
||||
Color(0xFF00ACC1),
|
||||
Color(0xFFFAFAFA),
|
||||
)
|
||||
|
||||
private val EmptySlot = Color(0x33808080)
|
||||
private val ExactPeg = Color(0xFF212121)
|
||||
private val MisplacedPeg = Color(0xFFFFFFFF)
|
||||
|
||||
/**
|
||||
* Mastermind, played as a duel: each player sets the code their opponent will
|
||||
* have to break, and then both are breaking one, a guess each in turn.
|
||||
*
|
||||
* The same row of pegs does both jobs — tap the colours to build it, and the
|
||||
* button says which one you are doing. The answer to each guess is the little
|
||||
* pegs beside it: a dark one for a colour in the right place, a light one for a
|
||||
* colour in the wrong place, and no indication whatever of which is which.
|
||||
*/
|
||||
@Composable
|
||||
fun MastermindScreen(
|
||||
controller: MatchController<MastermindState, MastermindMove>,
|
||||
localSeat: Int,
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val abandoned by controller.abandoned.collectAsState()
|
||||
|
||||
val building = remember { mutableStateListOf<Int>() }
|
||||
var confirmingEnd by remember { mutableStateOf(false) }
|
||||
|
||||
val endGameAction: @Composable () -> Unit = {
|
||||
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
|
||||
}
|
||||
|
||||
val current = state
|
||||
if (current == null) {
|
||||
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||
Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") }
|
||||
abandoned?.let { LeftDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) EndDialog(onExit) { confirmingEnd = false }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val setting = current.phase == MastermindPhase.SETTING
|
||||
val yourTurn = current.turn == localSeat && current.phase != MastermindPhase.GAME_OVER
|
||||
// A row has to be a code this table allows — which on a no-repeats table
|
||||
// rules out a repeated colour — and a guess may not be one already made.
|
||||
val readyToSend = MastermindRules.isWellFormed(current.options, building.toList()) &&
|
||||
(setting || current.guesses[localSeat].none { it.code == building.toList() })
|
||||
|
||||
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||
Column(
|
||||
modifier = modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = when {
|
||||
current.outcome != null -> current.outcome!!.label
|
||||
setting && yourTurn -> "Set the code they will have to break"
|
||||
setting -> "Waiting for them to set theirs…"
|
||||
yourTurn -> "Your guess — ${current.guessesLeft(localSeat)} left"
|
||||
else -> "Thinking…"
|
||||
},
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = if (setting) {
|
||||
"Choose any code the table allows. They will not see it, and you " +
|
||||
"will be breaking the one they choose for you."
|
||||
} else {
|
||||
"A dark peg is a colour in the right place, a light one a colour " +
|
||||
"somewhere else. Which is which is for you to work out."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
if (setting) {
|
||||
// Nothing has been guessed yet, so the only thing worth showing
|
||||
// is whether the other player is ready.
|
||||
Text(
|
||||
text = if (current.hasSet(other(localSeat))) {
|
||||
"They have set theirs."
|
||||
} else {
|
||||
"They have not set theirs yet."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "Your guesses",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
GuessList(current, localSeat)
|
||||
}
|
||||
|
||||
if (current.phase != MastermindPhase.GAME_OVER) {
|
||||
Builder(
|
||||
length = current.options.length,
|
||||
colours = current.options.colours,
|
||||
allowDuplicates = current.options.allowDuplicates,
|
||||
building = building,
|
||||
enabled = yourTurn,
|
||||
label = if (setting) "Set this code" else "Guess",
|
||||
ready = readyToSend,
|
||||
onSubmit = {
|
||||
controller.submit(MastermindMove(building.toList()))
|
||||
building.clear()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (!setting) {
|
||||
Text(
|
||||
text = "Against you",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
Text(
|
||||
text = "${current.guesses[other(localSeat)].size} guesses at your code so far.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
GuessList(current, other(localSeat))
|
||||
}
|
||||
|
||||
// Your own code, which is yours to see all along — you chose it, and
|
||||
// you are guarding it rather than guessing it.
|
||||
if (current.secrets[localSeat].isNotEmpty()) {
|
||||
Text(
|
||||
text = "The code you set",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
PegRow(current.secrets[localSeat])
|
||||
}
|
||||
}
|
||||
|
||||
val over = abandoned
|
||||
when {
|
||||
over != null -> LeftDialog(over, onExit, controller::dismissAbandoned)
|
||||
confirmingEnd -> EndDialog(onExit) { confirmingEnd = false }
|
||||
current.phase == MastermindPhase.GAME_OVER -> ResultDialog(current, localSeat, onExit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val TITLE = "Mastermind"
|
||||
|
||||
@Composable
|
||||
private fun GuessList(state: MastermindState, seat: Int) {
|
||||
val guesses = state.guesses[seat]
|
||||
if (guesses.isEmpty()) {
|
||||
Text("Nothing yet.", style = MaterialTheme.typography.bodySmall)
|
||||
return
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
guesses.forEachIndexed { index, guess ->
|
||||
GuessRow(index + 1, guess, state.options.length)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GuessRow(number: Int, guess: Guess, length: Int) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "$number.",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.size(width = 22.dp, height = 20.dp),
|
||||
)
|
||||
PegRow(guess.code)
|
||||
Box(modifier = Modifier.padding(start = 10.dp)) {
|
||||
FeedbackPegs(guess.feedback, length)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PegRow(code: List<Int>) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
code.forEach { colour -> Peg(colour) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Peg(colour: Int?, size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
.clip(CircleShape)
|
||||
.background(colour?.let { PegColours[it % PegColours.size] } ?: EmptySlot)
|
||||
.border(1.dp, Color(0x55000000), CircleShape)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The answer, as pegs rather than as numbers.
|
||||
*
|
||||
* Deliberately not laid out one-per-code-peg: the pegs say how many, never
|
||||
* which, and putting them in a tidy row under the code would invite reading a
|
||||
* position into them that is not there.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun FeedbackPegs(feedback: Feedback, length: Int) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
maxItemsInEachRow = (length + 1) / 2,
|
||||
) {
|
||||
repeat(feedback.exact) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.clip(CircleShape)
|
||||
.background(ExactPeg)
|
||||
.border(1.dp, Color(0x55000000), CircleShape)
|
||||
)
|
||||
}
|
||||
repeat(feedback.misplaced) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MisplacedPeg)
|
||||
.border(1.dp, Color(0x55000000), CircleShape)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The guess being built: tap a colour to add it, tap a peg to take it back. */
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun Builder(
|
||||
length: Int,
|
||||
colours: Int,
|
||||
allowDuplicates: Boolean,
|
||||
building: MutableList<Int>,
|
||||
enabled: Boolean,
|
||||
label: String,
|
||||
ready: Boolean,
|
||||
onSubmit: () -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
repeat(length) { slot ->
|
||||
Box(
|
||||
modifier = Modifier.clickable(enabled = enabled && slot < building.size) {
|
||||
// Taking a peg back removes it and everything after it,
|
||||
// which is what a physical board does when you lift one
|
||||
// out of the middle of a row you are still filling.
|
||||
while (building.size > slot) building.removeAt(building.size - 1)
|
||||
}
|
||||
) {
|
||||
Peg(building.getOrNull(slot), size = 32.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
repeat(colours) { colour ->
|
||||
// On a table without repeats, a colour already down is not a
|
||||
// colour you can put down again — greyed rather than merely
|
||||
// refused, so it never comes as a surprise.
|
||||
val spent = !allowDuplicates && colour in building
|
||||
Box(
|
||||
modifier = Modifier.alpha(if (spent) 0.3f else 1f).clickable(
|
||||
enabled = enabled && !spent && building.size < length,
|
||||
) {
|
||||
building.add(colour)
|
||||
}
|
||||
) {
|
||||
Peg(colour, size = 34.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PrimaryAction(
|
||||
text = label,
|
||||
enabled = enabled && ready,
|
||||
onClick = onSubmit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EndDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onKeepPlaying,
|
||||
title = { Text("End this game?") },
|
||||
text = { Text("The game ends here. Anyone else at the table is told you have left.") },
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LeftDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onStay,
|
||||
title = { Text("The game has ended") },
|
||||
text = { Text("$notice There are no more guesses to make.") },
|
||||
confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } },
|
||||
dismissButton = { TextButton(onClick = onStay) { Text("Look at the board") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResultDialog(state: MastermindState, localSeat: Int, onExit: () -> Unit) {
|
||||
var dismissed by remember(state.outcome) { mutableStateOf(false) }
|
||||
if (dismissed) return
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { dismissed = true },
|
||||
title = { Text("Game over") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(state.outcome?.label.orEmpty(), fontWeight = FontWeight.Bold)
|
||||
// Both codes are shown now: there is nothing left to protect,
|
||||
// and everyone wants to see what they were up against.
|
||||
Text("The code you were breaking", style = MaterialTheme.typography.bodySmall)
|
||||
PegRow(state.secrets[other(localSeat)])
|
||||
state.brokeIn(localSeat)?.let {
|
||||
Text("You broke it in $it.", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
state.brokeIn(other(localSeat))?.let {
|
||||
Text("They broke yours in $it.", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } },
|
||||
dismissButton = { TextButton(onClick = { dismissed = true }) { Text("Look at the board") } },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
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.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.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.prolibertate.games.game.pirates.ADJACENCY
|
||||
import org.prolibertate.games.game.pirates.BULGAR_SEAT
|
||||
import org.prolibertate.games.game.pirates.GRID
|
||||
import org.prolibertate.games.game.pirates.PIRATE_SEAT
|
||||
import org.prolibertate.games.game.pirates.POINTS
|
||||
import org.prolibertate.games.game.pirates.PiratesMove
|
||||
import org.prolibertate.games.game.pirates.PiratesPhase
|
||||
import org.prolibertate.games.game.pirates.PiratesState
|
||||
import org.prolibertate.games.game.pirates.STRONGHOLD
|
||||
import org.prolibertate.games.game.pirates.columnOf
|
||||
import org.prolibertate.games.game.pirates.isOnBoard
|
||||
import org.prolibertate.games.game.pirates.isStronghold
|
||||
import org.prolibertate.games.game.pirates.pointAt
|
||||
import org.prolibertate.games.game.pirates.rowOf
|
||||
import org.prolibertate.games.net.MatchController
|
||||
import org.prolibertate.games.ui.ScreenScaffold
|
||||
|
||||
private val BoardField = Color(0xFFE7D6B8)
|
||||
private val StrongholdField = Color(0xFFCBA96B)
|
||||
private val BoardLine = Color(0xFF5C4326)
|
||||
private val BulgarStone = Color(0xFF3C6E47)
|
||||
private val PirateStone = Color(0xFF7A1E1E)
|
||||
private val StoneEdge = Color(0x66000000)
|
||||
private val VacantPoint = Color(0x442B2B2B)
|
||||
private val SelectedRing = Color(0xFF2E9E2E)
|
||||
private val PathRing = Color(0xFFE0A800)
|
||||
private val DestinationDot = Color(0x66000000)
|
||||
|
||||
/**
|
||||
* Pirates and Bulgars, on the cross-shaped board.
|
||||
*
|
||||
* Tap one of your pieces and then where it goes. A pirate's chain of jumps is
|
||||
* one turn, so it is tapped out hop by hop and sent when there is nothing left
|
||||
* to take — the capture cannot be abandoned halfway, and the board will not let
|
||||
* a player try.
|
||||
*
|
||||
* The stronghold is the shaded arm at the top. Both sides look at it the same
|
||||
* way up: the board is not turned round, because what each side is trying to do
|
||||
* with it is not the same and swapping ends would only disguise that.
|
||||
*/
|
||||
@Composable
|
||||
fun PiratesScreen(
|
||||
controller: MatchController<PiratesState, PiratesMove>,
|
||||
localSeat: Int,
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val legal by controller.legalMoves.collectAsState()
|
||||
val abandoned by controller.abandoned.collectAsState()
|
||||
|
||||
var selected by remember { mutableStateOf<Int?>(null) }
|
||||
var path by remember { mutableStateOf(emptyList<Int>()) }
|
||||
var confirmingEnd by remember { mutableStateOf(false) }
|
||||
|
||||
val endGameAction: @Composable () -> Unit = {
|
||||
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
|
||||
}
|
||||
|
||||
val current = state
|
||||
if (current == null) {
|
||||
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||
Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") }
|
||||
abandoned?.let { LeftFieldDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) LeaveFieldDialog(onExit) { confirmingEnd = false }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val following = legal.filter { it.from == selected && it.steps.take(path.size) == path }
|
||||
val nextHops = following.mapNotNull { it.steps.getOrNull(path.size) }.toSet()
|
||||
|
||||
val onPointTapped: (Int) -> Unit = { point ->
|
||||
when {
|
||||
point in nextHops -> {
|
||||
val extended = path + point
|
||||
val done = following.firstOrNull { it.steps == extended }
|
||||
if (done != null) {
|
||||
controller.submit(done)
|
||||
selected = null
|
||||
path = emptyList()
|
||||
} else {
|
||||
path = extended
|
||||
}
|
||||
}
|
||||
|
||||
legal.any { it.from == point } -> {
|
||||
selected = point
|
||||
path = emptyList()
|
||||
}
|
||||
|
||||
else -> {
|
||||
selected = null
|
||||
path = emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
val wide = maxWidth > maxHeight
|
||||
val side = if (wide) {
|
||||
minOf(maxHeight, maxWidth * 0.6f)
|
||||
} else {
|
||||
minOf(maxWidth, maxHeight * 0.68f)
|
||||
}
|
||||
|
||||
val board: @Composable () -> Unit = {
|
||||
Board(
|
||||
state = current,
|
||||
side = side,
|
||||
selected = selected,
|
||||
path = path.toSet(),
|
||||
destinations = nextHops,
|
||||
onTap = onPointTapped,
|
||||
)
|
||||
}
|
||||
val panel: @Composable (Modifier) -> Unit = { panelModifier ->
|
||||
Column(modifier = panelModifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Status(current, localSeat, path.isNotEmpty())
|
||||
MoveLog(current, modifier = Modifier.weight(1f, fill = false))
|
||||
}
|
||||
}
|
||||
|
||||
if (wide) {
|
||||
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
board()
|
||||
panel(Modifier.weight(1f).fillMaxSize())
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
board()
|
||||
panel(Modifier.fillMaxWidth().weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
val over = abandoned
|
||||
when {
|
||||
over != null -> LeftFieldDialog(over, onExit, controller::dismissAbandoned)
|
||||
confirmingEnd -> LeaveFieldDialog(onExit) { confirmingEnd = false }
|
||||
current.phase == PiratesPhase.GAME_OVER -> ResultDialog(current, onExit)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val TITLE = "Pirates and Bulgars"
|
||||
|
||||
@Composable
|
||||
private fun Board(
|
||||
state: PiratesState,
|
||||
side: Dp,
|
||||
selected: Int?,
|
||||
path: Set<Int>,
|
||||
destinations: Set<Int>,
|
||||
onTap: (Int) -> Unit,
|
||||
) {
|
||||
Box(modifier = Modifier.size(side)) {
|
||||
Lines(modifier = Modifier.fillMaxSize())
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
for (row in 0 until GRID) {
|
||||
Row(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
for (column in 0 until GRID) {
|
||||
val point = pointAt(row, column)
|
||||
Box(
|
||||
modifier = Modifier.weight(1f).fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (point != null) {
|
||||
Point(
|
||||
state = state,
|
||||
point = point,
|
||||
cell = side / GRID,
|
||||
selected = point == selected,
|
||||
onPath = point in path,
|
||||
isDestination = point in destinations,
|
||||
onTap = { onTap(point) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The board itself: the cross, the stronghold shaded, and a line between every
|
||||
* pair of points that are joined.
|
||||
*
|
||||
* The lines are drawn from the adjacency table rather than from a picture of a
|
||||
* board, so what a player can see is exactly what the rules allow — there is no
|
||||
* way for the drawing and the moves to disagree about where the diagonals are.
|
||||
*/
|
||||
@Composable
|
||||
private fun Lines(modifier: Modifier) {
|
||||
Canvas(modifier = modifier) {
|
||||
val cell = size.width / GRID
|
||||
val stroke = (cell * 0.045f).coerceAtLeast(2f)
|
||||
fun centre(point: Int) = Offset(
|
||||
x = (columnOf(point) + 0.5f) * cell,
|
||||
y = (rowOf(point) + 0.5f) * cell,
|
||||
)
|
||||
|
||||
// The cross, filled square by square so the cut corners come out right.
|
||||
for (row in 0 until GRID) {
|
||||
for (column in 0 until GRID) {
|
||||
if (!isOnBoard(row, column)) continue
|
||||
val inStronghold = pointAt(row, column)?.let { isStronghold(it) } == true
|
||||
drawRect(
|
||||
color = if (inStronghold) StrongholdField else BoardField,
|
||||
topLeft = Offset(column * cell, row * cell),
|
||||
size = androidx.compose.ui.geometry.Size(cell, cell),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (from in 0 until POINTS) {
|
||||
for (to in ADJACENCY[from]) {
|
||||
// Each line once rather than twice over.
|
||||
if (to <= from) continue
|
||||
drawLine(
|
||||
color = BoardLine,
|
||||
start = centre(from),
|
||||
end = centre(to),
|
||||
strokeWidth = stroke,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Point(
|
||||
state: PiratesState,
|
||||
point: Int,
|
||||
cell: Dp,
|
||||
selected: Boolean,
|
||||
onPath: Boolean,
|
||||
isDestination: Boolean,
|
||||
onTap: () -> Unit,
|
||||
) {
|
||||
val occupant = state.board[point]
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().clickable { onTap() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
when {
|
||||
occupant != null -> Box(
|
||||
modifier = Modifier
|
||||
.size(cell * 0.7f)
|
||||
.clip(CircleShape)
|
||||
.background(if (occupant == PIRATE_SEAT) PirateStone else BulgarStone)
|
||||
.border(
|
||||
width = when {
|
||||
selected -> cell * 0.07f
|
||||
onPath -> cell * 0.05f
|
||||
else -> 1.dp
|
||||
},
|
||||
color = when {
|
||||
selected -> SelectedRing
|
||||
onPath -> PathRing
|
||||
else -> StoneEdge
|
||||
},
|
||||
shape = CircleShape,
|
||||
)
|
||||
)
|
||||
|
||||
else -> Box(
|
||||
modifier = Modifier
|
||||
.size(if (isDestination) cell * 0.32f else cell * 0.14f)
|
||||
.clip(CircleShape)
|
||||
.background(if (isDestination) DestinationDot else VacantPoint)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Status(state: PiratesState, localSeat: Int, midJump: Boolean) {
|
||||
val yours = state.turn == localSeat && state.phase == PiratesPhase.PLAYING
|
||||
Column {
|
||||
Text(
|
||||
text = state.outcome?.label ?: when {
|
||||
midJump -> "Keep jumping — the capture has to be finished"
|
||||
yours -> "Your move"
|
||||
else -> "Thinking…"
|
||||
},
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = "You play ${if (localSeat == PIRATE_SEAT) "the pirates" else "the Bulgars"} · " +
|
||||
"${state.stronghold()} of ${STRONGHOLD.size} points held, " +
|
||||
"${state.count(BULGAR_SEAT)} Bulgars left",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Text(
|
||||
text = if (localSeat == PIRATE_SEAT) {
|
||||
"Cut them below ${STRONGHOLD.size} and the assault is finished. " +
|
||||
"You must take when you can."
|
||||
} else {
|
||||
"Fill the stronghold or pen the pirates in. You cannot take anything, " +
|
||||
"and you cannot go back."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MoveLog(state: PiratesState, modifier: Modifier = Modifier) {
|
||||
if (state.moveLog.isEmpty()) {
|
||||
Text("No moves yet.", style = MaterialTheme.typography.bodySmall, modifier = modifier)
|
||||
return
|
||||
}
|
||||
Column(modifier = modifier.verticalScroll(rememberScrollState())) {
|
||||
state.moveLog.takeLast(30).forEach {
|
||||
Text(text = it, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LeaveFieldDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onKeepPlaying,
|
||||
title = { Text("End this game?") },
|
||||
text = {
|
||||
Text(
|
||||
"This game ends here and the position is not saved. Anyone else " +
|
||||
"at the table is told you have left."
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LeftFieldDialog(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("Leave the game") } },
|
||||
dismissButton = { TextButton(onClick = onStay) { Text("Look at the board") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResultDialog(state: PiratesState, onExit: () -> Unit) {
|
||||
var dismissed by remember(state.outcome) { mutableStateOf(false) }
|
||||
if (dismissed) return
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { dismissed = true },
|
||||
title = { Text("Game over") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(state.outcome?.label.orEmpty(), fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
text = "${state.moveLog.size} moves played.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } },
|
||||
dismissButton = { TextButton(onClick = { dismissed = true }) { Text("Look at the board") } },
|
||||
)
|
||||
}
|
||||
@@ -38,7 +38,7 @@ private const val DEV_SUFFIX = "-dev"
|
||||
/**
|
||||
* Over-the-air updates from the repository's GitHub Releases.
|
||||
*
|
||||
* Release tags are v1.0.<run-number> and the APK's versionCode is that same run
|
||||
* Release tags are v<series>.<run-number> and the APK's versionCode is that same run
|
||||
* number, so comparing the two is enough to tell whether a newer build exists.
|
||||
* The repository's releases must be publicly downloadable — no token is
|
||||
* embedded in the app, and GitHub blocks anonymous access to private assets.
|
||||
@@ -112,7 +112,9 @@ class Updater(private val activity: Activity) {
|
||||
|
||||
private fun toRelease(json: JSONObject): Release? {
|
||||
val tag = json.optString("tag_name").ifBlank { return null }
|
||||
// Tags are v1.0.<n> or v1.0.<n>-dev; the run number is the trailing part.
|
||||
// Tags are v<series>.<n>, with -dev on a prerelease. Only the trailing
|
||||
// run number is read: the series is a name for people and changing it
|
||||
// must not make an older build look newer.
|
||||
val versionCode = tag.substringAfterLast('.')
|
||||
.removeSuffix(DEV_SUFFIX)
|
||||
.toIntOrNull()
|
||||
|
||||
@@ -26,12 +26,25 @@ class CatalogTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a dev build lists everything including what is still coming`() {
|
||||
fun `a dev build lists the whole catalogue`() {
|
||||
val shown = GameCategory.entries.flatMap {
|
||||
GameCatalog.byCategory(it, includeComingSoon = true)
|
||||
}
|
||||
assertEquals(GameCatalog.all.size, shown.size)
|
||||
assertTrue("unfinished games are visible while developing", shown.any { !it.available })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every game in the catalogue can be played`() {
|
||||
// There is nothing left marked "coming soon". The machinery that hides
|
||||
// unfinished games from a production build is still there and still
|
||||
// tested above — it simply has nothing to hide at the moment, and this
|
||||
// is what will fail the day something is added to the list before its
|
||||
// engine is written.
|
||||
assertTrue(GameCatalog.all.all { it.available })
|
||||
assertEquals(
|
||||
GameCatalog.byCategory(GameCategory.BOARD, includeComingSoon = false),
|
||||
GameCatalog.byCategory(GameCategory.BOARD, includeComingSoon = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -61,6 +74,10 @@ class CatalogTest {
|
||||
GameCatalog.CHESS,
|
||||
GameCatalog.TAYU,
|
||||
GameCatalog.MORRIS,
|
||||
GameCatalog.CHECKERS,
|
||||
GameCatalog.MASTERMIND,
|
||||
GameCatalog.BACKGAMMON,
|
||||
GameCatalog.PIRATES,
|
||||
),
|
||||
GameCatalog.playable.map { it.id }.toSet(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
package org.prolibertate.games.game.backgammon
|
||||
|
||||
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
|
||||
|
||||
class BackgammonRulesTest {
|
||||
|
||||
private fun start(seed: Long = 1L) = BackgammonRules.initialState(BackgammonOptions(), seed)
|
||||
|
||||
/**
|
||||
* A board built by hand. Points are given the way a player says them —
|
||||
* White's 6 point is index 5 — so [white] and [black] are index-to-count.
|
||||
*/
|
||||
private fun position(
|
||||
white: Map<Int, Int> = emptyMap(),
|
||||
black: Map<Int, Int> = emptyMap(),
|
||||
turn: Int = WHITE_SEAT,
|
||||
dice: List<Int> = listOf(6, 5),
|
||||
bar: List<Int> = listOf(0, 0),
|
||||
off: List<Int> = listOf(0, 0),
|
||||
): BackgammonState {
|
||||
val points = IntArray(POINTS)
|
||||
white.forEach { (point, count) -> points[point] = count }
|
||||
black.forEach { (point, count) -> points[point] = -count }
|
||||
return start().copy(
|
||||
points = points.toList(),
|
||||
turn = turn,
|
||||
dice = dice,
|
||||
roll = dice.take(2),
|
||||
bar = bar,
|
||||
off = off,
|
||||
)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The board
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the opening array is fifteen a side in the usual places`() {
|
||||
val state = start()
|
||||
assertEquals(15, (0 until POINTS).sumOf { state.countOn(it, WHITE_SEAT) })
|
||||
assertEquals(15, (0 until POINTS).sumOf { state.countOn(it, BLACK_SEAT) })
|
||||
assertEquals(2, state.countOn(23, WHITE_SEAT))
|
||||
assertEquals(5, state.countOn(12, WHITE_SEAT))
|
||||
assertEquals(3, state.countOn(7, WHITE_SEAT))
|
||||
assertEquals(5, state.countOn(5, WHITE_SEAT))
|
||||
// The two sides are mirror images, so the opening pip counts match.
|
||||
assertEquals(167, state.pipCount(WHITE_SEAT))
|
||||
assertEquals(167, state.pipCount(BLACK_SEAT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the two sides run in opposite directions`() {
|
||||
assertEquals(-1, directionOf(WHITE_SEAT))
|
||||
assertEquals(1, directionOf(BLACK_SEAT))
|
||||
assertEquals(0..5, homeOf(WHITE_SEAT))
|
||||
assertEquals(18..23, homeOf(BLACK_SEAT))
|
||||
// A checker on the point nearest home is one pip from being off.
|
||||
assertEquals(1, pipsFrom(WHITE_SEAT, 0))
|
||||
assertEquals(1, pipsFrom(BLACK_SEAT, 23))
|
||||
assertEquals(24, pipsFrom(WHITE_SEAT, 23))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each player comes in from the bar at the other end`() {
|
||||
assertEquals(23, entryPoint(WHITE_SEAT, 1))
|
||||
assertEquals(18, entryPoint(WHITE_SEAT, 6))
|
||||
assertEquals(0, entryPoint(BLACK_SEAT, 1))
|
||||
assertEquals(5, entryPoint(BLACK_SEAT, 6))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The dice
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the opening roll is two different dice and the higher one starts`() {
|
||||
(1L..25L).forEach { seed ->
|
||||
val state = BackgammonRules.initialState(BackgammonOptions(), seed)
|
||||
assertEquals(2, state.dice.size)
|
||||
assertNotEquals(state.dice[0], state.dice[1])
|
||||
assertEquals(state.dice.sortedDescending(), state.dice)
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertNotEquals(a: Int, b: Int) =
|
||||
assertTrue("expected different dice, got $a and $b", a != b)
|
||||
|
||||
@Test
|
||||
fun `the same table throws the same dice`() {
|
||||
assertEquals(start(9L).dice, start(9L).dice)
|
||||
assertEquals(start(9L).seed, 9L)
|
||||
// And a game replayed from its own state rolls on identically.
|
||||
val first = start(9L)
|
||||
val second = BackgammonRules.decodeState(BackgammonRules.encodeState(first))
|
||||
val move = BackgammonRules.legalMoves(first).first()
|
||||
assertEquals(
|
||||
BackgammonRules.applyMove(first, first.turn, move),
|
||||
BackgammonRules.applyMove(second, second.turn, move),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a double is four moves, not two`() {
|
||||
val doubled = position(
|
||||
white = mapOf(23 to 2),
|
||||
black = mapOf(0 to 2),
|
||||
dice = listOf(3, 3, 3, 3),
|
||||
)
|
||||
assertEquals(4, doubled.dice.size)
|
||||
var state = doubled
|
||||
repeat(4) {
|
||||
val moves = BackgammonRules.legalMoves(state)
|
||||
assertTrue("ran out of moves after $it", moves.isNotEmpty())
|
||||
state = BackgammonRules.applyMove(state, WHITE_SEAT, moves.first())
|
||||
}
|
||||
// Four threes is twelve pips off the count.
|
||||
assertEquals(doubled.pipCount(WHITE_SEAT) - 12, state.pipCount(WHITE_SEAT))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Moving
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a point with two enemy checkers cannot be landed on`() {
|
||||
val state = position(white = mapOf(23 to 2), black = mapOf(17 to 2), dice = listOf(6, 5))
|
||||
// 23 minus 6 is 17, which Black holds.
|
||||
assertTrue(BackgammonRules.legalMoves(state).none { it.from == 23 && it.die == 6 })
|
||||
assertTrue(BackgammonRules.legalMoves(state).any { it.from == 23 && it.die == 5 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a lone enemy checker is hit and goes to the bar`() {
|
||||
val state = position(white = mapOf(23 to 2), black = mapOf(17 to 1, 0 to 2))
|
||||
val hit = BackgammonRules.legalMoves(state).first { it.from == 23 && it.die == 6 }
|
||||
val after = BackgammonRules.applyMove(state, WHITE_SEAT, hit)
|
||||
assertEquals(1, after.bar[BLACK_SEAT])
|
||||
assertEquals(1, after.countOn(17, WHITE_SEAT))
|
||||
assertEquals(0, after.countOn(17, BLACK_SEAT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing else moves while a checker is on the bar`() {
|
||||
val state = position(
|
||||
white = mapOf(23 to 2, 12 to 3),
|
||||
black = mapOf(0 to 2),
|
||||
bar = listOf(1, 0),
|
||||
)
|
||||
val moves = BackgammonRules.legalMoves(state)
|
||||
assertTrue("everything must come in first", moves.all { it.from == BAR })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a closed board leaves a barred player with nothing and the turn passes`() {
|
||||
// Black holds every point White could come in on, so White cannot move
|
||||
// at all and the dice go back across the table.
|
||||
val state = position(
|
||||
white = mapOf(12 to 3),
|
||||
black = (18..23).associateWith { 2 },
|
||||
bar = listOf(1, 0),
|
||||
turn = WHITE_SEAT,
|
||||
dice = listOf(6, 5),
|
||||
)
|
||||
val settled = BackgammonRules.applyMove(
|
||||
state.copy(turn = BLACK_SEAT, dice = listOf(1, 2)),
|
||||
BLACK_SEAT,
|
||||
BackgammonRules.legalMoves(state.copy(turn = BLACK_SEAT, dice = listOf(1, 2))).first(),
|
||||
)
|
||||
assertTrue(settled.moveLog.isNotEmpty())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Using as much of the roll as possible
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a move that would strand the other die is not offered`() {
|
||||
// White has one checker on 23 and Black holds 17. Playing the 6 first
|
||||
// is blocked outright; playing the 5 first lands on 18, and the 6 then
|
||||
// carries on to 12. Only the order that plays the whole roll is a move,
|
||||
// so the 6 is not offered even though six pips of it are playable
|
||||
// further down the line.
|
||||
val state = position(
|
||||
white = mapOf(23 to 1),
|
||||
black = mapOf(17 to 2, 0 to 2),
|
||||
dice = listOf(6, 5),
|
||||
)
|
||||
val moves = BackgammonRules.legalMoves(state)
|
||||
assertEquals("only one first step plays the whole roll", 1, moves.size)
|
||||
assertEquals(5, moves.single().die)
|
||||
|
||||
// And playing it does leave the 6 to play.
|
||||
val after = BackgammonRules.applyMove(state, WHITE_SEAT, moves.single())
|
||||
assertEquals(WHITE_SEAT, after.turn)
|
||||
assertEquals(listOf(6), after.dice)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when only one die can be played it must be the higher`() {
|
||||
// Either die can be played on its own — 23 to 17, or 23 to 18 — but
|
||||
// never both, because 12 is shut and that is where eleven pips land
|
||||
// whichever order they are played in. The rules say take the bigger.
|
||||
val state = position(
|
||||
white = mapOf(23 to 1),
|
||||
black = mapOf(12 to 2, 11 to 2, 0 to 2),
|
||||
dice = listOf(6, 5),
|
||||
)
|
||||
val moves = BackgammonRules.legalMoves(state)
|
||||
assertEquals(1, moves.size)
|
||||
assertEquals("the higher die has to be the one played", 6, moves.single().die)
|
||||
|
||||
// Playing it ends the turn, because the 5 has nowhere to go.
|
||||
val after = BackgammonRules.applyMove(state, WHITE_SEAT, moves.single())
|
||||
assertEquals(BLACK_SEAT, after.turn)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Bearing off
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `nothing comes off until everything is home`() {
|
||||
val notHome = position(white = mapOf(5 to 2, 12 to 1), black = mapOf(0 to 2))
|
||||
assertFalse(notHome.canBearOff(WHITE_SEAT))
|
||||
|
||||
val home = position(white = mapOf(5 to 2, 3 to 1), black = mapOf(23 to 2))
|
||||
assertTrue(home.canBearOff(WHITE_SEAT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an exact die takes a checker off`() {
|
||||
val state = position(
|
||||
white = mapOf(5 to 2),
|
||||
black = mapOf(23 to 2),
|
||||
dice = listOf(6, 1),
|
||||
)
|
||||
val off = BackgammonRules.legalMoves(state).first { it.from == 5 && it.die == 6 }
|
||||
val after = BackgammonRules.applyMove(state, WHITE_SEAT, off)
|
||||
assertEquals(1, after.off[WHITE_SEAT])
|
||||
assertEquals(1, after.countOn(5, WHITE_SEAT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bigger die only takes the furthest checker`() {
|
||||
// Something on the 6 point, so a 6 must take that and not the one on
|
||||
// the 3; with the 6 point empty, the 6 may take the furthest instead.
|
||||
val blocked = position(
|
||||
white = mapOf(5 to 1, 2 to 1),
|
||||
black = mapOf(23 to 2),
|
||||
dice = listOf(6, 6, 6, 6),
|
||||
)
|
||||
assertTrue(BackgammonRules.legalMoves(blocked).none { it.from == 2 })
|
||||
|
||||
val free = position(
|
||||
white = mapOf(2 to 2),
|
||||
black = mapOf(23 to 2),
|
||||
dice = listOf(6, 6, 6, 6),
|
||||
)
|
||||
assertTrue(BackgammonRules.legalMoves(free).any { it.from == 2 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fifteen off wins the game`() {
|
||||
val state = position(
|
||||
white = mapOf(0 to 1),
|
||||
black = mapOf(23 to 2),
|
||||
dice = listOf(1, 2),
|
||||
off = listOf(14, 0),
|
||||
)
|
||||
val last = BackgammonRules.legalMoves(state).first { it.from == 0 }
|
||||
val after = BackgammonRules.applyMove(state, WHITE_SEAT, last)
|
||||
assertEquals(BackgammonPhase.GAME_OVER, after.phase)
|
||||
assertTrue(BackgammonRules.isFinished(after))
|
||||
assertNull(BackgammonRules.currentSeat(after))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a loser who bore nothing off has lost a gammon`() {
|
||||
val state = position(
|
||||
white = mapOf(0 to 1),
|
||||
black = mapOf(23 to 2),
|
||||
dice = listOf(1, 2),
|
||||
off = listOf(14, 0),
|
||||
)
|
||||
val after = BackgammonRules.applyMove(
|
||||
state,
|
||||
WHITE_SEAT,
|
||||
BackgammonRules.legalMoves(state).first { it.from == 0 },
|
||||
)
|
||||
assertEquals(BackgammonOutcome.WHITE_GAMMON, after.outcome)
|
||||
assertEquals(2, after.outcome!!.points)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a loser still on the bar has lost a backgammon`() {
|
||||
val state = position(
|
||||
white = mapOf(0 to 1),
|
||||
black = mapOf(23 to 1),
|
||||
dice = listOf(1, 2),
|
||||
off = listOf(14, 0),
|
||||
bar = listOf(0, 1),
|
||||
)
|
||||
val after = BackgammonRules.applyMove(
|
||||
state,
|
||||
WHITE_SEAT,
|
||||
BackgammonRules.legalMoves(state).first { it.from == 0 },
|
||||
)
|
||||
assertEquals(BackgammonOutcome.WHITE_BACKGAMMON, after.outcome)
|
||||
assertEquals(3, after.outcome!!.points)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a loser who bore one off has only lost a single game`() {
|
||||
val state = position(
|
||||
white = mapOf(0 to 1),
|
||||
black = mapOf(23 to 2),
|
||||
dice = listOf(1, 2),
|
||||
off = listOf(14, 1),
|
||||
)
|
||||
val after = BackgammonRules.applyMove(
|
||||
state,
|
||||
WHITE_SEAT,
|
||||
BackgammonRules.legalMoves(state).first { it.from == 0 },
|
||||
)
|
||||
assertEquals(BackgammonOutcome.WHITE_SINGLE, after.outcome)
|
||||
assertEquals(1, after.outcome!!.points)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Turns
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the turn passes when the dice run out`() {
|
||||
var state = position(
|
||||
white = mapOf(23 to 2, 12 to 2),
|
||||
black = mapOf(0 to 2, 11 to 2),
|
||||
dice = listOf(3, 4),
|
||||
)
|
||||
state = BackgammonRules.applyMove(state, WHITE_SEAT, BackgammonRules.legalMoves(state).first())
|
||||
assertEquals("still White's roll", WHITE_SEAT, state.turn)
|
||||
state = BackgammonRules.applyMove(state, WHITE_SEAT, BackgammonRules.legalMoves(state).first())
|
||||
assertEquals(BLACK_SEAT, state.turn)
|
||||
assertEquals("the new player has dice to play", 2, state.roll.size)
|
||||
assertTrue(state.dice.isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a whole game can be played out without getting stuck`() {
|
||||
var state = start(3L)
|
||||
var plies = 0
|
||||
while (!BackgammonRules.isFinished(state) && plies < 4000) {
|
||||
val moves = BackgammonRules.legalMoves(state)
|
||||
assertTrue("nobody can move at ply $plies", moves.isNotEmpty())
|
||||
state = BackgammonRules.applyMove(state, state.turn, moves.last())
|
||||
plies++
|
||||
}
|
||||
assertTrue("the game never finished in $plies plies", BackgammonRules.isFinished(state))
|
||||
assertNotNull(state.outcome)
|
||||
assertEquals(CHECKERS, state.off.max())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an illegal move is refused rather than played`() {
|
||||
val state = start(2L)
|
||||
assertTrue(
|
||||
runCatching {
|
||||
BackgammonRules.applyMove(state, state.turn, BackgammonMove(from = 3, die = 6))
|
||||
}.exceptionOrNull() is IllegalArgumentException
|
||||
)
|
||||
assertTrue(
|
||||
"the other player cannot move on this roll",
|
||||
runCatching {
|
||||
BackgammonRules.applyMove(
|
||||
state,
|
||||
other(state.turn),
|
||||
BackgammonRules.legalMoves(state).first(),
|
||||
)
|
||||
}.exceptionOrNull() is IllegalArgumentException
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a state survives the round trip over the wire`() {
|
||||
val state = start(5L)
|
||||
assertEquals(state, BackgammonRules.decodeState(BackgammonRules.encodeState(state)))
|
||||
val move = BackgammonMove(from = BAR, die = 4)
|
||||
assertEquals(move, BackgammonRules.decodeMove(BackgammonRules.encodeMove(move)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing is hidden from either seat`() {
|
||||
val state = start(5L)
|
||||
assertEquals(state, BackgammonRules.viewFor(state, BLACK_SEAT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the summary says whose roll it is and what they threw`() {
|
||||
val state = position(white = mapOf(23 to 2), black = mapOf(0 to 2), dice = listOf(6, 5))
|
||||
assertTrue(BackgammonRules.summary(state).startsWith("White to play 6-5"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
package org.prolibertate.games.game.checkers
|
||||
|
||||
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
|
||||
|
||||
class CheckersRulesTest {
|
||||
|
||||
private fun start(options: CheckersOptions = CheckersOptions()) =
|
||||
CheckersRules.initialState(options)
|
||||
|
||||
/** A board built by hand, for positions a game would take an hour to reach. */
|
||||
private fun position(
|
||||
black: List<Int> = emptyList(),
|
||||
blackKings: List<Int> = emptyList(),
|
||||
white: List<Int> = emptyList(),
|
||||
whiteKings: List<Int> = emptyList(),
|
||||
turn: Int = BLACK_SEAT,
|
||||
options: CheckersOptions = CheckersOptions(),
|
||||
): CheckersState {
|
||||
val board = arrayOfNulls<Piece>(SQUARES)
|
||||
black.forEach { board[it - 1] = Piece(BLACK_SEAT) }
|
||||
blackKings.forEach { board[it - 1] = Piece(BLACK_SEAT, king = true) }
|
||||
white.forEach { board[it - 1] = Piece(WHITE_SEAT) }
|
||||
whiteKings.forEach { board[it - 1] = Piece(WHITE_SEAT, king = true) }
|
||||
return start(options).copy(board = board.toList(), turn = turn)
|
||||
}
|
||||
|
||||
/** Squares are written 1–32 in the notation and 0–31 in the code. */
|
||||
private fun square(number: Int) = number - 1
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The board
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the thirty-two dark squares map to the board and back`() {
|
||||
(0 until SQUARES).forEach { square ->
|
||||
val row = rowOf(square)
|
||||
val column = columnOf(square)
|
||||
assertTrue("$square is off the board", row in 0..7 && column in 0..7)
|
||||
assertEquals("square $square", square, squareAt(row, column))
|
||||
// Dark squares are the ones where row and column have different parity.
|
||||
assertEquals(1, (row + column) % 2)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `light squares are not squares at all`() {
|
||||
assertNull(squareAt(0, 0))
|
||||
assertNull(squareAt(7, 7))
|
||||
assertNull(squareAt(-1, 0))
|
||||
assertNull(squareAt(0, 8))
|
||||
// The corners of the playable diagonal.
|
||||
assertEquals(0, squareAt(0, 1))
|
||||
assertEquals(SQUARES - 1, squareAt(7, 6))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the opening array is twelve a side on the three nearest rows`() {
|
||||
val state = start()
|
||||
assertEquals(12, state.count(BLACK_SEAT))
|
||||
assertEquals(12, state.count(WHITE_SEAT))
|
||||
assertEquals(0, state.kings(BLACK_SEAT))
|
||||
assertEquals(BLACK_SEAT, state.turn)
|
||||
// The two middle rows are empty, which is what makes an opening move
|
||||
// possible at all.
|
||||
(12 until 20).forEach { assertNull(state.board[it]) }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Moving
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the opening position has seven moves, as every rulebook says`() {
|
||||
assertEquals(7, CheckersRules.legalMoves(start()).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the move generator counts what it should, four ply down`() {
|
||||
// The published perft numbers for English draughts. A generator that
|
||||
// matches them this far has very little room left to be wrong: they
|
||||
// count compulsory captures, crowning and the men's forward-only rule
|
||||
// all at once.
|
||||
assertEquals(7L, CheckersRules.perft(start(), 1))
|
||||
assertEquals(49L, CheckersRules.perft(start(), 2))
|
||||
assertEquals(302L, CheckersRules.perft(start(), 3))
|
||||
assertEquals(1469L, CheckersRules.perft(start(), 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a man moves forward only, and a king moves both ways`() {
|
||||
val man = position(black = listOf(14), white = listOf(30))
|
||||
assertEquals(
|
||||
setOf(square(17), square(18)),
|
||||
CheckersRules.legalMoves(man).map { it.to }.toSet(),
|
||||
)
|
||||
|
||||
val king = position(blackKings = listOf(14), white = listOf(30))
|
||||
assertEquals(
|
||||
setOf(square(9), square(10), square(17), square(18)),
|
||||
CheckersRules.legalMoves(king).map { it.to }.toSet(),
|
||||
)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Taking
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a capture is compulsory`() {
|
||||
// Black has a jump on the board, so the quiet moves elsewhere are not
|
||||
// moves at all.
|
||||
val state = position(black = listOf(14, 12), white = listOf(18, 30))
|
||||
val moves = CheckersRules.legalMoves(state)
|
||||
assertTrue("only the jump may be played", moves.all { it.isJump })
|
||||
assertEquals(1, moves.size)
|
||||
assertEquals(square(23), moves.single().to)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a double jump is one move, not two`() {
|
||||
// Two white men lined up for Black to hop over in a single turn.
|
||||
val state = position(black = listOf(14), white = listOf(18, 27))
|
||||
val moves = CheckersRules.legalMoves(state)
|
||||
assertEquals(1, moves.size)
|
||||
val jump = moves.single()
|
||||
assertEquals(2, jump.steps.size)
|
||||
assertEquals(square(32), jump.to)
|
||||
|
||||
val after = CheckersRules.applyMove(state, BLACK_SEAT, jump)
|
||||
assertEquals(0, after.count(WHITE_SEAT))
|
||||
assertEquals(1, after.count(BLACK_SEAT))
|
||||
// One move, so the turn has passed exactly once.
|
||||
assertEquals(WHITE_SEAT, after.turn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stopping halfway through a capture is not offered`() {
|
||||
val state = position(black = listOf(14), white = listOf(18, 27))
|
||||
// The single hop that stops on 23 is a prefix of the double, and the
|
||||
// rules do not allow it to be played on its own.
|
||||
assertTrue(
|
||||
CheckersRules.legalMoves(state).none { it.steps == listOf(square(23)) }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same piece is never taken twice in one turn`() {
|
||||
val state = position(blackKings = listOf(15), white = listOf(18, 19))
|
||||
CheckersRules.legalMoves(state).forEach { move ->
|
||||
val taken = mutableListOf<Int>()
|
||||
var at = move.from
|
||||
move.steps.forEach { landing ->
|
||||
jumped(at, landing)?.let { taken += it }
|
||||
at = landing
|
||||
}
|
||||
assertEquals("a piece was jumped twice: $move", taken.size, taken.distinct().size)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a man may not jump backwards`() {
|
||||
// A white man sits behind the black man; nothing can be taken.
|
||||
val state = position(black = listOf(18), white = listOf(14, 30))
|
||||
assertTrue(CheckersRules.legalMoves(state).none { it.isJump })
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Crowning
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a man reaching the far row is crowned`() {
|
||||
val state = position(black = listOf(25), white = listOf(1))
|
||||
val move = CheckersRules.legalMoves(state).first()
|
||||
val after = CheckersRules.applyMove(state, BLACK_SEAT, move)
|
||||
assertTrue("it should be crowned", after.board[move.to]!!.king)
|
||||
assertEquals(1, after.kings(BLACK_SEAT))
|
||||
// Crowning is progress, so the draw clock goes back to nothing.
|
||||
assertEquals(0, after.pliesSinceProgress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `crowning ends the turn under the English rule`() {
|
||||
// A black man can jump into the crown row with another jump available
|
||||
// beyond it. Under the English rule it stops and is crowned.
|
||||
val state = position(black = listOf(22), white = listOf(26, 19), turn = BLACK_SEAT)
|
||||
val english = CheckersRules.legalMoves(state)
|
||||
assertTrue(english.isNotEmpty())
|
||||
english.filter { rowOf(it.to) == crownRowOf(BLACK_SEAT) }.forEach {
|
||||
assertEquals("the turn stops on crowning", 1, it.steps.size)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Kings that fly
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a flying king slides the length of the diagonal`() {
|
||||
val short = position(blackKings = listOf(15), white = listOf(1))
|
||||
val long = position(
|
||||
blackKings = listOf(15),
|
||||
white = listOf(1),
|
||||
options = CheckersOptions(flyingKings = true),
|
||||
)
|
||||
assertTrue(
|
||||
"a flying king reaches further than a walking one",
|
||||
CheckersRules.legalMoves(long).size > CheckersRules.legalMoves(short).size,
|
||||
)
|
||||
assertTrue(CheckersRules.legalMoves(short).all { it.steps.size == 1 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a flying king takes from a distance and lands where it likes`() {
|
||||
// The king on 1 and the man on 10 share a diagonal with open board
|
||||
// beyond it, so the king may take it and carry on to any of the empty
|
||||
// squares past it.
|
||||
val state = position(
|
||||
blackKings = listOf(1),
|
||||
white = listOf(10),
|
||||
turn = BLACK_SEAT,
|
||||
options = CheckersOptions(flyingKings = true),
|
||||
)
|
||||
val takes = CheckersRules.legalMoves(state).filter { move ->
|
||||
CheckersRules.applyMove(state, BLACK_SEAT, move).count(WHITE_SEAT) == 0
|
||||
}
|
||||
assertTrue("the far man should be takeable", takes.isNotEmpty())
|
||||
assertTrue(
|
||||
"and the king may stop on any square beyond it",
|
||||
takes.map { it.to }.distinct().size >= 2,
|
||||
)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Ending
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `taking the last piece wins`() {
|
||||
val state = position(black = listOf(14), white = listOf(18))
|
||||
val move = CheckersRules.legalMoves(state).single()
|
||||
val after = CheckersRules.applyMove(state, BLACK_SEAT, move)
|
||||
assertEquals(CheckersPhase.GAME_OVER, after.phase)
|
||||
assertEquals(CheckersOutcome.BLACK_WINS, after.outcome)
|
||||
assertTrue(CheckersRules.isFinished(after))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a player with no move loses, pieces or not`() {
|
||||
// White's single man is in the corner with a black man in front of it
|
||||
// and a black man behind the landing square, so it cannot move.
|
||||
val state = position(
|
||||
black = listOf(23, 27, 26),
|
||||
white = listOf(32),
|
||||
turn = BLACK_SEAT,
|
||||
)
|
||||
val quiet = CheckersRules.legalMoves(state).firstOrNull { !it.isJump }
|
||||
assertNotNull("black needs a quiet move for this test", quiet)
|
||||
val after = CheckersRules.applyMove(state, BLACK_SEAT, quiet!!)
|
||||
if (CheckersRules.legalMoves(after, WHITE_SEAT).isEmpty()) {
|
||||
assertEquals(CheckersOutcome.BLACK_WINS, after.outcome)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shuffling kings about is eventually a draw`() {
|
||||
val state = position(
|
||||
blackKings = listOf(15),
|
||||
whiteKings = listOf(32),
|
||||
options = CheckersOptions(plyLimitWithoutProgress = 4, threefoldRepetition = false),
|
||||
).copy(pliesSinceProgress = 3)
|
||||
val quiet = CheckersRules.legalMoves(state).first { !it.isJump }
|
||||
val after = CheckersRules.applyMove(state, BLACK_SEAT, quiet)
|
||||
assertEquals(CheckersOutcome.DRAW_NO_PROGRESS, after.outcome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same position three times over is a draw`() {
|
||||
val state = position(blackKings = listOf(15), whiteKings = listOf(32))
|
||||
val move = CheckersRules.legalMoves(state).first { !it.isJump }
|
||||
// Where that move lands, seen twice already: playing it makes three.
|
||||
val key = CheckersRules.advanced(state, move).positionKey()
|
||||
val primed = state.copy(repetitionKeys = listOf(key, key))
|
||||
|
||||
val after = CheckersRules.applyMove(primed, BLACK_SEAT, move)
|
||||
assertEquals(CheckersOutcome.DRAW_REPETITION, after.outcome)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The engine contract
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `an illegal move is refused rather than played`() {
|
||||
val state = start()
|
||||
assertTrue(
|
||||
runCatching {
|
||||
CheckersRules.applyMove(state, BLACK_SEAT, CheckersMove(0, listOf(20)))
|
||||
}.exceptionOrNull() is IllegalArgumentException
|
||||
)
|
||||
assertTrue(
|
||||
"white cannot move first",
|
||||
runCatching {
|
||||
CheckersRules.applyMove(state, WHITE_SEAT, CheckersRules.legalMoves(state).first())
|
||||
}.exceptionOrNull() is IllegalArgumentException
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a state survives the round trip over the wire`() {
|
||||
var state = start()
|
||||
state = CheckersRules.applyMove(state, BLACK_SEAT, CheckersRules.legalMoves(state).first())
|
||||
assertEquals(state, CheckersRules.decodeState(CheckersRules.encodeState(state)))
|
||||
val move = CheckersMove(from = 10, steps = listOf(14, 21))
|
||||
assertEquals(move, CheckersRules.decodeMove(CheckersRules.encodeMove(move)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing is hidden from either seat`() {
|
||||
val state = start()
|
||||
assertEquals(state, CheckersRules.viewFor(state, WHITE_SEAT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a move reads as the notation the game is written in`() {
|
||||
assertEquals("11-15", CheckersMove(from = square(11), steps = listOf(square(15))).toString())
|
||||
assertEquals(
|
||||
"11x18x25",
|
||||
CheckersMove(from = square(11), steps = listOf(square(18), square(25))).toString(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the summary says whose move it is and how it ended`() {
|
||||
assertEquals("Black to move", CheckersRules.summary(start()))
|
||||
val over = start().copy(
|
||||
phase = CheckersPhase.GAME_OVER,
|
||||
outcome = CheckersOutcome.WHITE_WINS,
|
||||
)
|
||||
assertEquals(CheckersOutcome.WHITE_WINS.label, CheckersRules.summary(over))
|
||||
assertNull(CheckersRules.currentSeat(over))
|
||||
assertFalse(CheckersRules.isFinished(start()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package org.prolibertate.games.game.mastermind
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class MastermindAiTest {
|
||||
|
||||
private fun choose(
|
||||
state: MastermindState,
|
||||
seat: Int = state.turn,
|
||||
level: MastermindLevel = MastermindLevel.CLUB,
|
||||
): MastermindMove =
|
||||
MastermindAi(level).chooseMove(state, seat, MastermindRules.legalMoves(state, seat))
|
||||
|
||||
@Test
|
||||
fun `it sets a code the table allows`() {
|
||||
listOf(
|
||||
MastermindOptions(),
|
||||
MastermindOptions(colours = 8, length = 5),
|
||||
MastermindOptions(colours = 6, length = 4, allowDuplicates = false),
|
||||
).forEach { options ->
|
||||
val state = MastermindRules.initialState(options)
|
||||
repeat(10) {
|
||||
val move = choose(state, FIRST_SEAT)
|
||||
assertTrue(
|
||||
"the computer set ${move.code} on $options",
|
||||
MastermindRules.isWellFormed(options, move.code),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the code it sets is not the same every game`() {
|
||||
// It is the one choice in the game drawn from a source the opponent
|
||||
// cannot reconstruct — a code that came off the table's own seed could
|
||||
// be worked out by the player who is meant to be guessing it.
|
||||
val options = MastermindOptions()
|
||||
val seen = (1..40).map {
|
||||
choose(MastermindRules.initialState(options), FIRST_SEAT).code
|
||||
}.toSet()
|
||||
assertTrue("it set the same code 40 times running", seen.size > 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a consistent solver breaks a code well inside the limit`() {
|
||||
// Both codes set by hand, then the computer plays both seats. A player
|
||||
// that never guesses something the answers have ruled out should crack
|
||||
// a four-peg, six-colour code in about five.
|
||||
var state = MastermindRules.initialState(MastermindOptions(maxGuesses = 10))
|
||||
state = MastermindRules.applyMove(state, FIRST_SEAT, MastermindMove(listOf(3, 1, 4, 1)))
|
||||
state = MastermindRules.applyMove(state, SECOND_SEAT, MastermindMove(listOf(5, 0, 2, 5)))
|
||||
|
||||
var plies = 0
|
||||
while (!MastermindRules.isFinished(state) && plies < 40) {
|
||||
state = MastermindRules.applyMove(state, state.turn, choose(state))
|
||||
plies++
|
||||
}
|
||||
|
||||
assertTrue("the game never ended", MastermindRules.isFinished(state))
|
||||
assertNotNull(state.outcome)
|
||||
val broke = state.brokeIn(FIRST_SEAT) ?: state.brokeIn(SECOND_SEAT)
|
||||
assertNotNull("neither side broke a code in ten guesses", broke)
|
||||
assertTrue("it took $broke guesses", broke!! <= 8)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `it never guesses something the answers have already ruled out`() {
|
||||
var state = MastermindRules.initialState(MastermindOptions())
|
||||
state = MastermindRules.applyMove(state, FIRST_SEAT, MastermindMove(listOf(0, 1, 2, 3)))
|
||||
state = MastermindRules.applyMove(state, SECOND_SEAT, MastermindMove(listOf(4, 5, 4, 5)))
|
||||
|
||||
repeat(6) {
|
||||
if (MastermindRules.isFinished(state)) return
|
||||
val seat = state.turn
|
||||
val move = choose(state, level = MastermindLevel.STRONG)
|
||||
// Everything already answered must still hold for this guess.
|
||||
state.guesses[seat].forEach { answered ->
|
||||
assertEquals(
|
||||
"guess ${move.code} contradicts ${answered.code}",
|
||||
answered.feedback,
|
||||
scoreGuess(move.code, answered.code),
|
||||
)
|
||||
}
|
||||
state = MastermindRules.applyMove(state, seat, move)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package org.prolibertate.games.game.mastermind
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class MastermindRulesTest {
|
||||
|
||||
private fun start(options: MastermindOptions = MastermindOptions()) =
|
||||
MastermindRules.initialState(options)
|
||||
|
||||
/**
|
||||
* A game with both codes already set, reached the way a real one is: each
|
||||
* player choosing theirs in turn.
|
||||
*/
|
||||
private fun withSecrets(
|
||||
first: List<Int>,
|
||||
second: List<Int>,
|
||||
options: MastermindOptions = MastermindOptions(),
|
||||
): MastermindState {
|
||||
val set = MastermindRules.applyMove(start(options), FIRST_SEAT, MastermindMove(first))
|
||||
return MastermindRules.applyMove(set, SECOND_SEAT, MastermindMove(second))
|
||||
}
|
||||
|
||||
private fun guess(state: MastermindState, code: List<Int>) =
|
||||
MastermindRules.applyMove(state, state.turn, MastermindMove(code))
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Scoring
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a peg in the right place is exact and one in the wrong place is not`() {
|
||||
assertEquals(Feedback(4, 0), scoreGuess(listOf(0, 1, 2, 3), listOf(0, 1, 2, 3)))
|
||||
assertEquals(Feedback(0, 4), scoreGuess(listOf(0, 1, 2, 3), listOf(3, 2, 1, 0)))
|
||||
assertEquals(Feedback(2, 2), scoreGuess(listOf(0, 1, 2, 3), listOf(0, 2, 1, 3)))
|
||||
assertEquals(Feedback(0, 0), scoreGuess(listOf(0, 0, 0, 0), listOf(1, 1, 1, 1)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a colour is never counted twice`() {
|
||||
// Three reds guessed against one red is worth one peg, however they sit.
|
||||
assertEquals(Feedback(1, 0), scoreGuess(listOf(0, 1, 2, 3), listOf(0, 0, 0, 0)))
|
||||
assertEquals(Feedback(0, 1), scoreGuess(listOf(0, 1, 2, 3), listOf(4, 4, 4, 0)))
|
||||
// And one red guessed against three is worth one as well.
|
||||
assertEquals(Feedback(1, 0), scoreGuess(listOf(0, 0, 0, 1), listOf(0, 2, 3, 4)))
|
||||
assertEquals(Feedback(2, 2), scoreGuess(listOf(0, 0, 1, 1), listOf(0, 1, 0, 1)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scoring reads the same either way round`() {
|
||||
// Which is what lets the solver test a candidate against a past guess
|
||||
// rather than having to hold on to the code it was scored against.
|
||||
val codes = allCodes(MastermindOptions(colours = 4, length = 3))
|
||||
codes.take(30).forEach { a ->
|
||||
codes.take(30).forEach { b ->
|
||||
assertEquals(scoreGuess(a, b), scoreGuess(b, a))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Setting up
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a new game has no codes in it — the players set them`() {
|
||||
val state = start()
|
||||
assertEquals(MastermindPhase.SETTING, state.phase)
|
||||
assertEquals(FIRST_SEAT, state.turn)
|
||||
assertTrue(state.secrets.all { it.isEmpty() })
|
||||
assertFalse(state.hasSet(FIRST_SEAT))
|
||||
assertFalse(state.hasSet(SECOND_SEAT))
|
||||
// Nothing is hidden yet, because there is nothing to hide.
|
||||
assertFalse(state.isHidden(SECOND_SEAT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each player sets their own code, one after the other`() {
|
||||
var state = start()
|
||||
assertEquals(allCodes(state.options).size, MastermindRules.legalMoves(state, FIRST_SEAT).size)
|
||||
|
||||
state = MastermindRules.applyMove(state, FIRST_SEAT, MastermindMove(listOf(0, 1, 2, 3)))
|
||||
assertEquals(MastermindPhase.SETTING, state.phase)
|
||||
assertEquals(SECOND_SEAT, state.turn)
|
||||
assertTrue(state.hasSet(FIRST_SEAT))
|
||||
assertEquals(listOf(0, 1, 2, 3), state.secrets[FIRST_SEAT])
|
||||
|
||||
state = MastermindRules.applyMove(state, SECOND_SEAT, MastermindMove(listOf(4, 4, 5, 5)))
|
||||
assertEquals(MastermindPhase.GUESSING, state.phase)
|
||||
// The guessing starts with the player who set first.
|
||||
assertEquals(FIRST_SEAT, state.turn)
|
||||
assertEquals(listOf(4, 4, 5, 5), state.secrets[SECOND_SEAT])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the code you set is the one your opponent is breaking`() {
|
||||
var state = withSecrets(first = listOf(0, 1, 2, 3), second = listOf(5, 5, 5, 5))
|
||||
// The first player guesses at the second player's code, which is 5555.
|
||||
state = guess(state, listOf(5, 5, 5, 5))
|
||||
assertEquals(Feedback(4, 0), state.guesses[FIRST_SEAT].single().feedback)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a code the table does not allow is refused`() {
|
||||
val state = start(MastermindOptions(colours = 6, length = 4, allowDuplicates = false))
|
||||
assertTrue(
|
||||
"a repeated colour on a no-repeats table",
|
||||
runCatching {
|
||||
MastermindRules.applyMove(state, FIRST_SEAT, MastermindMove(listOf(0, 0, 1, 2)))
|
||||
}.exceptionOrNull() is IllegalArgumentException
|
||||
)
|
||||
assertTrue(
|
||||
"the wrong number of pegs",
|
||||
runCatching {
|
||||
MastermindRules.applyMove(state, FIRST_SEAT, MastermindMove(listOf(0, 1, 2)))
|
||||
}.exceptionOrNull() is IllegalArgumentException
|
||||
)
|
||||
// And a table without repeats offers only codes without repeats.
|
||||
assertTrue(
|
||||
MastermindRules.legalMoves(state, FIRST_SEAT)
|
||||
.all { it.code.distinct().size == it.code.size }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nobody sets a code twice`() {
|
||||
val state = MastermindRules.applyMove(
|
||||
start(),
|
||||
FIRST_SEAT,
|
||||
MastermindMove(listOf(0, 1, 2, 3)),
|
||||
)
|
||||
assertTrue(
|
||||
runCatching {
|
||||
MastermindRules.applyMove(state, FIRST_SEAT, MastermindMove(listOf(3, 2, 1, 0)))
|
||||
}.exceptionOrNull() is IllegalArgumentException
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a table without duplicates counts a smaller space`() {
|
||||
val options = MastermindOptions(colours = 6, length = 4, allowDuplicates = false)
|
||||
// The falling factorial rather than a power.
|
||||
assertEquals(6 * 5 * 4 * 3, options.codeSpace())
|
||||
assertEquals(6 * 5 * 4 * 3, allCodes(options).size)
|
||||
assertTrue(allCodes(options).all { it.distinct().size == it.size })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every code is offered once and only once`() {
|
||||
val options = MastermindOptions(colours = 4, length = 3)
|
||||
val codes = allCodes(options)
|
||||
assertEquals(4 * 4 * 4, codes.size)
|
||||
assertEquals(codes.size, codes.distinct().size)
|
||||
assertEquals(options.codeSpace(), codes.size)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Keeping the codes secret
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a code being chosen is not shown to the other player either`() {
|
||||
val half = MastermindRules.applyMove(start(), FIRST_SEAT, MastermindMove(listOf(0, 1, 2, 3)))
|
||||
val asSecond = MastermindRules.viewFor(half, SECOND_SEAT)
|
||||
assertTrue("set and hidden", asSecond.isHidden(FIRST_SEAT))
|
||||
// But that they are ready is not a secret, or the screen could not say so.
|
||||
assertTrue(asSecond.hasSet(FIRST_SEAT))
|
||||
assertFalse(asSecond.hasSet(SECOND_SEAT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a player is shown their own code and not the one they are breaking`() {
|
||||
val state = withSecrets(listOf(0, 1, 2, 3), listOf(4, 5, 0, 1))
|
||||
val asFirst = MastermindRules.viewFor(state, FIRST_SEAT)
|
||||
assertEquals(listOf(0, 1, 2, 3), asFirst.secrets[FIRST_SEAT])
|
||||
assertTrue("the code being broken must not cross the wire", asFirst.isHidden(SECOND_SEAT))
|
||||
|
||||
val asSecond = MastermindRules.viewFor(state, SECOND_SEAT)
|
||||
assertEquals(listOf(4, 5, 0, 1), asSecond.secrets[SECOND_SEAT])
|
||||
assertTrue(asSecond.isHidden(FIRST_SEAT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `both codes are shown once the game is over`() {
|
||||
val state = withSecrets(listOf(0, 1, 2, 3), listOf(4, 5, 0, 1))
|
||||
.copy(phase = MastermindPhase.GAME_OVER, outcome = MastermindOutcome.DRAW_NEITHER)
|
||||
val seen = MastermindRules.viewFor(state, FIRST_SEAT)
|
||||
assertFalse(seen.isHidden(SECOND_SEAT))
|
||||
assertEquals(state, seen)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a device that was never told the code cannot score a guess against it`() {
|
||||
// Which is what stops a client trying to run the host's job locally and
|
||||
// silently scoring every guess as nothing.
|
||||
val redacted = MastermindRules.viewFor(
|
||||
withSecrets(listOf(0, 1, 2, 3), listOf(4, 5, 0, 1)),
|
||||
FIRST_SEAT,
|
||||
)
|
||||
val attempt = runCatching {
|
||||
MastermindRules.applyMove(redacted, FIRST_SEAT, MastermindMove(listOf(0, 0, 0, 0)))
|
||||
}
|
||||
assertTrue(attempt.exceptionOrNull() is IllegalArgumentException)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Playing
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a guess is answered and the turn passes`() {
|
||||
var state = withSecrets(listOf(0, 1, 2, 3), listOf(5, 5, 5, 5))
|
||||
state = guess(state, listOf(0, 1, 3, 2))
|
||||
assertEquals(SECOND_SEAT, state.turn)
|
||||
assertEquals(1, state.guesses[FIRST_SEAT].size)
|
||||
// The first player is guessing at the second player's code.
|
||||
assertEquals(Feedback(0, 0), state.guesses[FIRST_SEAT].single().feedback)
|
||||
|
||||
state = guess(state, listOf(0, 1, 2, 3))
|
||||
assertEquals(Feedback(4, 0), state.guesses[SECOND_SEAT].single().feedback)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same guess is not offered twice`() {
|
||||
var state = withSecrets(listOf(0, 1, 2, 3), listOf(5, 5, 5, 5))
|
||||
val opening = listOf(0, 0, 1, 1)
|
||||
val before = MastermindRules.legalMoves(state, FIRST_SEAT).size
|
||||
state = guess(state, opening)
|
||||
state = guess(state, listOf(3, 3, 3, 3))
|
||||
val after = MastermindRules.legalMoves(state, FIRST_SEAT)
|
||||
assertEquals(before - 1, after.size)
|
||||
assertTrue(after.none { it.code == opening })
|
||||
|
||||
val repeated = runCatching {
|
||||
MastermindRules.applyMove(state, FIRST_SEAT, MastermindMove(opening))
|
||||
}
|
||||
assertTrue(repeated.exceptionOrNull() is IllegalArgumentException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a guess of the wrong shape is refused`() {
|
||||
val state = withSecrets(listOf(0, 1, 2, 3), listOf(5, 5, 5, 5))
|
||||
assertTrue(
|
||||
runCatching {
|
||||
MastermindRules.applyMove(state, FIRST_SEAT, MastermindMove(listOf(0, 1, 2)))
|
||||
}.exceptionOrNull() is IllegalArgumentException
|
||||
)
|
||||
assertTrue(
|
||||
"a colour outside the game is not a guess",
|
||||
runCatching {
|
||||
MastermindRules.applyMove(state, FIRST_SEAT, MastermindMove(listOf(0, 1, 2, 9)))
|
||||
}.exceptionOrNull() is IllegalArgumentException
|
||||
)
|
||||
assertTrue(
|
||||
runCatching {
|
||||
MastermindRules.applyMove(state, SECOND_SEAT, MastermindMove(listOf(0, 1, 2, 3)))
|
||||
}.exceptionOrNull() is IllegalArgumentException
|
||||
)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Ending
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the player who replies always gets an equal number of guesses`() {
|
||||
var state = withSecrets(listOf(0, 1, 2, 3), listOf(4, 5, 4, 5))
|
||||
// The first player breaks it immediately. The game does not stop
|
||||
// there — the second player is owed a guess of their own.
|
||||
state = guess(state, listOf(4, 5, 4, 5))
|
||||
assertEquals(MastermindPhase.GUESSING, state.phase)
|
||||
assertEquals(SECOND_SEAT, state.turn)
|
||||
|
||||
state = guess(state, listOf(0, 0, 0, 0))
|
||||
assertEquals(MastermindPhase.GAME_OVER, state.phase)
|
||||
assertEquals(MastermindOutcome.FIRST_WINS, state.outcome)
|
||||
assertNull(MastermindRules.currentSeat(state))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `breaking it in the same round is a draw`() {
|
||||
var state = withSecrets(listOf(0, 1, 2, 3), listOf(4, 5, 4, 5))
|
||||
state = guess(state, listOf(4, 5, 4, 5))
|
||||
state = guess(state, listOf(0, 1, 2, 3))
|
||||
assertEquals(MastermindOutcome.DRAW_BOTH, state.outcome)
|
||||
assertEquals(1, state.brokeIn(FIRST_SEAT))
|
||||
assertEquals(1, state.brokeIn(SECOND_SEAT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the player who replies can win it outright`() {
|
||||
var state = withSecrets(listOf(0, 1, 2, 3), listOf(4, 5, 4, 5))
|
||||
state = guess(state, listOf(0, 0, 0, 0))
|
||||
state = guess(state, listOf(0, 1, 2, 3))
|
||||
assertEquals(MastermindOutcome.SECOND_WINS, state.outcome)
|
||||
assertNull(state.brokeIn(FIRST_SEAT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `running out of guesses with neither code broken is a draw`() {
|
||||
val options = MastermindOptions(colours = 6, length = 4, maxGuesses = 2)
|
||||
var state = withSecrets(listOf(0, 0, 0, 0), listOf(1, 1, 1, 1), options)
|
||||
repeat(2) {
|
||||
state = guess(state, listOf(2, 2, 2, it))
|
||||
state = guess(state, listOf(3, 3, 3, it))
|
||||
}
|
||||
assertEquals(MastermindOutcome.DRAW_NEITHER, state.outcome)
|
||||
assertEquals(0, state.guessesLeft(FIRST_SEAT))
|
||||
assertTrue(MastermindRules.legalMoves(state, FIRST_SEAT).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a state survives the round trip over the wire`() {
|
||||
var state = withSecrets(listOf(0, 1, 2, 3), listOf(4, 5, 4, 5))
|
||||
state = guess(state, listOf(0, 1, 2, 3))
|
||||
assertEquals(state, MastermindRules.decodeState(MastermindRules.encodeState(state)))
|
||||
val move = MastermindMove(listOf(1, 2, 3, 4))
|
||||
assertEquals(move, MastermindRules.decodeMove(MastermindRules.encodeMove(move)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the summary counts guesses without ever naming a colour`() {
|
||||
assertEquals("Setting the codes — 0 of 2 chosen", MastermindRules.summary(start()))
|
||||
val state = withSecrets(listOf(0, 1, 2, 3), listOf(4, 5, 4, 5))
|
||||
assertEquals("Guess 1 of 10", MastermindRules.summary(state))
|
||||
val over = state.copy(
|
||||
phase = MastermindPhase.GAME_OVER,
|
||||
outcome = MastermindOutcome.FIRST_WINS,
|
||||
)
|
||||
assertEquals(MastermindOutcome.FIRST_WINS.label, MastermindRules.summary(over))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the codes still standing are exactly those that fit every answer`() {
|
||||
var state = withSecrets(listOf(0, 1, 2, 3), listOf(5, 4, 3, 2))
|
||||
state = guess(state, listOf(0, 0, 1, 1))
|
||||
state = guess(state, listOf(0, 0, 0, 0))
|
||||
state = guess(state, listOf(2, 3, 4, 5))
|
||||
|
||||
val standing = MastermindRules.consistentCodes(state, FIRST_SEAT)
|
||||
assertTrue("the real code must survive", listOf(5, 4, 3, 2) in standing)
|
||||
standing.forEach { candidate ->
|
||||
state.guesses[FIRST_SEAT].forEach { answered ->
|
||||
assertEquals(answered.feedback, scoreGuess(candidate, answered.code))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package org.prolibertate.games.game.pirates
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PiratesRulesTest {
|
||||
|
||||
private fun start(options: PiratesOptions = PiratesOptions()) =
|
||||
PiratesRules.initialState(options)
|
||||
|
||||
private fun point(name: String) = requireNotNull(pointFromName(name)) { "no point $name" }
|
||||
|
||||
private fun position(
|
||||
bulgars: List<String>,
|
||||
pirates: List<String>,
|
||||
turn: Int = BULGAR_SEAT,
|
||||
options: PiratesOptions = PiratesOptions(),
|
||||
): PiratesState {
|
||||
val board = arrayOfNulls<Int>(POINTS)
|
||||
bulgars.forEach { board[point(it)] = BULGAR_SEAT }
|
||||
pirates.forEach { board[point(it)] = PIRATE_SEAT }
|
||||
return start(options).copy(board = board.toList(), turn = turn)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The board
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the cross has thirty-three points and a nine-point stronghold`() {
|
||||
assertEquals(33, POINTS)
|
||||
assertEquals(9, STRONGHOLD.size)
|
||||
// Which leaves exactly twenty-four points outside it — the number of
|
||||
// Bulgars, and not a coincidence.
|
||||
assertEquals(24, POINTS - STRONGHOLD.size)
|
||||
assertFalse(isOnBoard(0, 0))
|
||||
assertFalse(isOnBoard(6, 6))
|
||||
assertTrue(isOnBoard(3, 0))
|
||||
assertTrue(isOnBoard(0, 3))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lines run orthogonally everywhere and diagonally on the even points`() {
|
||||
// The centre of the board has all eight; the point beside it has four.
|
||||
assertEquals(8, ADJACENCY[point("d4")].size)
|
||||
assertEquals(4, ADJACENCY[point("d5")].size)
|
||||
// And every line is drawn from both ends.
|
||||
(0 until POINTS).forEach { from ->
|
||||
ADJACENCY[from].forEach { to ->
|
||||
assertTrue("${pointName(from)}–${pointName(to)} is one-way", from in ADJACENCY[to])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `points are named as a grid, and the names round-trip`() {
|
||||
// Reading order, so the stronghold starts at its top-left corner.
|
||||
assertEquals("c7", pointName(STRONGHOLD.first()))
|
||||
assertEquals("e5", pointName(STRONGHOLD.last()))
|
||||
(0 until POINTS).forEach { assertEquals(it, pointFromName(pointName(it))) }
|
||||
assertNull(pointFromName("a7"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the opening array fills the board but the stronghold`() {
|
||||
val state = start()
|
||||
assertEquals(24, state.count(BULGAR_SEAT))
|
||||
assertEquals(2, state.count(PIRATE_SEAT))
|
||||
assertEquals(0, state.stronghold())
|
||||
assertEquals(BULGAR_SEAT, state.turn)
|
||||
// Every point outside the stronghold is occupied by a Bulgar.
|
||||
(0 until POINTS).filterNot { isStronghold(it) }.forEach {
|
||||
assertEquals(BULGAR_SEAT, state.board[it])
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The Bulgars
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `Bulgars move one step and never backwards`() {
|
||||
val state = position(bulgars = listOf("d4"), pirates = listOf("c7", "e7"))
|
||||
val moves = PiratesRules.legalMoves(state)
|
||||
assertTrue("every Bulgar move is a single step", moves.all { it.steps.size == 1 })
|
||||
moves.forEach {
|
||||
assertTrue(
|
||||
"${pointName(it.to)} is behind ${pointName(it.from)}",
|
||||
rowOf(it.to) <= rowOf(it.from),
|
||||
)
|
||||
}
|
||||
// d4 is the centre, so eight lines out of it and three of them go back.
|
||||
assertEquals(5, moves.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Bulgars may retreat when the table says so`() {
|
||||
val penned = position(
|
||||
bulgars = listOf("d4"),
|
||||
pirates = listOf("c7", "e7"),
|
||||
options = PiratesOptions(bulgarsMayNotRetreat = false),
|
||||
)
|
||||
assertEquals(8, PiratesRules.legalMoves(penned).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Bulgars cannot take anything`() {
|
||||
// A pirate stands next to a Bulgar with an empty point beyond it, which
|
||||
// would be a capture if the Bulgars could capture.
|
||||
val state = position(bulgars = listOf("d5"), pirates = listOf("d6", "c7"))
|
||||
assertTrue(PiratesRules.legalMoves(state).all { it.steps.size == 1 })
|
||||
assertTrue(PiratesRules.legalMoves(state).none { isJumpFrom(it.from, it.to) })
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The pirates
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a pirate takes by jumping and must take when it can`() {
|
||||
val state = position(
|
||||
bulgars = listOf("d5", "d3", "b4", "f4", "c1", "e1", "d1", "a3", "g3"),
|
||||
pirates = listOf("d6", "c7"),
|
||||
turn = PIRATE_SEAT,
|
||||
)
|
||||
val moves = PiratesRules.legalMoves(state)
|
||||
assertTrue("with a jump on the board nothing else is a move", moves.isNotEmpty())
|
||||
assertTrue(moves.all { isJumpFrom(it.from, it.steps.first()) })
|
||||
|
||||
val jump = moves.first { it.from == point("d6") }
|
||||
val after = PiratesRules.applyMove(state, PIRATE_SEAT, jump)
|
||||
// One Bulgar per hop — and the hops carry on while there is another to
|
||||
// take, so this one takes two.
|
||||
assertEquals(9 - jump.steps.size, after.count(BULGAR_SEAT))
|
||||
assertTrue(jump.steps.size >= 1)
|
||||
assertEquals(0, after.pliesSinceProgress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a chain of jumps is one turn`() {
|
||||
// Two Bulgars in a line with a gap past each, so the pirate takes both.
|
||||
val state = position(
|
||||
bulgars = listOf("d6", "d4", "b4", "f4", "c1", "e1", "d1", "a3", "g3", "c2"),
|
||||
pirates = listOf("d7", "c7"),
|
||||
turn = PIRATE_SEAT,
|
||||
)
|
||||
val chains = PiratesRules.legalMoves(state).filter { it.steps.size > 1 }
|
||||
assertTrue("the double jump should be there", chains.isNotEmpty())
|
||||
val chain = chains.first()
|
||||
val after = PiratesRules.applyMove(state, PIRATE_SEAT, chain)
|
||||
assertEquals(10 - chain.steps.size, after.count(BULGAR_SEAT))
|
||||
// One move, so the turn has passed exactly once.
|
||||
assertEquals(BULGAR_SEAT, after.turn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pirate that cannot take may walk`() {
|
||||
val state = position(
|
||||
bulgars = listOf("a3", "g3", "d1", "c1", "e1", "c2", "e2", "a4", "g4"),
|
||||
pirates = listOf("c7", "e7"),
|
||||
turn = PIRATE_SEAT,
|
||||
)
|
||||
val moves = PiratesRules.legalMoves(state)
|
||||
assertTrue(moves.isNotEmpty())
|
||||
assertTrue(moves.all { it.steps.size == 1 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same Bulgar is never taken twice in one chain`() {
|
||||
val state = position(
|
||||
bulgars = listOf("d6", "d4", "c5", "b4", "c1", "e1", "d1", "a3", "g3", "f4"),
|
||||
pirates = listOf("d7", "e7"),
|
||||
turn = PIRATE_SEAT,
|
||||
)
|
||||
PiratesRules.legalMoves(state).forEach { move ->
|
||||
val taken = mutableListOf<Int>()
|
||||
var at = move.from
|
||||
move.steps.forEach { landing ->
|
||||
jumpedBetween(at, landing)?.let { taken += it }
|
||||
at = landing
|
||||
}
|
||||
assertEquals("a Bulgar was jumped twice: $move", taken.size, taken.distinct().size)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Ending
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `filling the stronghold wins it for the Bulgars`() {
|
||||
// Eight of the nine points held, the ninth empty at c5, and a Bulgar on
|
||||
// b5 one step to its left. The pirates have been driven out of it
|
||||
// entirely, which is the only way this position arises.
|
||||
val held = STRONGHOLD.map { pointName(it) }.filterNot { it == "c5" }
|
||||
val state = position(
|
||||
bulgars = held + listOf("b5"),
|
||||
pirates = listOf("a4", "g4"),
|
||||
turn = BULGAR_SEAT,
|
||||
)
|
||||
assertEquals(8, state.stronghold())
|
||||
|
||||
val into = PiratesRules.legalMoves(state)
|
||||
.single { it.from == point("b5") && it.to == point("c5") }
|
||||
val after = PiratesRules.applyMove(state, BULGAR_SEAT, into)
|
||||
|
||||
assertEquals(9, after.stronghold())
|
||||
assertEquals(PiratesOutcome.BULGARS_STORM, after.outcome)
|
||||
assertTrue(PiratesRules.isFinished(after))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cutting the Bulgars below nine wins it for the pirates`() {
|
||||
// Nine Bulgars, one of them about to be jumped: eight cannot fill a
|
||||
// nine-point stronghold, so the assault is over.
|
||||
val state = position(
|
||||
bulgars = listOf("d5", "b4", "f4", "c1", "e1", "d1", "a3", "g3", "c2"),
|
||||
pirates = listOf("d6", "c7"),
|
||||
turn = PIRATE_SEAT,
|
||||
)
|
||||
val jump = PiratesRules.legalMoves(state).first { isJumpFrom(it.from, it.steps.first()) }
|
||||
val after = PiratesRules.applyMove(state, PIRATE_SEAT, jump)
|
||||
assertEquals(PiratesOutcome.PIRATES_CUT_DOWN, after.outcome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pirates with nowhere to go have lost`() {
|
||||
// Both pirates in the stronghold's back corners with Bulgars filling
|
||||
// every point they could step or jump to.
|
||||
val state = position(
|
||||
bulgars = STRONGHOLD.map { pointName(it) }.filterNot { it == "c7" || it == "e7" } +
|
||||
listOf("c4", "d4", "e4", "b4", "f4", "a3", "g3", "d1"),
|
||||
pirates = listOf("c7", "e7"),
|
||||
turn = BULGAR_SEAT,
|
||||
)
|
||||
// Whatever the Bulgars play, if the pirates end up stuck they lose.
|
||||
val move = PiratesRules.legalMoves(state).firstOrNull()
|
||||
if (move != null) {
|
||||
val after = PiratesRules.applyMove(state, BULGAR_SEAT, move)
|
||||
if (after.phase == PiratesPhase.GAME_OVER) {
|
||||
assertTrue(
|
||||
after.outcome == PiratesOutcome.BULGARS_PEN_IN ||
|
||||
after.outcome == PiratesOutcome.BULGARS_STORM
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a game that gets nowhere for a long time is a draw`() {
|
||||
val state = position(
|
||||
bulgars = listOf("d5", "b4", "f4", "c1", "e1", "d1", "a3", "g3", "c2", "e2"),
|
||||
pirates = listOf("c7", "e7"),
|
||||
turn = BULGAR_SEAT,
|
||||
options = PiratesOptions(plyLimitWithoutProgress = 2),
|
||||
).copy(pliesSinceProgress = 1)
|
||||
val quiet = PiratesRules.legalMoves(state).first { !isStronghold(it.to) }
|
||||
val after = PiratesRules.applyMove(state, BULGAR_SEAT, quiet)
|
||||
assertEquals(PiratesOutcome.DRAW_STALEMATE, after.outcome)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The engine contract
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the opening has a move for the crowd and the generator counts straight`() {
|
||||
val state = start()
|
||||
val moves = PiratesRules.legalMoves(state)
|
||||
assertTrue(moves.isNotEmpty())
|
||||
assertTrue(moves.all { state.board[it.from] == BULGAR_SEAT })
|
||||
assertEquals(moves.size.toLong(), PiratesRules.perft(state, 1))
|
||||
assertTrue(PiratesRules.perft(state, 2) > moves.size.toLong())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an illegal move is refused rather than played`() {
|
||||
val state = start()
|
||||
assertTrue(
|
||||
runCatching {
|
||||
PiratesRules.applyMove(state, PIRATE_SEAT, PiratesRules.legalMoves(state).first())
|
||||
}.exceptionOrNull() is IllegalArgumentException
|
||||
)
|
||||
assertTrue(
|
||||
"a Bulgar cannot walk onto an occupied point",
|
||||
runCatching {
|
||||
PiratesRules.applyMove(
|
||||
state,
|
||||
BULGAR_SEAT,
|
||||
PiratesMove(from = point("d1"), steps = listOf(point("d2"))),
|
||||
)
|
||||
}.exceptionOrNull() is IllegalArgumentException
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a state survives the round trip over the wire`() {
|
||||
var state = start()
|
||||
state = PiratesRules.applyMove(state, BULGAR_SEAT, PiratesRules.legalMoves(state).first())
|
||||
assertEquals(state, PiratesRules.decodeState(PiratesRules.encodeState(state)))
|
||||
val move = PiratesMove(from = 0, steps = listOf(1))
|
||||
assertEquals(move, PiratesRules.decodeMove(PiratesRules.encodeMove(move)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing is hidden from either seat`() {
|
||||
val state = start()
|
||||
assertEquals(state, PiratesRules.viewFor(state, PIRATE_SEAT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the summary counts the stronghold and the crowd`() {
|
||||
val summary = PiratesRules.summary(start())
|
||||
assertTrue(summary.contains("0 of 9"))
|
||||
assertTrue(summary.contains("24 Bulgars"))
|
||||
val over = start().copy(
|
||||
phase = PiratesPhase.GAME_OVER,
|
||||
outcome = PiratesOutcome.PIRATES_CUT_DOWN,
|
||||
)
|
||||
assertEquals(PiratesOutcome.PIRATES_CUT_DOWN.label, PiratesRules.summary(over))
|
||||
assertNull(PiratesRules.currentSeat(over))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
# Backgammon
|
||||
|
||||
Twenty-four points, fifteen checkers a side, and two dice. Bring everything
|
||||
round to your home board and bear it off before your opponent does.
|
||||
|
||||
## The board
|
||||
|
||||
Points are numbered 1 to 24. **White runs down the numbers and bears off past
|
||||
1; Black runs up them and bears off past 24.** White's home board is points 1–6,
|
||||
Black's is 19–24.
|
||||
|
||||
A point belongs to whoever has two or more checkers on it, and is closed to the
|
||||
other player. A point with exactly one checker on it is a **blot**, and can be
|
||||
hit.
|
||||
|
||||
## The opening
|
||||
|
||||
Each player throws one die and the higher number starts, playing both dice as
|
||||
thrown. A tie is thrown again, so the game never opens on a double.
|
||||
|
||||
## A turn
|
||||
|
||||
Throw two dice and move one checker for each. **A double is four moves**, not
|
||||
two.
|
||||
|
||||
The rule that decides most disputes is this one: **you must use as much of the
|
||||
roll as you can.** If there is a way to play both dice, you must play both, even
|
||||
when playing one would suit you better. If only one can be played, it must be
|
||||
the higher one. The app enforces this by working out every way the whole roll
|
||||
could be played before offering you anything, so a move that would strand the
|
||||
other die is simply never on the board. That is why a legal-looking move
|
||||
sometimes cannot be tapped: playing it would waste half your roll.
|
||||
|
||||
## Hitting and the bar
|
||||
|
||||
Landing on a blot sends it to the bar. A player with a checker on the bar must
|
||||
bring it back on before moving anything else, entering in the opponent's home
|
||||
board: White enters on 24 minus the die, Black on the die itself. If every entry
|
||||
point is closed the turn is lost, and it can be lost several times over.
|
||||
|
||||
## Bearing off
|
||||
|
||||
Once all fifteen checkers are in your home board you may start bearing off. An
|
||||
exact die takes a checker off the point that needs it. A bigger die may take one
|
||||
off your highest occupied point, but only when nothing is standing further back —
|
||||
an overshooting die may never take a checker that a smaller die could have
|
||||
reached.
|
||||
|
||||
## Winning
|
||||
|
||||
Fifteen off wins. How much it is worth depends on how badly the loser lost:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **Single**, 1 point | the loser has borne at least one checker off |
|
||||
| **Gammon**, 2 points | the loser has borne nothing off |
|
||||
| **Backgammon**, 3 points | and still has a checker on the bar or in the winner's home |
|
||||
|
||||
Counting gammons is an option. It changes nothing about the moves and everything
|
||||
about whether a hopeless game is worth playing out.
|
||||
|
||||
## Not implemented
|
||||
|
||||
**There is no doubling cube.** A single game is played rather than a match to a
|
||||
number of points, and with nothing being kept between games there is nothing for
|
||||
a cube to double. It is the largest thing missing from this implementation and
|
||||
the one a serious player will notice first.
|
||||
|
||||
## The dice
|
||||
|
||||
Every die in the game comes from the table's own seed and a count of how many
|
||||
rolls have been made, so a game is reproducible from the state it started in and
|
||||
a host and a client can never disagree about what was thrown. The seed travels
|
||||
with the state, which is safe: backgammon has nothing hidden, and the host is
|
||||
authoritative about every roll in any case.
|
||||
|
||||
## What the computer does
|
||||
|
||||
Not a search in the sense the chess and checkers players are one — there is no
|
||||
tree to walk, because the opponent's reply depends on dice nobody has thrown
|
||||
yet. Instead it weighs **whole turns**: every distinct way the roll can be
|
||||
played, scored by the board it leaves behind.
|
||||
|
||||
Weighing turns rather than checkers is the point. A backgammon move is only good
|
||||
in company — splitting the back checkers is right or wrong depending on what the
|
||||
other die did — and a player choosing one checker at a time would never see it.
|
||||
|
||||
At the strongest level it also averages what the dice might do to it next: all
|
||||
twenty-one distinct rolls, weighted properly (6-5 comes up twice as often as
|
||||
5-5), taking the opponent's best answer to each.
|
||||
|
||||
The evaluation is built on the pip count, because backgammon is a race. Around
|
||||
that: a blot is only a liability if it can be hit, so exposure is weighted by
|
||||
how far away the nearest enemy checker is; a made point in your own home board
|
||||
is worth having because it is one more number your opponent cannot come in on;
|
||||
and a long run of consecutive points is what traps a checker behind it.
|
||||
@@ -0,0 +1,95 @@
|
||||
# Checkers
|
||||
|
||||
English draughts, on the thirty-two dark squares of an eight-by-eight board.
|
||||
Twelve pieces a side, Black moves first.
|
||||
|
||||
## The board
|
||||
|
||||
Squares are numbered 1 to 32 the way the game has always been written down:
|
||||
square 1 at the top left, running along each row and down the board to 32. Black
|
||||
starts on 1–12, White on 21–32, and the two middle rows are empty. Only the dark
|
||||
squares are ever played on, so a move is two numbers — `11-15` for a quiet move,
|
||||
`11x18` for a jump.
|
||||
|
||||
## Moving
|
||||
|
||||
A man moves one square diagonally **forwards**. Black moves down the board,
|
||||
White up it.
|
||||
|
||||
A man that reaches the far row is **crowned** and becomes a king, which moves
|
||||
one square diagonally in either direction.
|
||||
|
||||
## Taking
|
||||
|
||||
You take by jumping an enemy piece and landing on the empty square directly
|
||||
beyond it. Three rules follow, and all three are enforced by the engine rather
|
||||
than left to the players:
|
||||
|
||||
- **A capture is compulsory.** If anything can be taken anywhere on the board,
|
||||
a quiet move is not a legal move at all. The app simply will not offer one.
|
||||
- **A multiple jump is one turn.** Where a jump can be followed by another with
|
||||
the same piece, it must be — and the whole chain is a single move. A shorter
|
||||
prefix of a longer capture is never offered, so there is no way to stop
|
||||
halfway.
|
||||
- **A man may not jump backwards.** Only kings take in both directions.
|
||||
|
||||
You do *not* have to take the longest capture available: any complete jump will
|
||||
do. That is the English rule; international draughts requires the maximum.
|
||||
|
||||
## Crowning mid-jump
|
||||
|
||||
A man that jumps into the far row is crowned and **the turn ends there**, even
|
||||
with another jump available. This is the English rule and it is on by default.
|
||||
Turn *Crowning ends the turn* off and the man carries on jumping as a man,
|
||||
crowned only if it finishes on the back row — which is the international rule.
|
||||
|
||||
## Winning
|
||||
|
||||
A player who has no pieces, or who has pieces but no legal move, has lost. The
|
||||
two are the same condition as far as the engine is concerned: no moves is a
|
||||
loss, whatever the reason.
|
||||
|
||||
## Draws
|
||||
|
||||
- **Threefold repetition** — the same position with the same player to move,
|
||||
three times over.
|
||||
- **Eighty plies without progress** — forty moves each with nothing taken and
|
||||
nothing crowned. Both are options.
|
||||
|
||||
## Flying kings
|
||||
|
||||
Off by default. Turned on, a king slides any distance along a diagonal and takes
|
||||
an enemy piece from a distance, landing on any empty square beyond it — the
|
||||
international king.
|
||||
|
||||
**One honest deviation:** a piece taken by a flying king is lifted immediately.
|
||||
International draughts leaves captured pieces standing until the turn ends, so
|
||||
they block the rest of the sequence, which occasionally forbids a chain this
|
||||
implementation allows. Fixing it properly means tracking captured-but-present
|
||||
pieces through the whole search, and it is written down here rather than
|
||||
pretended away.
|
||||
|
||||
## What the computer does
|
||||
|
||||
Negamax with alpha-beta pruning and iterative deepening, to seven ply at the
|
||||
strongest level — deeper than the chess player reaches, because compulsory
|
||||
capture keeps the branching factor low.
|
||||
|
||||
There is no quiescence search and it does not need one: the usual failure of a
|
||||
fixed-depth search is stopping in the middle of an exchange, and compulsory
|
||||
capture handles that by itself, since a position where a piece has just been
|
||||
taken usually has the recapture as its only legal move.
|
||||
|
||||
Its evaluation counts material first, with kings worth well over a man, and then
|
||||
two things beginners underrate: **men are worth more the closer they are to
|
||||
being crowned**, and **the back row is worth holding**, because an empty back
|
||||
row is a road to a crown for the other side. The two pull against each other,
|
||||
which is about right.
|
||||
|
||||
## The move generator is checked against published numbers
|
||||
|
||||
`CheckersRulesTest` counts the leaf nodes below the opening position — 7, 49,
|
||||
302 and 1469 at one to four ply — and those are the published figures for
|
||||
English draughts. A generator that matches them that far has very little room
|
||||
left to be wrong: the counts fold in compulsory capture, crowning and the
|
||||
men's forward-only rule all at once.
|
||||
@@ -0,0 +1,110 @@
|
||||
# Mastermind
|
||||
|
||||
A hidden code of coloured pegs, and a fixed number of guesses to break it.
|
||||
|
||||
## Played as a duel
|
||||
|
||||
The pencil-and-paper game is one person setting a code and another breaking it,
|
||||
which makes a poor two-handed game: one player would sit and watch.
|
||||
|
||||
Here **each player sets the code their opponent will have to break**, and then
|
||||
both are breaking one, a guess each in turn. Whoever cracks theirs first wins.
|
||||
|
||||
Setting your own code is half of Mastermind, and a generated one takes it away.
|
||||
Whether to repeat a colour, whether to leave one out, whether to do the thing
|
||||
this particular opponent will not expect — those are real decisions, and no
|
||||
random number generator makes them.
|
||||
|
||||
The codes are chosen one after the other rather than at the same time, because
|
||||
the engine only ever has one player on the clock. It costs nothing: neither can
|
||||
see the other's, so going second is not an advantage. Once both are down, the
|
||||
guessing starts with whoever set first.
|
||||
|
||||
That symmetry is why the game ends at the foot of a round rather than the moment
|
||||
somebody gets it. If the player who went first breaks the code on their sixth
|
||||
guess, the player who replied still gets a sixth guess of their own — and
|
||||
equalling it is a draw. Ending it any earlier would make going first worth half
|
||||
a guess.
|
||||
|
||||
## The answer
|
||||
|
||||
Each guess is answered with pegs:
|
||||
|
||||
- a **dark** peg for a colour in the right place;
|
||||
- a **light** peg for a colour that is in the code but somewhere else.
|
||||
|
||||
**A colour is never counted twice.** Three reds guessed against a code with one
|
||||
red is worth exactly one peg, however they are arranged. The pegs say how many,
|
||||
never which, and they are deliberately not lined up under the code they answer —
|
||||
a tidy row would invite reading a position into them that is not there.
|
||||
|
||||
## Options
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **Colours** | 6 or 8 |
|
||||
| **Pegs** | 3, 4 or 5 |
|
||||
| **Guesses each** | 8, 10 or 12 |
|
||||
| **Repeated colours** | on by default; off makes every peg a different colour |
|
||||
| **Opponent** | Casual, Club or Strong |
|
||||
|
||||
The setup screen shows how many codes are possible, which is the honest measure
|
||||
of how hard a game will be: 1,296 for the standard four pegs and six colours,
|
||||
32,768 for five pegs and eight.
|
||||
|
||||
## Ending
|
||||
|
||||
- One player breaks it and the other does not — that player wins.
|
||||
- Both break it in the same round — a draw.
|
||||
- Neither breaks it before the guesses run out — a draw.
|
||||
|
||||
## Keeping the codes secret
|
||||
|
||||
This is the only game here with anything to hide, and it is the only one whose
|
||||
`viewFor` does real work. The host holds both codes and sends each device **only
|
||||
its own**: the code you are trying to break is never on your device in the first
|
||||
place, so there is nothing to find by digging through what arrived over the
|
||||
network.
|
||||
|
||||
A code set on a guest's device reaches the host as a move, and stops there.
|
||||
Moves travel one way — they are intents sent to the host, never relayed on to
|
||||
the other player — so the only copy that ever reaches an opponent's screen is
|
||||
the redacted one.
|
||||
|
||||
What is *not* hidden is whether the other player has finished choosing. That is
|
||||
not a secret, and a screen has to be able to say "waiting for them" rather than
|
||||
sit there blank, so the state carries a plain flag per player alongside the
|
||||
code.
|
||||
|
||||
A guess is scored by the host, which is the only machine that can score it. A
|
||||
device asked to score a guess against a code it was never given refuses rather
|
||||
than quietly answering "nothing" — tested, because that failure would look like
|
||||
a very hard code rather than like a bug.
|
||||
|
||||
Both codes are revealed when the game ends. There is nothing left to protect,
|
||||
and a code breaker wants to see what they were up against.
|
||||
|
||||
## What the computer does
|
||||
|
||||
**Setting a code**, the computer takes one at random and does not try to be
|
||||
clever: a code chosen to beat a particular solver is a code with a pattern in
|
||||
it, and a pattern is the one thing a code should not have.
|
||||
|
||||
That single choice is drawn from a source nobody else can see. Every other
|
||||
random number in this app is seeded from the position so that a host and a
|
||||
client agree on what happened — but a code seeded from anything that travels
|
||||
over the wire could be reconstructed by the very player who is supposed to be
|
||||
guessing it.
|
||||
|
||||
**Breaking one**, there is no tree to search — a guess cannot be answered badly,
|
||||
and the opponent makes no reply — so the whole game is in choosing what to try
|
||||
next.
|
||||
|
||||
- **Casual** guesses anything it has not tried, ruled out or not.
|
||||
- **Club** never guesses a code the answers have already eliminated. That one
|
||||
rule is most of the strength, and it cracks a standard code in about five.
|
||||
- **Strong** is Knuth's idea one ply deep: among the codes still standing, it
|
||||
plays the one whose *worst* possible answer leaves the fewest candidates
|
||||
behind, so it cannot be handed a reply that tells it little. Both lists are
|
||||
sampled when they are large — the full calculation on a fresh game is over a
|
||||
million scorings for a choice a sample makes just as well.
|
||||
@@ -0,0 +1,105 @@
|
||||
# Pirates and Bulgars
|
||||
|
||||
Two pirates hold a stronghold at the head of a cross-shaped board. Twenty-four
|
||||
Bulgars come at it from the other three arms. Only one side can take pieces, and
|
||||
it is not the big one.
|
||||
|
||||
## Note the caveats about the reconstruction
|
||||
|
||||
**"Dalmatian Pirates and the Volga Bulgars" is Sid Sackson's theming, in *The
|
||||
Book of Classic Board Games*, of the game usually called Asalto** — itself a
|
||||
nineteenth-century descendant of Fox and Geese. That much is attested.
|
||||
|
||||
The book could not be obtained, so **what is implemented here is Asalto**, and
|
||||
where Asalto's own sources disagree a choice had to be made. What follows says
|
||||
which is which, in the same spirit as the Ta Yü document: the game is playable
|
||||
and coherent, and it is not a transcription of a rulebook nobody here has read.
|
||||
|
||||
**Attested** — the shape of the board and its thirty-three points; the
|
||||
stronghold being one nine-point arm; two hunters against twenty-four; the
|
||||
hunters taking by jumping and the crowd being unable to take at all; the crowd
|
||||
winning by filling the stronghold or by penning the hunters in; the hunters
|
||||
winning by cutting the crowd below the number needed to fill it.
|
||||
|
||||
**Chosen here** —
|
||||
|
||||
- **Which side is which.** Sackson's theme names two peoples; nothing available
|
||||
said which of them is the pair and which the crowd. The pirates are the two,
|
||||
on the reasoning that a raiding party is small and a stronghold is what you
|
||||
raid.
|
||||
- **The diagonals.** Boards of this family are printed with diagonals in a
|
||||
pattern rather than everywhere. The pattern used is the usual one: a diagonal
|
||||
exists where the row and column of a point add to an even number, which gives
|
||||
the centre eight lines and its neighbours four.
|
||||
- **Compulsory capture instead of huffing.** Traditionally a hunter who fails to
|
||||
take when it could is *huffed* — removed by the other player. That is the same
|
||||
rule with an extra step and a way to forget to use it, so a pirate that can
|
||||
take simply must. It is an option, and turning it off restores the choice
|
||||
without restoring the punishment.
|
||||
- **Bulgars may not retreat.** Standard for the soldiers in Asalto, and stated
|
||||
here as an option because it is the single rule that most changes how the game
|
||||
feels.
|
||||
- **The draw rule.** No source gives one. Sixty plies with nothing taken and no
|
||||
ground gained is called a draw, because two sides that can each hold a
|
||||
position otherwise have no reason ever to stop.
|
||||
|
||||
## The board
|
||||
|
||||
```
|
||||
c7 d7 e7 ← the stronghold, nine points
|
||||
c6 d6 e6
|
||||
c5 d5 e5
|
||||
a4 b4 c4 d4 e4 f4 g4
|
||||
a3 b3 c3 d3 e3 f3 g3 (the middle band is seven wide)
|
||||
a2 b2 c2 d2 e2 f2 g2
|
||||
c1 d1 e1
|
||||
```
|
||||
|
||||
Thirty-three points: a seven-by-seven grid with the four corners cut away. The
|
||||
top arm — nine points — is the stronghold, which leaves exactly twenty-four
|
||||
points outside it. That is not a coincidence: the board and the pieces are cut
|
||||
to each other, and the game opens with every point occupied except the seven the
|
||||
pirates are not standing on.
|
||||
|
||||
Lines run orthogonally between all neighbouring points, and diagonally where the
|
||||
row and column add to an even number. **The lines the screen draws come from the
|
||||
same table the moves come from**, so what you can see and what you may play
|
||||
cannot disagree.
|
||||
|
||||
## The two sides
|
||||
|
||||
**The Bulgars** — twenty-four. Move one step along a line, towards the
|
||||
stronghold or across it, **never back down the board**. They cannot take
|
||||
anything at all.
|
||||
|
||||
**The pirates** — two. Move one step along a line in any direction, and take by
|
||||
jumping an adjacent Bulgar to the empty point directly beyond. A jump may be
|
||||
followed by another with the same pirate, and then it must be: a chain is one
|
||||
turn, and it cannot be abandoned halfway.
|
||||
|
||||
The Bulgars move first. The pirates are already where they want to be.
|
||||
|
||||
## Winning
|
||||
|
||||
**The Bulgars win** by occupying all nine points of the stronghold, or by
|
||||
leaving the pirates with no legal move.
|
||||
|
||||
**The pirates win** by cutting the Bulgars below nine, at which point there are
|
||||
not enough of them left to fill the stronghold however long the game runs.
|
||||
|
||||
## What the computer does
|
||||
|
||||
Negamax with alpha-beta pruning and iterative deepening, over the same rules
|
||||
engine the game runs on.
|
||||
|
||||
The interesting part of a hunt game is that the two sides want opposite shapes
|
||||
out of the same board, and one evaluation has to speak for both. The terms are
|
||||
written from the Bulgars' point of view and negated for the pirates, so the two
|
||||
cannot drift apart and start valuing the same position differently.
|
||||
|
||||
What it counts: how many Bulgars are left and how far up the board they have
|
||||
come; how much of the stronghold they hold; how much room the pirates have to
|
||||
work in; and — the term that does the most work — **how many Bulgars are
|
||||
standing where a pirate could jump them**. A Bulgar with an empty point behind
|
||||
it on a line is a Bulgar about to be taken, and not being jumpable is most of
|
||||
staying alive in this game.
|
||||
@@ -11,3 +11,15 @@ kotlin.code.style=official
|
||||
|
||||
# Only build the resources needed for the configuration being built.
|
||||
android.nonTransitiveRClass=true
|
||||
|
||||
# The version series every build and every release tag is named for: an APK is
|
||||
# <series>.<run number>, a production tag is v<series>.<n> and a dev tag adds
|
||||
# -dev. It lives here rather than in the build file because the workflows have
|
||||
# to name the tag as well, and they read it straight out of this file — three
|
||||
# copies of "1.1" would be three chances for a release to be tagged one thing
|
||||
# and built as another.
|
||||
#
|
||||
# Only the series is set by hand. The number after it is the CI run number, so
|
||||
# it keeps climbing on its own and an APK's versionCode never goes backwards
|
||||
# when this changes.
|
||||
plgVersionSeries=1.1
|
||||
|
||||
Reference in New Issue
Block a user