Merge pull request #9: Checkers, Backgammon, Mastermind, Pirates and Bulgars

Verified green on the feature branch before merging: build, unit tests
and Android lint (ci.yml run 51). Nothing in the catalogue is marked
coming soon any more.
This commit is contained in:
adrianoftyriel
2026-08-07 02:04:27 -06:00
committed by GitHub
29 changed files with 6299 additions and 20 deletions
+24 -10
View File
@@ -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
@@ -269,7 +272,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, both players
guarding a code and breaking one
- [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 +299,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
@@ -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 = "Break the hidden code from black and white peg clues. " +
"Both players get one, and 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,92 @@
package org.prolibertate.games.game.mastermind
import kotlin.random.Random
import org.prolibertate.games.game.engine.GameAi
/**
* 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())
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,170 @@
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
* **both** players are set a code and both are breaking one, a guess each in
* turn, and whoever cracks theirs first wins.
*
* That 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 { 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].
*/
val secrets: List<List<Int>>,
/** 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 the secret has been stripped out for a client's view. */
fun isHidden(seat: Int): Boolean = secrets[seat].isEmpty()
}
const val FIRST_SEAT = 0
const val SECOND_SEAT = 1
fun other(seat: Int): Int = 1 - seat
@@ -0,0 +1,172 @@
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 guess at the opponent's code. */
@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.
*
* 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. Nothing else in this
* file may leak it 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, config.seed)
}
fun initialState(options: MastermindOptions, seed: Long = 0L): 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}"
}
val random = Random(seed)
return MastermindState(
options = options,
// Both codes come off the table's own seed, so a game can be
// replayed exactly from the config it started with.
secrets = listOf(secretFrom(options, random), secretFrom(options, random)),
guesses = listOf(emptyList(), emptyList()),
turn = FIRST_SEAT,
phase = MastermindPhase.GUESSING,
outcome = null,
)
}
private fun secretFrom(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)) }
}
override fun currentSeat(state: MastermindState): Int? =
if (state.phase == MastermindPhase.GAME_OVER) null else state.turn
/**
* Every code this seat has not already tried.
*
* 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()
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(move.code.size == state.options.length) {
"a guess is ${state.options.length} pegs long"
}
require(move.code.all { it in 0 until state.options.colours }) {
"that colour is not in this game"
}
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()
}
/**
* 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
?: "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 — it is theirs to guard, and the screen shows
* it once the game is over — and loses the one it is trying to break. The
* feedback on every guess stays, because that is the game.
*
* 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,356 @@
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.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.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: both players are guarding a code and breaking
* one, a guess each in turn.
*
* Tap the colours to build a guess and press the button to commit it. The
* answer to each guess is the little pegs beside it — a black one for a colour
* in the right place, a white 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 yourTurn = current.turn == localSeat && current.phase == MastermindPhase.GUESSING
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
yourTurn -> "Your guess — ${current.guessesLeft(localSeat)} left"
else -> "Thinking…"
},
fontWeight = FontWeight.Bold,
)
Text(
text = "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,
)
Text("Your guesses", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
GuessList(current, localSeat)
if (current.phase == MastermindPhase.GUESSING) {
Builder(
length = current.options.length,
colours = current.options.colours,
building = building,
enabled = yourTurn,
onSubmit = {
controller.submit(MastermindMove(building.toList()))
building.clear()
},
)
}
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 are guarding
// it, not guessing it.
if (current.secrets[localSeat].isNotEmpty()) {
Text(
text = "The code you are guarding",
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,
building: MutableList<Int>,
enabled: 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 ->
Box(
modifier = Modifier.clickable(enabled = enabled && building.size < length) {
building.add(colour)
}
) {
Peg(colour, size = 34.dp)
}
}
}
PrimaryAction(
text = "Guess",
enabled = enabled && building.size == length,
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") } },
)
}
@@ -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,285 @@
package org.prolibertate.games.game.mastermind
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class MastermindRulesTest {
private fun start(
options: MastermindOptions = MastermindOptions(),
seed: Long = 42L,
) = MastermindRules.initialState(options, seed)
/** Sets both codes by hand, which is the only way to write a test about them. */
private fun withSecrets(
first: List<Int>,
second: List<Int>,
options: MastermindOptions = MastermindOptions(),
) = start(options).copy(secrets = listOf(first, 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 `both players are set a code of the right shape`() {
val state = start()
assertEquals(2, state.secrets.size)
state.secrets.forEach { secret ->
assertEquals(4, secret.size)
assertTrue(secret.all { it in 0 until 6 })
}
assertEquals(FIRST_SEAT, state.turn)
assertEquals(MastermindPhase.GUESSING, state.phase)
}
@Test
fun `the same seed sets the same codes, and a different one does not`() {
assertEquals(start(seed = 7L).secrets, start(seed = 7L).secrets)
assertNotEquals(start(seed = 7L).secrets, start(seed = 8L).secrets)
}
@Test
fun `a game without duplicates never repeats a colour`() {
val options = MastermindOptions(colours = 6, length = 4, allowDuplicates = false)
(0L until 20L).forEach { seed ->
MastermindRules.initialState(options, seed).secrets.forEach { secret ->
assertEquals(secret.size, secret.distinct().size)
}
}
// And the code space is the falling factorial rather than a power.
assertEquals(6 * 5 * 4 * 3, options.codeSpace())
assertEquals(6 * 5 * 4 * 3, allCodes(options).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 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`() {
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))
}
}
+96
View File
@@ -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.
+95
View File
@@ -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.
+79
View File
@@ -0,0 +1,79 @@
# 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 **both players are set a code and both are breaking one**, a guess each in
turn. Whoever cracks theirs first wins.
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 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
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.
+105
View File
@@ -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.