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:
@@ -5,20 +5,23 @@ or against other people over Wi-Fi (LAN).
|
|||||||
|
|
||||||
## What works today
|
## 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 |
|
| **Cards** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s |
|
||||||
| **Listed, not yet implemented** | Mastermind, Backgammon, Checkers, Pirates and Bulgars |
|
| **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
|
Nothing is marked *Coming soon* at the moment, because nothing is waiting. The
|
||||||
only** — a production release lists just the games that can actually be played,
|
machinery for it is still there and still tested: a game listed in `GameCatalog`
|
||||||
so nobody installs it and taps into a dead end. The menu decides this from the
|
with `available = false` appears in the menu marked *Coming soon* **on dev builds
|
||||||
installed APK's own version name, so it follows the build rather than the
|
only**, so a production release lists just what can actually be played and nobody
|
||||||
channel selected for future updates. Adding one means
|
installs it and taps into a dead end. The menu decides this from the installed
|
||||||
writing a `GameRules` implementation, a `GameAi`, and a screen, then flipping
|
APK's own version name, so it follows the build rather than the channel selected
|
||||||
`available` in `GameCatalog` — the menu, lobby, networking, settings and OTA
|
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.
|
layers are game-agnostic and need no changes.
|
||||||
|
|
||||||
## Playing with other people
|
## Playing with other people
|
||||||
@@ -269,7 +272,14 @@ game:
|
|||||||
- [Wizard](docs/RULES-wizard.md)
|
- [Wizard](docs/RULES-wizard.md)
|
||||||
- [Crazy 8s](docs/RULES-crazy8s.md)
|
- [Crazy 8s](docs/RULES-crazy8s.md)
|
||||||
- [Chess](docs/RULES-chess.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)
|
- [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**:
|
- [Ta Yü](docs/RULES-tayu.md) — **note the caveats about the reconstruction**:
|
||||||
the game is out of print and the rulebook could not be obtained, so the
|
the game is out of print and the rulebook could not be obtained, so the
|
||||||
document says which parts are attested and which were derived
|
document says which parts are attested and which were derived
|
||||||
@@ -289,6 +299,10 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested
|
|||||||
crazyeights/ Crazy 8s model, rules, AI
|
crazyeights/ Crazy 8s model, rules, AI
|
||||||
chess/ Chess model, FEN, move generation, search
|
chess/ Chess model, FEN, move generation, search
|
||||||
morris/ Nine Men's Morris board geometry, mills, rules, 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
|
tayu/ Ta Yü tile geometry, river rules, scoring, AI
|
||||||
net/ wire protocol, LAN transport, lobby, match driver
|
net/ wire protocol, LAN transport, lobby, match driver
|
||||||
score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store
|
score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ object GameCatalog {
|
|||||||
const val CHECKERS = "checkers"
|
const val CHECKERS = "checkers"
|
||||||
const val TAYU = "tayu"
|
const val TAYU = "tayu"
|
||||||
const val MORRIS = "morris"
|
const val MORRIS = "morris"
|
||||||
|
const val PIRATES = "pirates"
|
||||||
|
|
||||||
val all: List<GameDescriptor> = listOf(
|
val all: List<GameDescriptor> = listOf(
|
||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
@@ -119,8 +120,9 @@ object GameCatalog {
|
|||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 2,
|
maxPlayers = 2,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
blurb = "Break the hidden code from black and white peg clues.",
|
blurb = "Break the hidden code from black and white peg clues. " +
|
||||||
available = false,
|
"Both players get one, and whoever cracks theirs first wins.",
|
||||||
|
available = true,
|
||||||
),
|
),
|
||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = BACKGAMMON,
|
id = BACKGAMMON,
|
||||||
@@ -130,7 +132,7 @@ object GameCatalog {
|
|||||||
maxPlayers = 2,
|
maxPlayers = 2,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
blurb = "Race your checkers home, and hit your opponent on the way.",
|
blurb = "Race your checkers home, and hit your opponent on the way.",
|
||||||
available = false,
|
available = true,
|
||||||
),
|
),
|
||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = CHESS,
|
id = CHESS,
|
||||||
@@ -150,7 +152,7 @@ object GameCatalog {
|
|||||||
maxPlayers = 2,
|
maxPlayers = 2,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
blurb = "Forced captures, kings, and the long diagonal.",
|
blurb = "Forced captures, kings, and the long diagonal.",
|
||||||
available = false,
|
available = true,
|
||||||
),
|
),
|
||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = MORRIS,
|
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.",
|
blurb = "Three in a row takes a piece. Reduce them to two and the game is yours.",
|
||||||
available = true,
|
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(
|
GameDescriptor(
|
||||||
id = TAYU,
|
id = TAYU,
|
||||||
title = "Ta Yü",
|
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.PlayerKind
|
||||||
import org.prolibertate.games.game.engine.PlayerSlot
|
import org.prolibertate.games.game.engine.PlayerSlot
|
||||||
import org.prolibertate.games.game.engine.TableConfig
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
import org.prolibertate.games.game.backgammon.BackgammonLevel
|
||||||
|
import org.prolibertate.games.game.backgammon.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.ChessLevel
|
||||||
import org.prolibertate.games.game.chess.ChessOptions
|
import org.prolibertate.games.game.chess.ChessOptions
|
||||||
import org.prolibertate.games.game.crazyeights.CrazyEightsOptions
|
import org.prolibertate.games.game.crazyeights.CrazyEightsOptions
|
||||||
import org.prolibertate.games.game.euchre.EuchreOptions
|
import org.prolibertate.games.game.euchre.EuchreOptions
|
||||||
import org.prolibertate.games.game.golf.GolfOptions
|
import org.prolibertate.games.game.golf.GolfOptions
|
||||||
import org.prolibertate.games.game.kaiser.KaiserOptions
|
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.MorrisLevel
|
||||||
import org.prolibertate.games.game.morris.MorrisOptions
|
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.president.PresidentOptions
|
||||||
import org.prolibertate.games.game.sequence.SequenceOptions
|
import org.prolibertate.games.game.sequence.SequenceOptions
|
||||||
import org.prolibertate.games.game.tayu.TayuLevel
|
import org.prolibertate.games.game.tayu.TayuLevel
|
||||||
@@ -74,9 +82,16 @@ fun GameSetupScreen(
|
|||||||
var chess by remember { mutableStateOf(ChessOptions()) }
|
var chess by remember { mutableStateOf(ChessOptions()) }
|
||||||
var tayu by remember { mutableStateOf(TayuOptions()) }
|
var tayu by remember { mutableStateOf(TayuOptions()) }
|
||||||
var morris by remember { mutableStateOf(MorrisOptions()) }
|
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
|
// Not an engine option: which seat the local player takes. Seat 0 is
|
||||||
// always White, so choosing Black means sitting in seat 1.
|
// always White, so choosing Black means sitting in seat 1.
|
||||||
var playWhite by remember { mutableStateOf(true) }
|
var playWhite by remember { mutableStateOf(true) }
|
||||||
|
// 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) {
|
val optionsJson = when (descriptor.id) {
|
||||||
GameCatalog.EUCHRE -> setupJson.encodeToString(euchre)
|
GameCatalog.EUCHRE -> setupJson.encodeToString(euchre)
|
||||||
@@ -89,6 +104,10 @@ fun GameSetupScreen(
|
|||||||
GameCatalog.CHESS -> setupJson.encodeToString(chess)
|
GameCatalog.CHESS -> setupJson.encodeToString(chess)
|
||||||
GameCatalog.TAYU -> setupJson.encodeToString(tayu)
|
GameCatalog.TAYU -> setupJson.encodeToString(tayu)
|
||||||
GameCatalog.MORRIS -> setupJson.encodeToString(morris)
|
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 -> "{}"
|
else -> "{}"
|
||||||
}
|
}
|
||||||
val seatCount = seatCountFor(descriptor.id, optionsJson)
|
val seatCount = seatCountFor(descriptor.id, optionsJson)
|
||||||
@@ -125,6 +144,19 @@ fun GameSetupScreen(
|
|||||||
|
|
||||||
GameCatalog.MORRIS -> MorrisOptionsEditor(morris) { morris = it }
|
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.")
|
else -> Text("No options yet for this game.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,9 +169,14 @@ fun GameSetupScreen(
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Chess is the only game so far where the local player picks a
|
// Two games let the local player pick a seat rather than always
|
||||||
// seat rather than always taking the first one.
|
// taking the first one: chess by colour, and Pirates and Bulgars by
|
||||||
val localSeat = if (descriptor.id == GameCatalog.CHESS && !playWhite) 1 else 0
|
// 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") {
|
PrimaryAction(text = "Play against the computer") {
|
||||||
onPlayOffline(
|
onPlayOffline(
|
||||||
offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat),
|
offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat),
|
||||||
@@ -202,7 +239,8 @@ fun seatCountFor(gameId: String, optionsJson: String): Int {
|
|||||||
GameCatalog.WIZARD ->
|
GameCatalog.WIZARD ->
|
||||||
setupJson.decodeFromString<WizardOptions>(optionsJson).playerCount
|
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 ->
|
GameCatalog.TAYU ->
|
||||||
setupJson.decodeFromString<TayuOptions>(optionsJson).playerCount
|
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
|
@Composable
|
||||||
private fun MorrisOptionsEditor(options: MorrisOptions, onChange: (MorrisOptions) -> Unit) {
|
private fun MorrisOptionsEditor(options: MorrisOptions, onChange: (MorrisOptions) -> Unit) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
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.KaiserPhase
|
||||||
import org.prolibertate.games.game.kaiser.KaiserRules
|
import org.prolibertate.games.game.kaiser.KaiserRules
|
||||||
import org.prolibertate.games.game.kaiser.KaiserState
|
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.MorrisAi
|
||||||
import org.prolibertate.games.game.morris.MorrisMove
|
import org.prolibertate.games.game.morris.MorrisMove
|
||||||
import org.prolibertate.games.game.morris.MorrisRules
|
import org.prolibertate.games.game.morris.MorrisRules
|
||||||
import org.prolibertate.games.game.morris.MorrisState
|
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.PresidentAi
|
||||||
import org.prolibertate.games.game.president.PresidentMove
|
import org.prolibertate.games.game.president.PresidentMove
|
||||||
import org.prolibertate.games.game.president.PresidentPhase
|
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.game.wizard.WizardState
|
||||||
import org.prolibertate.games.net.MatchController
|
import org.prolibertate.games.net.MatchController
|
||||||
import org.prolibertate.games.settings.Settings
|
import org.prolibertate.games.settings.Settings
|
||||||
|
import org.prolibertate.games.ui.game.BackgammonScreen
|
||||||
|
import org.prolibertate.games.ui.game.CheckersScreen
|
||||||
import org.prolibertate.games.ui.game.ChessScreen
|
import org.prolibertate.games.ui.game.ChessScreen
|
||||||
import org.prolibertate.games.ui.game.CrazyEightsScreen
|
import org.prolibertate.games.ui.game.CrazyEightsScreen
|
||||||
import org.prolibertate.games.ui.game.EuchreScreen
|
import org.prolibertate.games.ui.game.EuchreScreen
|
||||||
import org.prolibertate.games.ui.game.GolfScreen
|
import org.prolibertate.games.ui.game.GolfScreen
|
||||||
import org.prolibertate.games.ui.game.KaiserScreen
|
import org.prolibertate.games.ui.game.KaiserScreen
|
||||||
|
import org.prolibertate.games.ui.game.MastermindScreen
|
||||||
import org.prolibertate.games.ui.game.MorrisScreen
|
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.PresidentScreen
|
||||||
import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS
|
import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS
|
||||||
import org.prolibertate.games.ui.game.SequenceScreen
|
import org.prolibertate.games.ui.game.SequenceScreen
|
||||||
@@ -307,6 +327,76 @@ fun PlayScreen(
|
|||||||
TayuScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
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 -> {
|
GameCatalog.MORRIS -> {
|
||||||
val controller = remember(route) {
|
val controller = remember(route) {
|
||||||
MatchController<MorrisState, MorrisMove>(
|
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
|
@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 {
|
val shown = GameCategory.entries.flatMap {
|
||||||
GameCatalog.byCategory(it, includeComingSoon = true)
|
GameCatalog.byCategory(it, includeComingSoon = true)
|
||||||
}
|
}
|
||||||
assertEquals(GameCatalog.all.size, shown.size)
|
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
|
@Test
|
||||||
@@ -61,6 +74,10 @@ class CatalogTest {
|
|||||||
GameCatalog.CHESS,
|
GameCatalog.CHESS,
|
||||||
GameCatalog.TAYU,
|
GameCatalog.TAYU,
|
||||||
GameCatalog.MORRIS,
|
GameCatalog.MORRIS,
|
||||||
|
GameCatalog.CHECKERS,
|
||||||
|
GameCatalog.MASTERMIND,
|
||||||
|
GameCatalog.BACKGAMMON,
|
||||||
|
GameCatalog.PIRATES,
|
||||||
),
|
),
|
||||||
GameCatalog.playable.map { it.id }.toSet(),
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Backgammon
|
||||||
|
|
||||||
|
Twenty-four points, fifteen checkers a side, and two dice. Bring everything
|
||||||
|
round to your home board and bear it off before your opponent does.
|
||||||
|
|
||||||
|
## The board
|
||||||
|
|
||||||
|
Points are numbered 1 to 24. **White runs down the numbers and bears off past
|
||||||
|
1; Black runs up them and bears off past 24.** White's home board is points 1–6,
|
||||||
|
Black's is 19–24.
|
||||||
|
|
||||||
|
A point belongs to whoever has two or more checkers on it, and is closed to the
|
||||||
|
other player. A point with exactly one checker on it is a **blot**, and can be
|
||||||
|
hit.
|
||||||
|
|
||||||
|
## The opening
|
||||||
|
|
||||||
|
Each player throws one die and the higher number starts, playing both dice as
|
||||||
|
thrown. A tie is thrown again, so the game never opens on a double.
|
||||||
|
|
||||||
|
## A turn
|
||||||
|
|
||||||
|
Throw two dice and move one checker for each. **A double is four moves**, not
|
||||||
|
two.
|
||||||
|
|
||||||
|
The rule that decides most disputes is this one: **you must use as much of the
|
||||||
|
roll as you can.** If there is a way to play both dice, you must play both, even
|
||||||
|
when playing one would suit you better. If only one can be played, it must be
|
||||||
|
the higher one. The app enforces this by working out every way the whole roll
|
||||||
|
could be played before offering you anything, so a move that would strand the
|
||||||
|
other die is simply never on the board. That is why a legal-looking move
|
||||||
|
sometimes cannot be tapped: playing it would waste half your roll.
|
||||||
|
|
||||||
|
## Hitting and the bar
|
||||||
|
|
||||||
|
Landing on a blot sends it to the bar. A player with a checker on the bar must
|
||||||
|
bring it back on before moving anything else, entering in the opponent's home
|
||||||
|
board: White enters on 24 minus the die, Black on the die itself. If every entry
|
||||||
|
point is closed the turn is lost, and it can be lost several times over.
|
||||||
|
|
||||||
|
## Bearing off
|
||||||
|
|
||||||
|
Once all fifteen checkers are in your home board you may start bearing off. An
|
||||||
|
exact die takes a checker off the point that needs it. A bigger die may take one
|
||||||
|
off your highest occupied point, but only when nothing is standing further back —
|
||||||
|
an overshooting die may never take a checker that a smaller die could have
|
||||||
|
reached.
|
||||||
|
|
||||||
|
## Winning
|
||||||
|
|
||||||
|
Fifteen off wins. How much it is worth depends on how badly the loser lost:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| **Single**, 1 point | the loser has borne at least one checker off |
|
||||||
|
| **Gammon**, 2 points | the loser has borne nothing off |
|
||||||
|
| **Backgammon**, 3 points | and still has a checker on the bar or in the winner's home |
|
||||||
|
|
||||||
|
Counting gammons is an option. It changes nothing about the moves and everything
|
||||||
|
about whether a hopeless game is worth playing out.
|
||||||
|
|
||||||
|
## Not implemented
|
||||||
|
|
||||||
|
**There is no doubling cube.** A single game is played rather than a match to a
|
||||||
|
number of points, and with nothing being kept between games there is nothing for
|
||||||
|
a cube to double. It is the largest thing missing from this implementation and
|
||||||
|
the one a serious player will notice first.
|
||||||
|
|
||||||
|
## The dice
|
||||||
|
|
||||||
|
Every die in the game comes from the table's own seed and a count of how many
|
||||||
|
rolls have been made, so a game is reproducible from the state it started in and
|
||||||
|
a host and a client can never disagree about what was thrown. The seed travels
|
||||||
|
with the state, which is safe: backgammon has nothing hidden, and the host is
|
||||||
|
authoritative about every roll in any case.
|
||||||
|
|
||||||
|
## What the computer does
|
||||||
|
|
||||||
|
Not a search in the sense the chess and checkers players are one — there is no
|
||||||
|
tree to walk, because the opponent's reply depends on dice nobody has thrown
|
||||||
|
yet. Instead it weighs **whole turns**: every distinct way the roll can be
|
||||||
|
played, scored by the board it leaves behind.
|
||||||
|
|
||||||
|
Weighing turns rather than checkers is the point. A backgammon move is only good
|
||||||
|
in company — splitting the back checkers is right or wrong depending on what the
|
||||||
|
other die did — and a player choosing one checker at a time would never see it.
|
||||||
|
|
||||||
|
At the strongest level it also averages what the dice might do to it next: all
|
||||||
|
twenty-one distinct rolls, weighted properly (6-5 comes up twice as often as
|
||||||
|
5-5), taking the opponent's best answer to each.
|
||||||
|
|
||||||
|
The evaluation is built on the pip count, because backgammon is a race. Around
|
||||||
|
that: a blot is only a liability if it can be hit, so exposure is weighted by
|
||||||
|
how far away the nearest enemy checker is; a made point in your own home board
|
||||||
|
is worth having because it is one more number your opponent cannot come in on;
|
||||||
|
and a long run of consecutive points is what traps a checker behind it.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Checkers
|
||||||
|
|
||||||
|
English draughts, on the thirty-two dark squares of an eight-by-eight board.
|
||||||
|
Twelve pieces a side, Black moves first.
|
||||||
|
|
||||||
|
## The board
|
||||||
|
|
||||||
|
Squares are numbered 1 to 32 the way the game has always been written down:
|
||||||
|
square 1 at the top left, running along each row and down the board to 32. Black
|
||||||
|
starts on 1–12, White on 21–32, and the two middle rows are empty. Only the dark
|
||||||
|
squares are ever played on, so a move is two numbers — `11-15` for a quiet move,
|
||||||
|
`11x18` for a jump.
|
||||||
|
|
||||||
|
## Moving
|
||||||
|
|
||||||
|
A man moves one square diagonally **forwards**. Black moves down the board,
|
||||||
|
White up it.
|
||||||
|
|
||||||
|
A man that reaches the far row is **crowned** and becomes a king, which moves
|
||||||
|
one square diagonally in either direction.
|
||||||
|
|
||||||
|
## Taking
|
||||||
|
|
||||||
|
You take by jumping an enemy piece and landing on the empty square directly
|
||||||
|
beyond it. Three rules follow, and all three are enforced by the engine rather
|
||||||
|
than left to the players:
|
||||||
|
|
||||||
|
- **A capture is compulsory.** If anything can be taken anywhere on the board,
|
||||||
|
a quiet move is not a legal move at all. The app simply will not offer one.
|
||||||
|
- **A multiple jump is one turn.** Where a jump can be followed by another with
|
||||||
|
the same piece, it must be — and the whole chain is a single move. A shorter
|
||||||
|
prefix of a longer capture is never offered, so there is no way to stop
|
||||||
|
halfway.
|
||||||
|
- **A man may not jump backwards.** Only kings take in both directions.
|
||||||
|
|
||||||
|
You do *not* have to take the longest capture available: any complete jump will
|
||||||
|
do. That is the English rule; international draughts requires the maximum.
|
||||||
|
|
||||||
|
## Crowning mid-jump
|
||||||
|
|
||||||
|
A man that jumps into the far row is crowned and **the turn ends there**, even
|
||||||
|
with another jump available. This is the English rule and it is on by default.
|
||||||
|
Turn *Crowning ends the turn* off and the man carries on jumping as a man,
|
||||||
|
crowned only if it finishes on the back row — which is the international rule.
|
||||||
|
|
||||||
|
## Winning
|
||||||
|
|
||||||
|
A player who has no pieces, or who has pieces but no legal move, has lost. The
|
||||||
|
two are the same condition as far as the engine is concerned: no moves is a
|
||||||
|
loss, whatever the reason.
|
||||||
|
|
||||||
|
## Draws
|
||||||
|
|
||||||
|
- **Threefold repetition** — the same position with the same player to move,
|
||||||
|
three times over.
|
||||||
|
- **Eighty plies without progress** — forty moves each with nothing taken and
|
||||||
|
nothing crowned. Both are options.
|
||||||
|
|
||||||
|
## Flying kings
|
||||||
|
|
||||||
|
Off by default. Turned on, a king slides any distance along a diagonal and takes
|
||||||
|
an enemy piece from a distance, landing on any empty square beyond it — the
|
||||||
|
international king.
|
||||||
|
|
||||||
|
**One honest deviation:** a piece taken by a flying king is lifted immediately.
|
||||||
|
International draughts leaves captured pieces standing until the turn ends, so
|
||||||
|
they block the rest of the sequence, which occasionally forbids a chain this
|
||||||
|
implementation allows. Fixing it properly means tracking captured-but-present
|
||||||
|
pieces through the whole search, and it is written down here rather than
|
||||||
|
pretended away.
|
||||||
|
|
||||||
|
## What the computer does
|
||||||
|
|
||||||
|
Negamax with alpha-beta pruning and iterative deepening, to seven ply at the
|
||||||
|
strongest level — deeper than the chess player reaches, because compulsory
|
||||||
|
capture keeps the branching factor low.
|
||||||
|
|
||||||
|
There is no quiescence search and it does not need one: the usual failure of a
|
||||||
|
fixed-depth search is stopping in the middle of an exchange, and compulsory
|
||||||
|
capture handles that by itself, since a position where a piece has just been
|
||||||
|
taken usually has the recapture as its only legal move.
|
||||||
|
|
||||||
|
Its evaluation counts material first, with kings worth well over a man, and then
|
||||||
|
two things beginners underrate: **men are worth more the closer they are to
|
||||||
|
being crowned**, and **the back row is worth holding**, because an empty back
|
||||||
|
row is a road to a crown for the other side. The two pull against each other,
|
||||||
|
which is about right.
|
||||||
|
|
||||||
|
## The move generator is checked against published numbers
|
||||||
|
|
||||||
|
`CheckersRulesTest` counts the leaf nodes below the opening position — 7, 49,
|
||||||
|
302 and 1469 at one to four ply — and those are the published figures for
|
||||||
|
English draughts. A generator that matches them that far has very little room
|
||||||
|
left to be wrong: the counts fold in compulsory capture, crowning and the
|
||||||
|
men's forward-only rule all at once.
|
||||||
@@ -0,0 +1,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.
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user