Add Checkers, Backgammon, Mastermind, and Pirates and Bulgars
The four games that were listed but not written. Nothing in the catalogue is marked "coming soon" any more. Checkers is English draughts on the thirty-two dark squares, numbered the way the game is written down. Captures are compulsory and a multiple jump is one move object, so a player cannot stop halfway through one — true by construction rather than by a rule that has to be enforced somewhere. The move generator matches the published perft counts at one to four ply (7, 49, 302, 1469), which fold in compulsory capture, crowning and the men's forward-only rule all at once. Backgammon's hard rule is not how a checker moves but that a player must use as much of the roll as they can, and that cannot be checked one move at a time: a move can be illegal precisely because playing it strands the other die. So the generator works backwards from whole turns — memoised depth-first over the remaining dice — and offers only the first steps of the longest plays. Recomputing it after each checker is what makes the rule true by construction. Every die comes from the table's seed and a count of rolls made, which is in the state rather than beside it so a game is reproducible from what crosses the wire. There is no doubling cube; the rules document says so plainly. Mastermind is played as a duel — both players are set a code and both are breaking one — because the pencil-and-paper game gives the codemaker nothing to do. It ends at the foot of a round, never mid-round, or going first would be worth half a guess. It is the one game here with anything to hide, so viewFor does real work: the host sends each device only its own code, and a device that was never given a code refuses to score a guess against it rather than quietly answering "nothing". Pirates and Bulgars is Sackson's theming of Asalto, and the rules document is explicit about which parts are attested and which had to be chosen — the book could not be obtained. Two hunters who take by jumping against twenty-four who cannot take at all, on a thirty-three point cross whose drawn lines come from the same adjacency table the moves come from, so the board and the rules cannot disagree. 130 unit tests across the four engines, all run locally as well as in CI: game/ has no Android in it, which is what makes that possible. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_019Pr6B8xDDtJJcb6WJMw7Ud
This commit is contained in:
@@ -40,6 +40,7 @@ object GameCatalog {
|
||||
const val CHECKERS = "checkers"
|
||||
const val TAYU = "tayu"
|
||||
const val MORRIS = "morris"
|
||||
const val PIRATES = "pirates"
|
||||
|
||||
val all: List<GameDescriptor> = listOf(
|
||||
GameDescriptor(
|
||||
@@ -119,8 +120,9 @@ object GameCatalog {
|
||||
minPlayers = 2,
|
||||
maxPlayers = 2,
|
||||
teamBased = false,
|
||||
blurb = "Break the hidden code from black and white peg clues.",
|
||||
available = false,
|
||||
blurb = "Break the hidden code from black and white peg clues. " +
|
||||
"Both players get one, and whoever cracks theirs first wins.",
|
||||
available = true,
|
||||
),
|
||||
GameDescriptor(
|
||||
id = BACKGAMMON,
|
||||
@@ -130,7 +132,7 @@ object GameCatalog {
|
||||
maxPlayers = 2,
|
||||
teamBased = false,
|
||||
blurb = "Race your checkers home, and hit your opponent on the way.",
|
||||
available = false,
|
||||
available = true,
|
||||
),
|
||||
GameDescriptor(
|
||||
id = CHESS,
|
||||
@@ -150,7 +152,7 @@ object GameCatalog {
|
||||
maxPlayers = 2,
|
||||
teamBased = false,
|
||||
blurb = "Forced captures, kings, and the long diagonal.",
|
||||
available = false,
|
||||
available = true,
|
||||
),
|
||||
GameDescriptor(
|
||||
id = MORRIS,
|
||||
@@ -162,6 +164,17 @@ object GameCatalog {
|
||||
blurb = "Three in a row takes a piece. Reduce them to two and the game is yours.",
|
||||
available = true,
|
||||
),
|
||||
GameDescriptor(
|
||||
id = PIRATES,
|
||||
title = "Pirates and Bulgars",
|
||||
category = GameCategory.BOARD,
|
||||
minPlayers = 2,
|
||||
maxPlayers = 2,
|
||||
teamBased = false,
|
||||
blurb = "Two pirates hold the stronghold against twenty-four Bulgars. " +
|
||||
"Only one side can take pieces, and it is not the big one.",
|
||||
available = true,
|
||||
),
|
||||
GameDescriptor(
|
||||
id = TAYU,
|
||||
title = "Ta Yü",
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
package org.prolibertate.games.game.backgammon
|
||||
|
||||
import kotlin.random.Random
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
* A backgammon player.
|
||||
*
|
||||
* This is not a search in the sense the chess and checkers players are. There
|
||||
* is no tree to walk: the opponent's reply depends on dice nobody has thrown
|
||||
* yet, so looking further ahead means averaging over twenty-one rolls rather
|
||||
* than choosing the best one. What it does instead is weigh **whole turns** —
|
||||
* every distinct way the roll can be played — and score the board each leaves
|
||||
* behind.
|
||||
*
|
||||
* Weighing turns rather than checkers is the whole point. A backgammon move is
|
||||
* only good in company: splitting the back checkers is right or wrong depending
|
||||
* on what the other die did, and a player that chose one checker at a time
|
||||
* would never see it.
|
||||
*
|
||||
* The interface hands back one checker at a time, so the best turn is worked
|
||||
* out and its first step returned; the rest follows on the moves after, because
|
||||
* the same calculation reaches the same turn.
|
||||
*/
|
||||
class BackgammonAi(private val level: BackgammonLevel? = null) :
|
||||
GameAi<BackgammonState, BackgammonMove> {
|
||||
|
||||
override fun chooseMove(
|
||||
state: BackgammonState,
|
||||
seat: Int,
|
||||
legal: List<BackgammonMove>,
|
||||
): BackgammonMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
val setting = level ?: state.options.level
|
||||
if (legal.size == 1) return legal.first()
|
||||
|
||||
val random = Random(state.rollsMade * 31L + state.moveLog.size)
|
||||
|
||||
if (setting == BackgammonLevel.CASUAL) {
|
||||
// Not thoughtless: it still prefers to hit, to come in off the bar
|
||||
// and to bear off, which is most of what a beginner knows.
|
||||
val scored = legal.map { move -> move to rough(state, seat, move) }
|
||||
val best = scored.maxOf { it.second }
|
||||
val equal = scored.filter { it.second == best }
|
||||
return equal[random.nextInt(equal.size)].first
|
||||
}
|
||||
|
||||
val sequences = BackgammonRules.deepestSequences(state, seat)
|
||||
if (sequences.isEmpty()) return legal.first()
|
||||
|
||||
var best = sequences.first()
|
||||
var bestScore = -Double.MAX_VALUE
|
||||
for (sequence in sequences) {
|
||||
val after = BackgammonRules.playSequence(state, seat, sequence)
|
||||
val score = if (setting == BackgammonLevel.STRONG) {
|
||||
// What the position is worth once the dice have had their say.
|
||||
evaluate(after, seat) - averageReply(after, other(seat))
|
||||
} else {
|
||||
evaluate(after, seat)
|
||||
}
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
best = sequence
|
||||
}
|
||||
}
|
||||
return best.first()
|
||||
}
|
||||
|
||||
/** A quick opinion of one checker, for the level that does not think. */
|
||||
private fun rough(state: BackgammonState, seat: Int, move: BackgammonMove): Int {
|
||||
val landing = BackgammonRules.destinationOf(seat, move.from, move.die)
|
||||
var score = 0
|
||||
if (move.from == BAR) score += 50
|
||||
if (landing == null) score += 40
|
||||
if (landing != null && state.countOn(landing, other(seat)) == 1) score += 60
|
||||
if (landing != null && state.countOn(landing, seat) >= 1) score += 15
|
||||
return score
|
||||
}
|
||||
|
||||
/**
|
||||
* What the opponent is likely to make of this position, averaged over the
|
||||
* twenty-one distinct rolls of two dice.
|
||||
*
|
||||
* Doubles come up half as often as a mixed pair — 6-5 can be thrown two
|
||||
* ways and 5-5 only one — so each roll is weighted rather than counted.
|
||||
* Only the opponent's best answer to each roll is taken: they will not
|
||||
* play a bad one on purpose.
|
||||
*/
|
||||
private fun averageReply(state: BackgammonState, opponent: Int): Double {
|
||||
if (state.phase == BackgammonPhase.GAME_OVER) return 0.0
|
||||
|
||||
var total = 0.0
|
||||
var weight = 0.0
|
||||
for (high in 1..6) {
|
||||
for (low in 1..high) {
|
||||
val chance = if (high == low) 1.0 else 2.0
|
||||
val dice = if (high == low) List(4) { high } else listOf(high, low)
|
||||
val rolled = state.copy(turn = opponent, dice = dice, roll = listOf(high, low))
|
||||
val replies = BackgammonRules.deepestSequences(rolled, opponent)
|
||||
val bestReply = replies.maxOfOrNull { sequence ->
|
||||
evaluate(BackgammonRules.playSequence(rolled, opponent, sequence), opponent)
|
||||
} ?: evaluate(rolled, opponent)
|
||||
total += chance * bestReply
|
||||
weight += chance
|
||||
}
|
||||
}
|
||||
return total / weight
|
||||
}
|
||||
|
||||
/**
|
||||
* The position from [seat]'s point of view.
|
||||
*
|
||||
* The pip count is the backbone of it — backgammon is a race, and the
|
||||
* player who is ahead in the race wants to keep it simple. Everything else
|
||||
* is about what happens when it is not simple: **a blot is only a liability
|
||||
* if it can be hit**, so exposure is weighted by how far away the enemy
|
||||
* checkers are, and a made point in your own home board is worth having
|
||||
* because it is one more number your opponent cannot come in on.
|
||||
*/
|
||||
private fun evaluate(state: BackgammonState, seat: Int): Double {
|
||||
val enemy = other(seat)
|
||||
|
||||
if (state.phase == BackgammonPhase.GAME_OVER) {
|
||||
val won = state.off[seat] >= CHECKERS
|
||||
return if (won) WIN else -WIN
|
||||
}
|
||||
|
||||
var score = 0.0
|
||||
|
||||
// The race. Being ahead in pips is the plainest good thing there is.
|
||||
score += (state.pipCount(enemy) - state.pipCount(seat)) * PIP
|
||||
|
||||
// Checkers home and dry, and checkers sent back to the beginning.
|
||||
score += state.off[seat] * BORNE_OFF
|
||||
score -= state.off[enemy] * BORNE_OFF
|
||||
score -= state.bar[seat] * ON_BAR
|
||||
score += state.bar[enemy] * ON_BAR
|
||||
|
||||
for (point in 0 until POINTS) {
|
||||
val mine = state.countOn(point, seat)
|
||||
if (mine == 0) continue
|
||||
|
||||
if (mine >= 2) {
|
||||
score += POINT_MADE
|
||||
// A point in your own home board is a door shut in the face of
|
||||
// anyone you send to the bar.
|
||||
if (point in homeOf(seat)) score += HOME_POINT
|
||||
// And an anchor deep in theirs is somewhere safe to come back to.
|
||||
if (point in homeOf(enemy)) score += ANCHOR
|
||||
} else {
|
||||
// A blot, weighted by how easily it can be reached: something
|
||||
// six away is in real danger, something twenty away is not.
|
||||
val distance = shortestEnemyDistance(state, seat, point)
|
||||
score -= when {
|
||||
distance <= 0 -> 0.0
|
||||
distance <= 6 -> BLOT_NEAR
|
||||
distance <= 12 -> BLOT_FAR
|
||||
else -> BLOT_DISTANT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
score += primeLength(state, seat) * PRIME
|
||||
return score
|
||||
}
|
||||
|
||||
/** How far the nearest enemy checker is from reaching [point], in pips. */
|
||||
private fun shortestEnemyDistance(state: BackgammonState, seat: Int, point: Int): Int {
|
||||
val enemy = other(seat)
|
||||
// Anything on the bar comes in at the far end and is a threat to the
|
||||
// whole board on its way round.
|
||||
if (state.bar[enemy] > 0) {
|
||||
val fromBar = if (enemy == WHITE_SEAT) 24 - point else point + 1
|
||||
if (fromBar in 1..6) return fromBar
|
||||
}
|
||||
var best = Int.MAX_VALUE
|
||||
for (from in 0 until POINTS) {
|
||||
if (state.countOn(from, enemy) == 0) continue
|
||||
val gap = (point - from) * directionOf(enemy)
|
||||
if (gap in 1..24 && gap < best) best = gap
|
||||
}
|
||||
return if (best == Int.MAX_VALUE) 0 else best
|
||||
}
|
||||
|
||||
/** The longest run of consecutive points held, which is what traps a checker. */
|
||||
private fun primeLength(state: BackgammonState, seat: Int): Int {
|
||||
var best = 0
|
||||
var run = 0
|
||||
for (point in 0 until POINTS) {
|
||||
if (state.countOn(point, seat) >= 2) {
|
||||
run++
|
||||
if (run > best) best = run
|
||||
} else {
|
||||
run = 0
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val WIN = 10_000.0
|
||||
|
||||
const val PIP = 1.0
|
||||
const val BORNE_OFF = 12.0
|
||||
const val ON_BAR = 25.0
|
||||
const val POINT_MADE = 4.0
|
||||
const val HOME_POINT = 8.0
|
||||
const val ANCHOR = 6.0
|
||||
const val PRIME = 5.0
|
||||
const val BLOT_NEAR = 14.0
|
||||
const val BLOT_FAR = 6.0
|
||||
const val BLOT_DISTANT = 2.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package org.prolibertate.games.game.backgammon
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The board is twenty-four points, 0 to 23.
|
||||
*
|
||||
* **White runs down the numbers and bears off past 0; Black runs up them and
|
||||
* bears off past 23.** White's home board is points 0–5, Black's is 18–23, and
|
||||
* each enters from the bar into the *other* player's home: White at 24 minus
|
||||
* the die, Black at the die minus one.
|
||||
*
|
||||
* A point holds a positive number of White checkers or a negative number of
|
||||
* Black ones, never both — which is the rule that a point belongs to whoever
|
||||
* has two or more on it, expressed as arithmetic rather than as a check.
|
||||
*/
|
||||
const val POINTS = 24
|
||||
|
||||
const val CHECKERS = 15
|
||||
|
||||
const val WHITE_SEAT = 0
|
||||
const val BLACK_SEAT = 1
|
||||
|
||||
/** Where a checker sits when it has been hit, as a [BackgammonMove.from]. */
|
||||
const val BAR = 24
|
||||
|
||||
fun other(seat: Int): Int = 1 - seat
|
||||
|
||||
fun seatName(seat: Int): String = if (seat == WHITE_SEAT) "White" else "Black"
|
||||
|
||||
/** Which way this seat's checkers travel along the numbering. */
|
||||
fun directionOf(seat: Int): Int = if (seat == WHITE_SEAT) -1 else 1
|
||||
|
||||
/** The six points this seat bears off from. */
|
||||
fun homeOf(seat: Int): IntRange = if (seat == WHITE_SEAT) 0..5 else 18..23
|
||||
|
||||
/** Where a checker coming off the bar lands for a given die. */
|
||||
fun entryPoint(seat: Int, die: Int): Int = if (seat == WHITE_SEAT) 24 - die else die - 1
|
||||
|
||||
/**
|
||||
* How far a point is from being borne off, counted in pips: 1 for the last
|
||||
* point before home, 24 for the far corner. The pip count is the sum of these
|
||||
* over a player's checkers and is the single most useful number in the game.
|
||||
*/
|
||||
fun pipsFrom(seat: Int, point: Int): Int = if (seat == WHITE_SEAT) point + 1 else 24 - point
|
||||
|
||||
@Serializable
|
||||
enum class BackgammonPhase { PLAYING, GAME_OVER }
|
||||
|
||||
/**
|
||||
* How a game ended. Backgammon scores by how badly the loser lost: a plain win
|
||||
* is worth one point, a gammon two — the loser bore nothing off — and a
|
||||
* backgammon three, where the loser still had a checker on the bar or in the
|
||||
* winner's home when it finished.
|
||||
*/
|
||||
@Serializable
|
||||
enum class BackgammonOutcome(val label: String, val points: Int) {
|
||||
WHITE_SINGLE("White wins", 1),
|
||||
WHITE_GAMMON("White wins a gammon — Black bore nothing off", 2),
|
||||
WHITE_BACKGAMMON("White wins a backgammon — Black was still stranded", 3),
|
||||
BLACK_SINGLE("Black wins", 1),
|
||||
BLACK_GAMMON("Black wins a gammon — White bore nothing off", 2),
|
||||
BLACK_BACKGAMMON("Black wins a backgammon — White was still stranded", 3),
|
||||
}
|
||||
|
||||
/** How hard the computer thinks about it. */
|
||||
@Serializable
|
||||
enum class BackgammonLevel(val label: String) {
|
||||
/** Takes the first playable thing it finds a reason for. */
|
||||
CASUAL("Casual"),
|
||||
|
||||
/** Weighs every way the turn could be played and takes the best. */
|
||||
CLUB("Club"),
|
||||
|
||||
/**
|
||||
* The same, and then weighs what the dice might do to it next — every one
|
||||
* of the twenty-one distinct rolls, and the best reply to each.
|
||||
*/
|
||||
STRONG("Strong"),
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class BackgammonOptions(
|
||||
val level: BackgammonLevel = BackgammonLevel.CLUB,
|
||||
/**
|
||||
* Score a gammon as two and a backgammon as three, rather than calling
|
||||
* every win a win. It changes nothing about the moves and everything about
|
||||
* whether a hopeless game is worth playing out.
|
||||
*/
|
||||
val countGammons: Boolean = true,
|
||||
)
|
||||
|
||||
/**
|
||||
* One checker moving one die's worth.
|
||||
*
|
||||
* A turn is two moves, or four on a double, and each is submitted on its own —
|
||||
* which is how a person plays it, one checker at a time. What keeps that honest
|
||||
* is that the legal moves are recomputed after each one: a move that would
|
||||
* strand the rest of the turn is never offered, because a move is only legal if
|
||||
* it starts a way of playing the whole roll.
|
||||
*/
|
||||
@Serializable
|
||||
data class BackgammonMove(val from: Int, val die: Int) {
|
||||
override fun toString(): String {
|
||||
val origin = if (from == BAR) "bar" else "${from + 1}"
|
||||
return "$origin/$die"
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class BackgammonState(
|
||||
val options: BackgammonOptions,
|
||||
/**
|
||||
* The table's own seed, which every die of the game is thrown from
|
||||
* together with [rollsMade]. It is part of the state rather than kept
|
||||
* beside it so that a game is reproducible from what crosses the wire, and
|
||||
* so two games running at once cannot get hold of each other's dice.
|
||||
*
|
||||
* Nothing is given away by sending it: backgammon has nothing hidden, and
|
||||
* the host is authoritative about every roll in any case.
|
||||
*/
|
||||
val seed: Long,
|
||||
/** Positive is that many White checkers, negative that many Black ones. */
|
||||
val points: List<Int>,
|
||||
/** Checkers waiting to come back on, by seat. */
|
||||
val bar: List<Int>,
|
||||
/** Checkers safely borne off, by seat. */
|
||||
val off: List<Int>,
|
||||
val turn: Int,
|
||||
/** The dice still to be played this turn. A double is four of them. */
|
||||
val dice: List<Int>,
|
||||
/** The pair as it was rolled, kept so the screen can show it whole. */
|
||||
val roll: List<Int>,
|
||||
/** How many rolls have been made, which is what makes the next one. */
|
||||
val rollsMade: Int,
|
||||
val phase: BackgammonPhase,
|
||||
val outcome: BackgammonOutcome?,
|
||||
val lastMove: BackgammonMove?,
|
||||
val moveLog: List<String>,
|
||||
) {
|
||||
fun countOn(point: Int, seat: Int): Int {
|
||||
val value = points[point]
|
||||
return if (seat == WHITE_SEAT) maxOf(value, 0) else maxOf(-value, 0)
|
||||
}
|
||||
|
||||
fun ownerOf(point: Int): Int? = when {
|
||||
points[point] > 0 -> WHITE_SEAT
|
||||
points[point] < 0 -> BLACK_SEAT
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** A single checker, which can be hit and sent back to the bar. */
|
||||
fun isBlot(point: Int): Boolean = points[point] == 1 || points[point] == -1
|
||||
|
||||
/** Two or more of the enemy's: nowhere to land. */
|
||||
fun isBlockedFor(point: Int, seat: Int): Boolean = countOn(point, other(seat)) >= 2
|
||||
|
||||
/** Everything home or borne off, which is what bearing off waits for. */
|
||||
fun canBearOff(seat: Int): Boolean {
|
||||
if (bar[seat] > 0) return false
|
||||
val home = homeOf(seat)
|
||||
return (0 until POINTS).none { it !in home && countOn(it, seat) > 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* The pip count: how many pips this seat still has to travel. A checker on
|
||||
* the bar has the whole board to cross, which is why it costs 25.
|
||||
*/
|
||||
fun pipCount(seat: Int): Int {
|
||||
var pips = bar[seat] * 25
|
||||
for (point in 0 until POINTS) pips += countOn(point, seat) * pipsFrom(seat, point)
|
||||
return pips
|
||||
}
|
||||
|
||||
fun checkersLeft(seat: Int): Int = CHECKERS - off[seat]
|
||||
}
|
||||
|
||||
/**
|
||||
* The standard opening array, from White's point of view: two on the 24, five
|
||||
* on the 13, three on the 8 and five on the 6 — mirrored for Black.
|
||||
*/
|
||||
fun startingPoints(): List<Int> {
|
||||
val points = IntArray(POINTS)
|
||||
points[23] = 2
|
||||
points[12] = 5
|
||||
points[7] = 3
|
||||
points[5] = 5
|
||||
points[0] = -2
|
||||
points[11] = -5
|
||||
points[16] = -3
|
||||
points[18] = -5
|
||||
return points.toList()
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
package org.prolibertate.games.game.backgammon
|
||||
|
||||
import kotlin.random.Random
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.engine.GameRules
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
|
||||
/**
|
||||
* Backgammon as a pure state machine. See RULES-backgammon.md.
|
||||
*
|
||||
* The hard rule in backgammon is not how a checker moves, it is that **a player
|
||||
* must use as much of the roll as they can**. It cannot be checked one move at
|
||||
* a time, because whether a move is legal depends on what is left afterwards: a
|
||||
* move that looks fine can be illegal precisely because playing it strands the
|
||||
* other die.
|
||||
*
|
||||
* So the generator works backwards from whole turns. It searches out every way
|
||||
* the roll could be played, keeps only the longest, and offers their first
|
||||
* steps. Recomputing that after each checker means the rule is enforced by
|
||||
* construction — a player physically cannot reach a position where the rest of
|
||||
* their roll is unplayable when it need not have been.
|
||||
*
|
||||
* The dice are rolled from the table's own seed and a count of the rolls made,
|
||||
* so a game is reproducible from the config it started with and a host and a
|
||||
* client cannot disagree about what was thrown.
|
||||
*/
|
||||
object BackgammonRules : GameRules<BackgammonState, BackgammonMove> {
|
||||
|
||||
override val gameId: String = GameCatalog.BACKGAMMON
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
override fun initialState(config: TableConfig): BackgammonState {
|
||||
require(config.seats.size == 2) { "Backgammon is a two-handed game" }
|
||||
val options = json.decodeFromString<BackgammonOptions>(config.optionsJson)
|
||||
return initialState(options, config.seed)
|
||||
}
|
||||
|
||||
fun initialState(
|
||||
options: BackgammonOptions = BackgammonOptions(),
|
||||
seed: Long = 0L,
|
||||
): BackgammonState {
|
||||
// The opening roll is one die each and the higher starts, playing both
|
||||
// — so a double is thrown again rather than played.
|
||||
var rolls = 0
|
||||
var white: Int
|
||||
var black: Int
|
||||
do {
|
||||
white = die(seed, rolls++)
|
||||
black = die(seed, rolls++)
|
||||
} while (white == black)
|
||||
|
||||
val opener = if (white > black) WHITE_SEAT else BLACK_SEAT
|
||||
val roll = listOf(white, black).sortedDescending()
|
||||
|
||||
return BackgammonState(
|
||||
options = options,
|
||||
seed = seed,
|
||||
points = startingPoints(),
|
||||
bar = listOf(0, 0),
|
||||
off = listOf(0, 0),
|
||||
turn = opener,
|
||||
dice = roll,
|
||||
roll = roll,
|
||||
rollsMade = rolls,
|
||||
phase = BackgammonPhase.PLAYING,
|
||||
outcome = null,
|
||||
lastMove = null,
|
||||
moveLog = emptyList(),
|
||||
).skipIfStuck()
|
||||
}
|
||||
|
||||
private fun die(seed: Long, index: Int): Int = Random(seed * 1_000_003L + index).nextInt(6) + 1
|
||||
|
||||
override fun currentSeat(state: BackgammonState): Int? =
|
||||
if (state.phase == BackgammonPhase.GAME_OVER) null else state.turn
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Moving one checker
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Where a checker from [from] lands with [die], or null if it runs off the board. */
|
||||
fun destinationOf(seat: Int, from: Int, die: Int): Int? {
|
||||
if (from == BAR) return entryPoint(seat, die)
|
||||
val landing = from + directionOf(seat) * die
|
||||
return landing.takeIf { it in 0 until POINTS }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether one checker may be played, ignoring what it leaves behind. The
|
||||
* "use as much of the roll as you can" rule is applied by [legalMoves] on
|
||||
* top of this.
|
||||
*/
|
||||
private fun canPlay(state: BackgammonState, seat: Int, move: BackgammonMove): Boolean {
|
||||
if (move.die !in state.dice) return false
|
||||
|
||||
// Anything on the bar comes back on first; nothing else may move.
|
||||
if (state.bar[seat] > 0 && move.from != BAR) return false
|
||||
if (move.from == BAR) {
|
||||
if (state.bar[seat] <= 0) return false
|
||||
val entry = entryPoint(seat, move.die)
|
||||
return !state.isBlockedFor(entry, seat)
|
||||
}
|
||||
|
||||
if (state.countOn(move.from, seat) <= 0) return false
|
||||
|
||||
val landing = destinationOf(seat, move.from, move.die)
|
||||
if (landing != null) return !state.isBlockedFor(landing, seat)
|
||||
|
||||
// Off the end of the board is bearing off, which has rules of its own.
|
||||
return canBearOffFrom(state, seat, move.from, move.die)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearing off: the exact die takes a checker from the point that needs it,
|
||||
* and a bigger die may take one from the highest occupied point when
|
||||
* nothing sits further back. An overshooting die may never take a checker
|
||||
* that a smaller one could have reached.
|
||||
*/
|
||||
private fun canBearOffFrom(
|
||||
state: BackgammonState,
|
||||
seat: Int,
|
||||
from: Int,
|
||||
die: Int,
|
||||
): Boolean {
|
||||
if (!state.canBearOff(seat)) return false
|
||||
val needed = pipsFrom(seat, from)
|
||||
if (die == needed) return true
|
||||
if (die < needed) return false
|
||||
// The die overshoots, so it is only allowed if this is the furthest
|
||||
// checker from home.
|
||||
return (0 until POINTS).none { point ->
|
||||
state.countOn(point, seat) > 0 && pipsFrom(seat, point) > needed
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies one checker's move to the board, hitting a blot if there is one. */
|
||||
private fun place(state: BackgammonState, seat: Int, move: BackgammonMove): BackgammonState {
|
||||
val points = state.points.toMutableList()
|
||||
val bar = state.bar.toMutableList()
|
||||
val off = state.off.toMutableList()
|
||||
val sign = if (seat == WHITE_SEAT) 1 else -1
|
||||
|
||||
if (move.from == BAR) {
|
||||
bar[seat] = bar[seat] - 1
|
||||
} else {
|
||||
points[move.from] = points[move.from] - sign
|
||||
}
|
||||
|
||||
val landing = destinationOf(seat, move.from, move.die)
|
||||
if (landing == null) {
|
||||
off[seat] = off[seat] + 1
|
||||
} else {
|
||||
// A lone enemy checker on the landing point is hit and starts again.
|
||||
if (state.countOn(landing, other(seat)) == 1) {
|
||||
points[landing] = 0
|
||||
bar[other(seat)] = bar[other(seat)] + 1
|
||||
}
|
||||
points[landing] = points[landing] + sign
|
||||
}
|
||||
|
||||
val dice = state.dice.toMutableList()
|
||||
dice.remove(move.die)
|
||||
|
||||
return state.copy(points = points, bar = bar, off = off, dice = dice, lastMove = move)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Moving a whole turn
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fun legalMoves(state: BackgammonState): List<BackgammonMove> = legalMoves(state, state.turn)
|
||||
|
||||
/**
|
||||
* The first steps of every way of playing as much of the roll as possible.
|
||||
*
|
||||
* Two ways of playing that both use every die are equally legal however
|
||||
* they differ; one that uses a single die when both could have been used is
|
||||
* not a legal turn at all. And when only one die can be played, it must be
|
||||
* the higher — which is the one part of the rule that does not fall out of
|
||||
* counting alone, so it is applied by hand at the end.
|
||||
*/
|
||||
override fun legalMoves(state: BackgammonState, seat: Int): List<BackgammonMove> {
|
||||
if (state.phase == BackgammonPhase.GAME_OVER || state.turn != seat) return emptyList()
|
||||
if (state.dice.isEmpty()) return emptyList()
|
||||
|
||||
val memo = mutableMapOf<String, Int>()
|
||||
val target = depthOf(state, seat, memo)
|
||||
if (target == 0) return emptyList()
|
||||
|
||||
val moves = candidates(state, seat).filter { move ->
|
||||
1 + depthOf(place(state, seat, move), seat, memo) == target
|
||||
}.distinct()
|
||||
|
||||
if (target == 1) {
|
||||
val highest = moves.maxOf { it.die }
|
||||
return moves.filter { it.die == highest }
|
||||
}
|
||||
return moves
|
||||
}
|
||||
|
||||
/**
|
||||
* How many of the remaining dice can still be played, at best.
|
||||
*
|
||||
* Memoised on the position rather than on the path to it: the four orders a
|
||||
* double can be played in mostly reach the same boards, and without the
|
||||
* memo the search would walk every one of them separately. The cut-off when
|
||||
* the best possible depth is reached matters just as much — most turns find
|
||||
* a full-length play immediately and never look at the rest.
|
||||
*/
|
||||
private fun depthOf(
|
||||
state: BackgammonState,
|
||||
seat: Int,
|
||||
memo: MutableMap<String, Int>,
|
||||
): Int {
|
||||
if (state.dice.isEmpty()) return 0
|
||||
val key = turnKey(state)
|
||||
memo[key]?.let { return it }
|
||||
|
||||
var best = 0
|
||||
for (move in candidates(state, seat)) {
|
||||
val depth = 1 + depthOf(place(state, seat, move), seat, memo)
|
||||
if (depth > best) best = depth
|
||||
if (best == state.dice.size) break
|
||||
}
|
||||
memo[key] = best
|
||||
return best
|
||||
}
|
||||
|
||||
/** What a half-played turn amounts to: the board, the bar, and the dice left. */
|
||||
private fun turnKey(state: BackgammonState): String =
|
||||
state.points.joinToString(",") + "|" + state.bar.joinToString(",") +
|
||||
"|" + state.off.joinToString(",") + "|" + state.dice.sorted().joinToString(",")
|
||||
|
||||
/**
|
||||
* Every longest way of playing what is left of the roll.
|
||||
*
|
||||
* Only for the computer, which wants to weigh whole turns rather than
|
||||
* single checkers. Branches that cannot reach full length are cut before
|
||||
* they are walked, and the list is capped: a double with checkers spread
|
||||
* across the board has more orderings than are worth looking at, and they
|
||||
* are mostly the same play in a different order anyway.
|
||||
*/
|
||||
fun deepestSequences(state: BackgammonState, seat: Int): List<List<BackgammonMove>> {
|
||||
val memo = mutableMapOf<String, Int>()
|
||||
val target = depthOf(state, seat, memo)
|
||||
if (target == 0) return emptyList()
|
||||
|
||||
val found = mutableListOf<List<BackgammonMove>>()
|
||||
val seen = mutableSetOf<String>()
|
||||
|
||||
fun walk(current: BackgammonState, played: List<BackgammonMove>) {
|
||||
if (found.size >= SEQUENCE_CAP) return
|
||||
if (played.size == target) {
|
||||
// Two orderings that end on the same board are one play.
|
||||
if (seen.add(turnKey(current))) found += played
|
||||
return
|
||||
}
|
||||
for (move in candidates(current, seat)) {
|
||||
val next = place(current, seat, move)
|
||||
if (played.size + 1 + depthOf(next, seat, memo) == target) {
|
||||
walk(next, played + move)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(state, emptyList())
|
||||
|
||||
if (target == 1 && found.isNotEmpty()) {
|
||||
val highest = found.maxOf { it.single().die }
|
||||
return found.filter { it.single().die == highest }
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/** The checkers that could move with one of the dice still in hand. */
|
||||
private fun candidates(state: BackgammonState, seat: Int): List<BackgammonMove> {
|
||||
if (state.dice.isEmpty()) return emptyList()
|
||||
val dice = state.dice.distinct()
|
||||
val origins = if (state.bar[seat] > 0) {
|
||||
listOf(BAR)
|
||||
} else {
|
||||
(0 until POINTS).filter { state.countOn(it, seat) > 0 }
|
||||
}
|
||||
return origins.flatMap { from ->
|
||||
dice.mapNotNull { die ->
|
||||
BackgammonMove(from, die).takeIf { canPlay(state, seat, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun applyMove(
|
||||
state: BackgammonState,
|
||||
seat: Int,
|
||||
move: BackgammonMove,
|
||||
): BackgammonState {
|
||||
require(state.phase != BackgammonPhase.GAME_OVER) { "The game is over" }
|
||||
require(state.turn == seat) { "It is not seat $seat's turn" }
|
||||
require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" }
|
||||
|
||||
val played = place(state, seat, move)
|
||||
.copy(moveLog = state.moveLog + "${seatName(seat)} $move")
|
||||
|
||||
val finished = played.withTerminalCheck()
|
||||
if (finished.phase == BackgammonPhase.GAME_OVER) return finished
|
||||
|
||||
// The turn ends when the dice run out or nothing more can be played.
|
||||
return if (played.dice.isEmpty() || legalMoves(played, seat).isEmpty()) {
|
||||
played.passTurn()
|
||||
} else {
|
||||
played
|
||||
}
|
||||
}
|
||||
|
||||
/** Hands the dice to the other player and throws them. */
|
||||
private fun BackgammonState.passTurn(): BackgammonState {
|
||||
val next = other(turn)
|
||||
val first = die(seed, rollsMade)
|
||||
val second = die(seed, rollsMade + 1)
|
||||
// A double is four moves of the same number, which is the one piece of
|
||||
// luck in backgammon big enough to decide a game on its own.
|
||||
val thrown = if (first == second) List(4) { first } else listOf(first, second)
|
||||
return copy(
|
||||
turn = next,
|
||||
dice = thrown,
|
||||
roll = listOf(first, second),
|
||||
rollsMade = rollsMade + 2,
|
||||
).skipIfStuck()
|
||||
}
|
||||
|
||||
/**
|
||||
* A player with nothing they can play loses the turn.
|
||||
*
|
||||
* Usually this is somebody stuck on the bar against a closed board. It can
|
||||
* happen several times over, so it keeps rolling until somebody can move —
|
||||
* with a cap, because a board that closed against a player who has nothing
|
||||
* else on it would otherwise roll for ever.
|
||||
*/
|
||||
private fun BackgammonState.skipIfStuck(): BackgammonState {
|
||||
var state = this
|
||||
var skipped = 0
|
||||
while (state.phase != BackgammonPhase.GAME_OVER &&
|
||||
legalMoves(state, state.turn).isEmpty() &&
|
||||
skipped < MAX_SKIPS
|
||||
) {
|
||||
val next = other(state.turn)
|
||||
val first = die(state.seed, state.rollsMade)
|
||||
val second = die(state.seed, state.rollsMade + 1)
|
||||
val thrown = if (first == second) List(4) { first } else listOf(first, second)
|
||||
state = state.copy(
|
||||
turn = next,
|
||||
dice = thrown,
|
||||
roll = listOf(first, second),
|
||||
rollsMade = state.rollsMade + 2,
|
||||
moveLog = state.moveLog + "${seatName(state.turn)} cannot move",
|
||||
)
|
||||
skipped++
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Fifteen checkers off is the game. How much it is worth depends on how the
|
||||
* loser was left: nothing borne off is a gammon, and still being on the bar
|
||||
* or in the winner's home on top of that is a backgammon.
|
||||
*/
|
||||
private fun BackgammonState.withTerminalCheck(): BackgammonState {
|
||||
val winner = (0..1).firstOrNull { off[it] >= CHECKERS } ?: return this
|
||||
val loser = other(winner)
|
||||
|
||||
val stranded = bar[loser] > 0 || homeOf(winner).any { countOn(it, loser) > 0 }
|
||||
val severity = when {
|
||||
!options.countGammons || off[loser] > 0 -> 0
|
||||
stranded -> 2
|
||||
else -> 1
|
||||
}
|
||||
|
||||
val outcome = if (winner == WHITE_SEAT) {
|
||||
when (severity) {
|
||||
2 -> BackgammonOutcome.WHITE_BACKGAMMON
|
||||
1 -> BackgammonOutcome.WHITE_GAMMON
|
||||
else -> BackgammonOutcome.WHITE_SINGLE
|
||||
}
|
||||
} else {
|
||||
when (severity) {
|
||||
2 -> BackgammonOutcome.BLACK_BACKGAMMON
|
||||
1 -> BackgammonOutcome.BLACK_GAMMON
|
||||
else -> BackgammonOutcome.BLACK_SINGLE
|
||||
}
|
||||
}
|
||||
return copy(phase = BackgammonPhase.GAME_OVER, outcome = outcome, dice = emptyList())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Engine contract
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
override fun isFinished(state: BackgammonState): Boolean =
|
||||
state.phase == BackgammonPhase.GAME_OVER
|
||||
|
||||
override fun summary(state: BackgammonState): String = state.outcome?.label ?: buildString {
|
||||
append(seatName(state.turn))
|
||||
append(" to play ")
|
||||
append(state.roll.joinToString("-"))
|
||||
if (state.dice.size < state.roll.size) append(" (${state.dice.size} left)")
|
||||
}
|
||||
|
||||
/** Backgammon hides nothing: the board and the dice are on the table. */
|
||||
override fun viewFor(state: BackgammonState, seat: Int): BackgammonState = state
|
||||
|
||||
override fun encodeState(state: BackgammonState): String = json.encodeToString(state)
|
||||
override fun decodeState(json: String): BackgammonState = this.json.decodeFromString(json)
|
||||
override fun encodeMove(move: BackgammonMove): String = json.encodeToString(move)
|
||||
override fun decodeMove(json: String): BackgammonMove = this.json.decodeFromString(json)
|
||||
|
||||
/**
|
||||
* Plays a whole turn's worth of moves in one go, for the search and the
|
||||
* tests. Anything that ends the turn stops it early.
|
||||
*/
|
||||
fun playSequence(
|
||||
state: BackgammonState,
|
||||
seat: Int,
|
||||
sequence: List<BackgammonMove>,
|
||||
): BackgammonState {
|
||||
var current = state
|
||||
for (move in sequence) {
|
||||
if (current.phase == BackgammonPhase.GAME_OVER || current.turn != seat) break
|
||||
current = applyMove(current, seat, move)
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
private const val MAX_SKIPS = 8
|
||||
|
||||
/** How many distinct ways of playing one roll the computer will weigh. */
|
||||
private const val SEQUENCE_CAP = 300
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package org.prolibertate.games.game.checkers
|
||||
|
||||
import kotlin.random.Random
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
* A negamax search with alpha-beta pruning and iterative deepening.
|
||||
*
|
||||
* Checkers can be searched deeper than chess for the same money: captures are
|
||||
* compulsory, so most positions offer a handful of moves rather than thirty,
|
||||
* and a forced capture costs nothing to search. That is why the levels here go
|
||||
* to seven ply where the chess ones stop at four.
|
||||
*
|
||||
* There is no quiescence tail. The usual reason for one is a search stopping in
|
||||
* the middle of an exchange, and compulsory capture takes care of that by
|
||||
* itself: a position where a piece has just been taken almost always has the
|
||||
* recapture as its only legal move, so the search plays it whether it wanted to
|
||||
* or not.
|
||||
*/
|
||||
class CheckersAi(private val level: CheckersLevel? = null) :
|
||||
GameAi<CheckersState, CheckersMove> {
|
||||
|
||||
private var nodeBudget = 0
|
||||
private var nodesUsed = 0
|
||||
|
||||
override fun chooseMove(
|
||||
state: CheckersState,
|
||||
seat: Int,
|
||||
legal: List<CheckersMove>,
|
||||
): CheckersMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
val setting = level ?: state.options.level
|
||||
if (legal.size == 1) return legal.first()
|
||||
|
||||
nodeBudget = NODE_BUDGET
|
||||
nodesUsed = 0
|
||||
|
||||
val random = Random(state.moveLog.size * 31L + state.positionKey().hashCode())
|
||||
|
||||
var best = legal.first()
|
||||
var scored = legal.map { it to 0 }
|
||||
|
||||
for (depth in 1..setting.depth) {
|
||||
val results = mutableListOf<Pair<CheckersMove, Int>>()
|
||||
val ordered = order(legal, preferred = best)
|
||||
for (move in ordered) {
|
||||
val score = -negamax(
|
||||
state = CheckersRules.advanced(state, move),
|
||||
depth = depth - 1,
|
||||
alpha = -INFINITY,
|
||||
beta = INFINITY,
|
||||
ply = 1,
|
||||
)
|
||||
results += move to score
|
||||
if (nodesUsed >= nodeBudget) break
|
||||
}
|
||||
if (nodesUsed >= nodeBudget && results.size < ordered.size) break
|
||||
scored = results
|
||||
best = results.maxBy { it.second }.first
|
||||
}
|
||||
|
||||
val bestScore = scored.maxOf { it.second }
|
||||
val acceptable = scored.filter { it.second >= bestScore - setting.slack() }
|
||||
return acceptable[random.nextInt(acceptable.size)].first
|
||||
}
|
||||
|
||||
private fun CheckersLevel.slack(): Int = when (this) {
|
||||
CheckersLevel.CASUAL -> 80
|
||||
CheckersLevel.CLUB -> 20
|
||||
CheckersLevel.STRONG -> 0
|
||||
}
|
||||
|
||||
private fun negamax(state: CheckersState, depth: Int, alpha: Int, beta: Int, ply: Int): Int {
|
||||
nodesUsed++
|
||||
|
||||
if (state.phase == CheckersPhase.GAME_OVER) {
|
||||
// The rules leave the loser on the clock, so a finished position is
|
||||
// always a loss for the side to move — or a draw.
|
||||
return when (state.outcome) {
|
||||
CheckersOutcome.DRAW_NO_PROGRESS, CheckersOutcome.DRAW_REPETITION, null -> 0
|
||||
else -> -(WIN - ply)
|
||||
}
|
||||
}
|
||||
if (depth <= 0) return evaluate(state)
|
||||
|
||||
val moves = CheckersRules.legalMoves(state)
|
||||
if (moves.isEmpty()) return -(WIN - ply)
|
||||
|
||||
var localAlpha = alpha
|
||||
for (move in order(moves, preferred = null)) {
|
||||
val score = -negamax(
|
||||
state = CheckersRules.advanced(state, move),
|
||||
depth = depth - 1,
|
||||
alpha = -beta,
|
||||
beta = -localAlpha,
|
||||
ply = ply + 1,
|
||||
)
|
||||
if (score >= beta) return beta
|
||||
if (score > localAlpha) localAlpha = score
|
||||
if (nodesUsed >= nodeBudget) break
|
||||
}
|
||||
return localAlpha
|
||||
}
|
||||
|
||||
/** Longest captures first: they take the most and they cut the most. */
|
||||
private fun order(moves: List<CheckersMove>, preferred: CheckersMove?): List<CheckersMove> =
|
||||
moves.sortedByDescending { move ->
|
||||
when {
|
||||
move == preferred -> 1_000
|
||||
else -> move.steps.size * 10
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The position from the point of view of the side to move.
|
||||
*
|
||||
* Material first, then two things that decide checkers games far more than
|
||||
* beginners expect: **kings are worth much more than men**, and **men are
|
||||
* worth more the closer they are to being crowned**, so a search with
|
||||
* nothing else to choose between will push its men up the board rather than
|
||||
* shuffle along the back.
|
||||
*
|
||||
* The back row is worth holding for the opposite reason — an empty back row
|
||||
* is a road to a crown for the other side — so the two pull against each
|
||||
* other, which is about right.
|
||||
*/
|
||||
private fun evaluate(state: CheckersState): Int {
|
||||
val us = state.turn
|
||||
val them = other(us)
|
||||
var score = 0
|
||||
|
||||
for (square in 0 until SQUARES) {
|
||||
val piece = state.board[square] ?: continue
|
||||
val sign = if (piece.seat == us) 1 else -1
|
||||
score += sign * if (piece.king) KING else MAN
|
||||
|
||||
if (!piece.king) {
|
||||
// How many rows it has come, out of the seven it needs.
|
||||
val advanced = if (piece.seat == BLACK_SEAT) rowOf(square) else 7 - rowOf(square)
|
||||
score += sign * advanced * ADVANCE
|
||||
}
|
||||
|
||||
// Sitting on the edge cannot be jumped, which is worth a little in
|
||||
// a game whose entire tactic is being jumped.
|
||||
if (columnOf(square) == 0 || columnOf(square) == 7) score += sign * EDGE
|
||||
|
||||
if (rowOf(square) == crownRowOf(other(piece.seat))) score += sign * BACK_ROW
|
||||
}
|
||||
|
||||
score += MOBILITY * (mobility(state, us) - mobility(state, them))
|
||||
return score
|
||||
}
|
||||
|
||||
/**
|
||||
* Somewhere to go, counted as piece-to-empty-square steps rather than as
|
||||
* legal moves: the legal list is only ever the captures when a capture
|
||||
* exists, and a position is not immobile for being obliged to take.
|
||||
*/
|
||||
private fun mobility(state: CheckersState, seat: Int): Int {
|
||||
var count = 0
|
||||
for (square in 0 until SQUARES) {
|
||||
val piece = state.board[square] ?: continue
|
||||
if (piece.seat != seat) continue
|
||||
val directions = if (piece.king) {
|
||||
listOf(1 to 1, 1 to -1, -1 to 1, -1 to -1)
|
||||
} else {
|
||||
listOf(forwardOf(seat) to 1, forwardOf(seat) to -1)
|
||||
}
|
||||
for ((rowStep, columnStep) in directions) {
|
||||
val target = squareAt(rowOf(square) + rowStep, columnOf(square) + columnStep)
|
||||
if (target != null && state.board[target] == null) count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val INFINITY = 1_000_000
|
||||
const val WIN = 100_000
|
||||
const val NODE_BUDGET = 200_000
|
||||
|
||||
const val MAN = 100
|
||||
const val KING = 175
|
||||
const val ADVANCE = 6
|
||||
const val EDGE = 4
|
||||
const val BACK_ROW = 5
|
||||
const val MOBILITY = 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package org.prolibertate.games.game.checkers
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The board is the thirty-two dark squares, numbered 1–32 the way checkers has
|
||||
* always been written down: square 1 at Black's left-hand end of the top row,
|
||||
* running left to right and down the board to 32.
|
||||
*
|
||||
* Only dark squares are ever played on, so numbering them alone means a move is
|
||||
* two small numbers rather than two coordinates, the notation the game is
|
||||
* recorded in falls out for free, and there is no such thing as an illegal
|
||||
* light square to have to rule out.
|
||||
*
|
||||
* Internally the squares are 0-based; [squareName] adds the one.
|
||||
*/
|
||||
const val SQUARES = 32
|
||||
|
||||
/** The row a square sits on, 0 at the top of the board. */
|
||||
fun rowOf(square: Int): Int = square / 4
|
||||
|
||||
/**
|
||||
* The column, 0 at the left. Dark squares alternate which half of a pair of
|
||||
* columns they fall in, which is what the odd-row offset is doing.
|
||||
*/
|
||||
fun columnOf(square: Int): Int {
|
||||
val row = rowOf(square)
|
||||
val within = square % 4
|
||||
return within * 2 + if (row % 2 == 0) 1 else 0
|
||||
}
|
||||
|
||||
/** The dark square at a row and column, or null if that square is light. */
|
||||
fun squareAt(row: Int, column: Int): Int? {
|
||||
if (row !in 0..7 || column !in 0..7) return null
|
||||
val offset = if (row % 2 == 0) 1 else 0
|
||||
if ((column - offset) % 2 != 0) return null
|
||||
return row * 4 + (column - offset) / 2
|
||||
}
|
||||
|
||||
fun squareName(square: Int): String = "${square + 1}"
|
||||
|
||||
@Serializable
|
||||
enum class CheckersPhase { PLAYING, GAME_OVER }
|
||||
|
||||
@Serializable
|
||||
enum class CheckersOutcome(val label: String) {
|
||||
BLACK_WINS("Black wins"),
|
||||
WHITE_WINS("White wins"),
|
||||
DRAW_NO_PROGRESS("A draw — nothing has been taken or crowned for a long time"),
|
||||
DRAW_REPETITION("A draw by threefold repetition"),
|
||||
}
|
||||
|
||||
/** How hard the computer thinks about it. */
|
||||
@Serializable
|
||||
enum class CheckersLevel(val label: String, val depth: Int) {
|
||||
CASUAL("Casual", depth = 3),
|
||||
CLUB("Club", depth = 5),
|
||||
STRONG("Strong", depth = 7),
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class CheckersOptions(
|
||||
val level: CheckersLevel = CheckersLevel.CLUB,
|
||||
/**
|
||||
* A king slides any distance along a diagonal and may take from a
|
||||
* distance, as in international draughts. Off by default: the English game
|
||||
* this board and notation come from has kings that move one square like
|
||||
* anybody else, only in both directions.
|
||||
*/
|
||||
val flyingKings: Boolean = false,
|
||||
/**
|
||||
* A man that reaches the far row is crowned and the turn ends there, even
|
||||
* with another jump available. This is the English rule; the international
|
||||
* one lets the jump continue and crowns only if the piece finishes there.
|
||||
*/
|
||||
val crowningEndsTheTurn: Boolean = true,
|
||||
val threefoldRepetition: Boolean = true,
|
||||
/** Plies with no capture and no crowning before the game is called a draw. */
|
||||
val plyLimitWithoutProgress: Int = 80,
|
||||
)
|
||||
|
||||
/**
|
||||
* Black moves down the board from row 0, White moves up from row 7. Black plays
|
||||
* first, as in every published checkers rulebook.
|
||||
*/
|
||||
const val BLACK_SEAT = 0
|
||||
const val WHITE_SEAT = 1
|
||||
|
||||
fun other(seat: Int): Int = 1 - seat
|
||||
|
||||
fun seatName(seat: Int): String = if (seat == BLACK_SEAT) "Black" else "White"
|
||||
|
||||
/** Which way a man of this seat advances: down the rows for Black, up for White. */
|
||||
fun forwardOf(seat: Int): Int = if (seat == BLACK_SEAT) 1 else -1
|
||||
|
||||
/** The row a man of this seat is crowned on. */
|
||||
fun crownRowOf(seat: Int): Int = if (seat == BLACK_SEAT) 7 else 0
|
||||
|
||||
@Serializable
|
||||
data class Piece(val seat: Int, val king: Boolean = false) {
|
||||
val glyph: String get() = when {
|
||||
seat == BLACK_SEAT && king -> "BK"
|
||||
seat == BLACK_SEAT -> "B"
|
||||
king -> "WK"
|
||||
else -> "W"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A move: where the piece started, and the squares it landed on in order.
|
||||
*
|
||||
* A quiet move has one landing square. A jump has one per hop, so a triple
|
||||
* capture is one move with three of them rather than three moves — a
|
||||
* multiple jump is one turn, and splitting it up would let a player stop
|
||||
* halfway, which the rules do not allow.
|
||||
*/
|
||||
@Serializable
|
||||
data class CheckersMove(val from: Int, val steps: List<Int>) {
|
||||
val to: Int get() = steps.last()
|
||||
|
||||
val isJump: Boolean get() = steps.isNotEmpty() && jumped(from, steps.first()) != null
|
||||
|
||||
override fun toString(): String {
|
||||
val separator = if (isJump) "x" else "-"
|
||||
return (listOf(from) + steps).joinToString(separator) { squareName(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The square hopped over going from [from] to [to], or null when the two are
|
||||
* not two squares apart on a diagonal.
|
||||
*/
|
||||
fun jumped(from: Int, to: Int): Int? {
|
||||
val rowStep = rowOf(to) - rowOf(from)
|
||||
val columnStep = columnOf(to) - columnOf(from)
|
||||
if (kotlin.math.abs(rowStep) != 2 || kotlin.math.abs(columnStep) != 2) return null
|
||||
return squareAt(rowOf(from) + rowStep / 2, columnOf(from) + columnStep / 2)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class CheckersState(
|
||||
val options: CheckersOptions,
|
||||
/** Thirty-two dark squares; null is empty. */
|
||||
val board: List<Piece?>,
|
||||
val turn: Int,
|
||||
val phase: CheckersPhase,
|
||||
val outcome: CheckersOutcome?,
|
||||
val lastMove: CheckersMove?,
|
||||
/** Plies since anything was taken or crowned. */
|
||||
val pliesSinceProgress: Int,
|
||||
val repetitionKeys: List<String>,
|
||||
val moveLog: List<String>,
|
||||
) {
|
||||
fun count(seat: Int): Int = board.count { it?.seat == seat }
|
||||
|
||||
fun kings(seat: Int): Int = board.count { it?.seat == seat && it.king }
|
||||
|
||||
fun positionKey(): String = buildString {
|
||||
for (square in 0 until SQUARES) {
|
||||
append(
|
||||
when (val piece = board[square]) {
|
||||
null -> '.'
|
||||
else -> when {
|
||||
piece.seat == BLACK_SEAT && piece.king -> 'K'
|
||||
piece.seat == BLACK_SEAT -> 'b'
|
||||
piece.king -> 'Q'
|
||||
else -> 'w'
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
append(' ').append(turn)
|
||||
}
|
||||
}
|
||||
|
||||
/** The opening array: twelve a side on the three rows nearest each player. */
|
||||
fun startingBoard(): List<Piece?> = (0 until SQUARES).map { square ->
|
||||
when (rowOf(square)) {
|
||||
0, 1, 2 -> Piece(BLACK_SEAT)
|
||||
5, 6, 7 -> Piece(WHITE_SEAT)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
package org.prolibertate.games.game.checkers
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.engine.GameRules
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
|
||||
/**
|
||||
* Checkers — English draughts — as a pure state machine. See RULES-checkers.md.
|
||||
*
|
||||
* Two rules shape everything else here. **Captures are compulsory**, so the
|
||||
* move generator produces the jumps and throws the quiet moves away whenever
|
||||
* there is a jump to be had; and **a multiple jump is one turn**, so a
|
||||
* sequence of hops is one move object rather than several. A player cannot stop
|
||||
* halfway through a capture, and building it as one move is what makes that
|
||||
* true by construction rather than by a rule somewhere that has to be enforced.
|
||||
*/
|
||||
object CheckersRules : GameRules<CheckersState, CheckersMove> {
|
||||
|
||||
override val gameId: String = GameCatalog.CHECKERS
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private val DIAGONALS = listOf(1 to 1, 1 to -1, -1 to 1, -1 to -1)
|
||||
|
||||
override fun initialState(config: TableConfig): CheckersState {
|
||||
require(config.seats.size == 2) { "Checkers is a two-handed game" }
|
||||
val options = json.decodeFromString<CheckersOptions>(config.optionsJson)
|
||||
return initialState(options)
|
||||
}
|
||||
|
||||
fun initialState(options: CheckersOptions = CheckersOptions()): CheckersState = CheckersState(
|
||||
options = options,
|
||||
board = startingBoard(),
|
||||
turn = BLACK_SEAT,
|
||||
phase = CheckersPhase.PLAYING,
|
||||
outcome = null,
|
||||
lastMove = null,
|
||||
pliesSinceProgress = 0,
|
||||
repetitionKeys = emptyList(),
|
||||
moveLog = emptyList(),
|
||||
).let { start -> start.copy(repetitionKeys = listOf(start.positionKey())) }
|
||||
|
||||
override fun currentSeat(state: CheckersState): Int? =
|
||||
if (state.phase == CheckersPhase.GAME_OVER) null else state.turn
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Moves
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fun legalMoves(state: CheckersState): List<CheckersMove> = legalMoves(state, state.turn)
|
||||
|
||||
override fun legalMoves(state: CheckersState, seat: Int): List<CheckersMove> {
|
||||
if (state.phase == CheckersPhase.GAME_OVER || state.turn != seat) return emptyList()
|
||||
|
||||
val mine = (0 until SQUARES).filter { state.board[it]?.seat == seat }
|
||||
|
||||
val jumps = mine.flatMap { square ->
|
||||
val piece = state.board[square]!!
|
||||
// The piece is lifted before its own jumps are worked out, so the
|
||||
// square it came from counts as empty for the rest of the sequence
|
||||
// — a jump can and often does end back where it started.
|
||||
val board = state.board.toMutableList()
|
||||
board[square] = null
|
||||
jumpsFrom(state.options, board, square, piece).map {
|
||||
CheckersMove(from = square, steps = it)
|
||||
}
|
||||
}
|
||||
|
||||
// Compulsory capture: a quiet move is only a move when nothing at all
|
||||
// can be taken, anywhere on the board.
|
||||
if (jumps.isNotEmpty()) return jumps
|
||||
|
||||
return mine.flatMap { square ->
|
||||
slidesFrom(state.options, state.board, square, state.board[square]!!).map {
|
||||
CheckersMove(from = square, steps = listOf(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Which diagonals a piece may travel along: both ways for a king, forwards for a man. */
|
||||
private fun directionsFor(piece: Piece): List<Pair<Int, Int>> =
|
||||
if (piece.king) DIAGONALS else DIAGONALS.filter { it.first == forwardOf(piece.seat) }
|
||||
|
||||
/** Quiet moves — one square, or any distance for a flying king. */
|
||||
private fun slidesFrom(
|
||||
options: CheckersOptions,
|
||||
board: List<Piece?>,
|
||||
from: Int,
|
||||
piece: Piece,
|
||||
): List<Int> = directionsFor(piece).flatMap { (rowStep, columnStep) ->
|
||||
val reach = if (piece.king && options.flyingKings) 7 else 1
|
||||
val landings = mutableListOf<Int>()
|
||||
for (distance in 1..reach) {
|
||||
val square = squareAt(
|
||||
rowOf(from) + rowStep * distance,
|
||||
columnOf(from) + columnStep * distance,
|
||||
) ?: break
|
||||
if (board[square] != null) break
|
||||
landings += square
|
||||
}
|
||||
landings
|
||||
}
|
||||
|
||||
/**
|
||||
* Every complete jump sequence from [at], as lists of landing squares.
|
||||
*
|
||||
* A sequence only ends where no further jump is available: a player may not
|
||||
* stop a capture halfway, so a shorter prefix of a longer jump is never
|
||||
* offered as a move of its own.
|
||||
*
|
||||
* [board] is mutated as it recurses and put back afterwards, which is what
|
||||
* stops the same piece being taken twice in one turn.
|
||||
*/
|
||||
private fun jumpsFrom(
|
||||
options: CheckersOptions,
|
||||
board: MutableList<Piece?>,
|
||||
at: Int,
|
||||
piece: Piece,
|
||||
): List<List<Int>> {
|
||||
val sequences = mutableListOf<List<Int>>()
|
||||
|
||||
for ((rowStep, columnStep) in directionsFor(piece)) {
|
||||
val hops = if (piece.king && options.flyingKings) {
|
||||
longJumps(board, at, piece, rowStep, columnStep)
|
||||
} else {
|
||||
shortJump(board, at, piece, rowStep, columnStep)
|
||||
}
|
||||
|
||||
for ((over, landing) in hops) {
|
||||
val taken = board[over]
|
||||
board[over] = null
|
||||
|
||||
// A man reaching the crown row stops there under the English
|
||||
// rule and is crowned; under the other it carries on as a man
|
||||
// and is only crowned if it finishes there.
|
||||
val crowning = !piece.king && rowOf(landing) == crownRowOf(piece.seat)
|
||||
val continuations = if (crowning && options.crowningEndsTheTurn) {
|
||||
emptyList()
|
||||
} else {
|
||||
jumpsFrom(options, board, landing, piece)
|
||||
}
|
||||
|
||||
board[over] = taken
|
||||
|
||||
if (continuations.isEmpty()) {
|
||||
sequences += listOf(landing)
|
||||
} else {
|
||||
continuations.forEach { sequences += listOf(landing) + it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sequences
|
||||
}
|
||||
|
||||
/** The one hop a man or a short king can make in a direction, if any. */
|
||||
private fun shortJump(
|
||||
board: List<Piece?>,
|
||||
at: Int,
|
||||
piece: Piece,
|
||||
rowStep: Int,
|
||||
columnStep: Int,
|
||||
): List<Pair<Int, Int>> {
|
||||
val over = squareAt(rowOf(at) + rowStep, columnOf(at) + columnStep) ?: return emptyList()
|
||||
val landing = squareAt(rowOf(at) + rowStep * 2, columnOf(at) + columnStep * 2)
|
||||
?: return emptyList()
|
||||
val victim = board[over] ?: return emptyList()
|
||||
if (victim.seat == piece.seat) return emptyList()
|
||||
if (board[landing] != null) return emptyList()
|
||||
return listOf(over to landing)
|
||||
}
|
||||
|
||||
/**
|
||||
* A flying king's captures: the first piece along the diagonal, if it is an
|
||||
* enemy, taken by landing on any empty square beyond it.
|
||||
*
|
||||
* Note that a captured piece is lifted at once rather than left standing
|
||||
* until the turn ends. International draughts leaves it there as an
|
||||
* obstacle, which occasionally forbids a sequence this allows; the
|
||||
* difference is written down in the rules document rather than pretended
|
||||
* away.
|
||||
*/
|
||||
private fun longJumps(
|
||||
board: List<Piece?>,
|
||||
at: Int,
|
||||
piece: Piece,
|
||||
rowStep: Int,
|
||||
columnStep: Int,
|
||||
): List<Pair<Int, Int>> {
|
||||
var distance = 1
|
||||
val over: Int
|
||||
while (true) {
|
||||
val square = squareAt(
|
||||
rowOf(at) + rowStep * distance,
|
||||
columnOf(at) + columnStep * distance,
|
||||
) ?: return emptyList()
|
||||
val occupant = board[square]
|
||||
if (occupant != null) {
|
||||
if (occupant.seat == piece.seat) return emptyList()
|
||||
over = square
|
||||
break
|
||||
}
|
||||
distance++
|
||||
}
|
||||
|
||||
val landings = mutableListOf<Pair<Int, Int>>()
|
||||
var beyond = distance + 1
|
||||
while (true) {
|
||||
val square = squareAt(
|
||||
rowOf(at) + rowStep * beyond,
|
||||
columnOf(at) + columnStep * beyond,
|
||||
) ?: break
|
||||
if (board[square] != null) break
|
||||
landings += over to square
|
||||
beyond++
|
||||
}
|
||||
return landings
|
||||
}
|
||||
|
||||
override fun applyMove(state: CheckersState, seat: Int, move: CheckersMove): CheckersState {
|
||||
require(state.phase != CheckersPhase.GAME_OVER) { "The game is over" }
|
||||
require(state.turn == seat) { "It is not seat $seat's turn" }
|
||||
require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" }
|
||||
|
||||
val next = make(state, seat, move)
|
||||
return next
|
||||
.copy(
|
||||
repetitionKeys = state.repetitionKeys + next.positionKey(),
|
||||
moveLog = state.moveLog + "${seatName(seat)} $move",
|
||||
)
|
||||
.withTerminalCheck(includeDraws = true)
|
||||
}
|
||||
|
||||
/** The position after [move], with the draw bookkeeping left off, for the search. */
|
||||
fun advanced(state: CheckersState, move: CheckersMove): CheckersState =
|
||||
make(state, state.turn, move).withTerminalCheck(includeDraws = false)
|
||||
|
||||
private fun make(state: CheckersState, seat: Int, move: CheckersMove): CheckersState {
|
||||
val board = state.board.toMutableList()
|
||||
val piece = requireNotNull(board[move.from]) { "no piece on ${squareName(move.from)}" }
|
||||
board[move.from] = null
|
||||
|
||||
var taken = 0
|
||||
var at = move.from
|
||||
for (landing in move.steps) {
|
||||
jumped(at, landing)?.let { over ->
|
||||
if (board[over] != null) {
|
||||
board[over] = null
|
||||
taken++
|
||||
}
|
||||
} ?: run {
|
||||
// A flying king's capture is further than a single hop, so the
|
||||
// square it took is found by walking the line rather than by
|
||||
// halving the distance.
|
||||
if (state.options.flyingKings && piece.king) {
|
||||
victimBetween(board, at, landing)?.let { over ->
|
||||
board[over] = null
|
||||
taken++
|
||||
}
|
||||
}
|
||||
}
|
||||
at = landing
|
||||
}
|
||||
|
||||
val crowned = !piece.king && rowOf(move.to) == crownRowOf(seat)
|
||||
board[move.to] = if (crowned) piece.copy(king = true) else piece
|
||||
|
||||
return state.copy(
|
||||
board = board,
|
||||
turn = other(seat),
|
||||
lastMove = move,
|
||||
// Taking a piece or crowning one is progress; shuffling kings
|
||||
// around is what the draw rule is there to stop.
|
||||
pliesSinceProgress = if (taken > 0 || crowned) 0 else state.pliesSinceProgress + 1,
|
||||
)
|
||||
}
|
||||
|
||||
/** The single enemy piece standing between two squares on a diagonal. */
|
||||
private fun victimBetween(board: List<Piece?>, from: Int, to: Int): Int? {
|
||||
val rowStep = (rowOf(to) - rowOf(from)).let { if (it > 0) 1 else -1 }
|
||||
val columnStep = (columnOf(to) - columnOf(from)).let { if (it > 0) 1 else -1 }
|
||||
var distance = 1
|
||||
while (true) {
|
||||
val square = squareAt(
|
||||
rowOf(from) + rowStep * distance,
|
||||
columnOf(from) + columnStep * distance,
|
||||
) ?: return null
|
||||
if (square == to) return null
|
||||
if (board[square] != null) return square
|
||||
distance++
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the game has just ended, from the point of view of the player who
|
||||
* is now to move: they are the only one who can have lost pieces or run out
|
||||
* of squares on the ply just played.
|
||||
*/
|
||||
private fun CheckersState.withTerminalCheck(includeDraws: Boolean): CheckersState {
|
||||
val loser = turn
|
||||
|
||||
// No pieces, or no move: both are a loss, and they are the same
|
||||
// condition as far as the rules are concerned.
|
||||
if (legalMoves(this, loser).isEmpty()) {
|
||||
return copy(
|
||||
phase = CheckersPhase.GAME_OVER,
|
||||
outcome = if (loser == BLACK_SEAT) {
|
||||
CheckersOutcome.WHITE_WINS
|
||||
} else {
|
||||
CheckersOutcome.BLACK_WINS
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (!includeDraws) return this
|
||||
|
||||
if (options.plyLimitWithoutProgress > 0 &&
|
||||
pliesSinceProgress >= options.plyLimitWithoutProgress
|
||||
) {
|
||||
return copy(
|
||||
phase = CheckersPhase.GAME_OVER,
|
||||
outcome = CheckersOutcome.DRAW_NO_PROGRESS,
|
||||
)
|
||||
}
|
||||
|
||||
if (options.threefoldRepetition) {
|
||||
val here = repetitionKeys.lastOrNull()
|
||||
if (here != null && repetitionKeys.count { it == here } >= 3) {
|
||||
return copy(
|
||||
phase = CheckersPhase.GAME_OVER,
|
||||
outcome = CheckersOutcome.DRAW_REPETITION,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Engine contract
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
override fun isFinished(state: CheckersState): Boolean =
|
||||
state.phase == CheckersPhase.GAME_OVER
|
||||
|
||||
override fun summary(state: CheckersState): String = state.outcome?.label
|
||||
?: "${seatName(state.turn)} to move"
|
||||
|
||||
/** Checkers hides nothing: both players see the same board. */
|
||||
override fun viewFor(state: CheckersState, seat: Int): CheckersState = state
|
||||
|
||||
override fun encodeState(state: CheckersState): String = json.encodeToString(state)
|
||||
override fun decodeState(json: String): CheckersState = this.json.decodeFromString(json)
|
||||
override fun encodeMove(move: CheckersMove): String = json.encodeToString(move)
|
||||
override fun decodeMove(json: String): CheckersMove = this.json.decodeFromString(json)
|
||||
|
||||
/** Leaf nodes at [depth]. The single most useful check on a move generator. */
|
||||
fun perft(state: CheckersState, depth: Int): Long {
|
||||
if (depth == 0) return 1L
|
||||
val moves = legalMoves(state)
|
||||
if (depth == 1) return moves.size.toLong()
|
||||
return moves.sumOf { move ->
|
||||
val next = advanced(state, move)
|
||||
if (next.phase == CheckersPhase.GAME_OVER) 1L else perft(next, depth - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package org.prolibertate.games.game.mastermind
|
||||
|
||||
import kotlin.random.Random
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
* A code breaker.
|
||||
*
|
||||
* There is no tree to search here — the opponent makes no reply, and a guess
|
||||
* cannot be answered badly — so the whole game is in choosing which code to try
|
||||
* next. Three ways of choosing it, in rising order of how much it thinks:
|
||||
*
|
||||
* - **Casual** takes anything untried, ruled out or not.
|
||||
* - **Club** never guesses a code the answers have already eliminated. That one
|
||||
* rule is most of the strength.
|
||||
* - **Strong** is Knuth's idea one ply deep: among the codes still standing, it
|
||||
* plays the one whose *worst* possible answer leaves the smallest number of
|
||||
* candidates behind, so it cannot be given a reply that tells it little.
|
||||
*/
|
||||
class MastermindAi(private val level: MastermindLevel? = null) :
|
||||
GameAi<MastermindState, MastermindMove> {
|
||||
|
||||
override fun chooseMove(
|
||||
state: MastermindState,
|
||||
seat: Int,
|
||||
legal: List<MastermindMove>,
|
||||
): MastermindMove {
|
||||
require(legal.isNotEmpty()) { "No legal guess for seat $seat" }
|
||||
val setting = level ?: state.options.level
|
||||
|
||||
// Deterministic for a given position, so a host and a client watching
|
||||
// the same game agree on what the computer did.
|
||||
val random = Random(seat * 7919L + state.guesses[seat].size * 31L + state.options.hashCode())
|
||||
|
||||
if (setting == MastermindLevel.CASUAL) return legal[random.nextInt(legal.size)]
|
||||
|
||||
val standing = MastermindRules.consistentCodes(state, seat)
|
||||
.filterNot { code -> state.guesses[seat].any { it.code == code } }
|
||||
// Nothing consistent left means the answers contradict each other,
|
||||
// which cannot happen in a game this code scored — but a guess still
|
||||
// has to be returned rather than an exception.
|
||||
if (standing.isEmpty()) return legal[random.nextInt(legal.size)]
|
||||
if (standing.size == 1) return MastermindMove(standing.single())
|
||||
|
||||
if (setting == MastermindLevel.CLUB) {
|
||||
return MastermindMove(standing[random.nextInt(standing.size)])
|
||||
}
|
||||
|
||||
return MastermindMove(minimax(standing, random))
|
||||
}
|
||||
|
||||
/**
|
||||
* The candidate whose worst reply leaves the fewest codes standing.
|
||||
*
|
||||
* Both lists are sampled when they are large. The full calculation is every
|
||||
* candidate against every survivor, which on a fresh six-colour game is
|
||||
* more than a million scorings — a second or two of a phone's time for a
|
||||
* choice that a sample of them makes just as well.
|
||||
*/
|
||||
private fun minimax(standing: List<List<Int>>, random: Random): List<Int> {
|
||||
val candidates = sample(standing, CANDIDATES, random)
|
||||
val against = sample(standing, OPPONENTS, random)
|
||||
|
||||
var best = candidates.first()
|
||||
var bestWorst = Int.MAX_VALUE
|
||||
for (candidate in candidates) {
|
||||
val partitions = mutableMapOf<Feedback, Int>()
|
||||
for (possible in against) {
|
||||
val answer = scoreGuess(possible, candidate)
|
||||
partitions[answer] = (partitions[answer] ?: 0) + 1
|
||||
}
|
||||
val worst = partitions.values.max()
|
||||
if (worst < bestWorst) {
|
||||
bestWorst = worst
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
private fun sample(codes: List<List<Int>>, limit: Int, random: Random): List<List<Int>> {
|
||||
if (codes.size <= limit) return codes
|
||||
val picked = LinkedHashSet<Int>()
|
||||
while (picked.size < limit) picked += random.nextInt(codes.size)
|
||||
return picked.map { codes[it] }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CANDIDATES = 80
|
||||
const val OPPONENTS = 400
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package org.prolibertate.games.game.mastermind
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Mastermind as a duel.
|
||||
*
|
||||
* The pencil-and-paper game is one person setting a code and another breaking
|
||||
* it, which makes a poor two-handed game: one player would sit and watch. Here
|
||||
* **both** players are set a code and both are breaking one, a guess each in
|
||||
* turn, and whoever cracks theirs first wins.
|
||||
*
|
||||
* That symmetry is what makes it fair to end the game at the foot of a round
|
||||
* rather than the moment somebody gets it: if the first player cracks the code
|
||||
* on their sixth guess, the second player still gets a sixth guess to equal it,
|
||||
* and equalling it is a draw.
|
||||
*/
|
||||
@Serializable
|
||||
data class MastermindOptions(
|
||||
/** How many colours the code is drawn from. */
|
||||
val colours: Int = 6,
|
||||
/** How many pegs long the code is. */
|
||||
val length: Int = 4,
|
||||
/** Whether a colour may appear more than once in the code. */
|
||||
val allowDuplicates: Boolean = true,
|
||||
/** Guesses each. Running out with neither code broken is a draw. */
|
||||
val maxGuesses: Int = 10,
|
||||
val level: MastermindLevel = MastermindLevel.CLUB,
|
||||
) {
|
||||
/** How many codes are possible, which is what a guess is chosen from. */
|
||||
fun codeSpace(): Int = if (allowDuplicates) {
|
||||
var total = 1
|
||||
repeat(length) { total *= colours }
|
||||
total
|
||||
} else {
|
||||
var total = 1
|
||||
for (step in 0 until length) total *= (colours - step).coerceAtLeast(0)
|
||||
total
|
||||
}
|
||||
}
|
||||
|
||||
/** How hard the computer thinks about it. */
|
||||
@Serializable
|
||||
enum class MastermindLevel(val label: String) {
|
||||
/**
|
||||
* Guesses anything it has not tried, whether or not the answers so far rule
|
||||
* it out. Beatable by anybody who is paying attention.
|
||||
*/
|
||||
CASUAL("Casual"),
|
||||
|
||||
/**
|
||||
* Never guesses a code the answers have already ruled out. That alone is a
|
||||
* respectable player and cracks a four-peg, six-colour code in about five.
|
||||
*/
|
||||
CLUB("Club"),
|
||||
|
||||
/**
|
||||
* Consistent, and among the consistent codes it picks the one whose worst
|
||||
* answer leaves the fewest possibilities standing — a one-ply minimax over
|
||||
* the replies, which is Knuth's idea without the full lookahead.
|
||||
*/
|
||||
STRONG("Strong"),
|
||||
}
|
||||
|
||||
/**
|
||||
* The answer to a guess: how many pegs are the right colour in the right place,
|
||||
* and how many are the right colour somewhere else.
|
||||
*
|
||||
* The two counts never double-count a peg. That is the whole subtlety of
|
||||
* scoring Mastermind, and it is why [scoreGuess] counts colours rather than
|
||||
* walking pairs.
|
||||
*/
|
||||
@Serializable
|
||||
data class Feedback(val exact: Int, val misplaced: Int) {
|
||||
override fun toString(): String = "$exact·$misplaced"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Guess(val code: List<Int>, val feedback: Feedback)
|
||||
|
||||
/**
|
||||
* Scores [guess] against [secret].
|
||||
*
|
||||
* Exact matches are counted first and taken out; what is left is compared by
|
||||
* colour count, so three reds guessed against one red can only ever be worth
|
||||
* one peg however they are arranged.
|
||||
*/
|
||||
fun scoreGuess(secret: List<Int>, guess: List<Int>): Feedback {
|
||||
require(secret.size == guess.size) { "a guess must be as long as the code" }
|
||||
var exact = 0
|
||||
val secretLeft = mutableListOf<Int>()
|
||||
val guessLeft = mutableListOf<Int>()
|
||||
for (index in secret.indices) {
|
||||
if (secret[index] == guess[index]) {
|
||||
exact++
|
||||
} else {
|
||||
secretLeft += secret[index]
|
||||
guessLeft += guess[index]
|
||||
}
|
||||
}
|
||||
val counts = secretLeft.groupingBy { it }.eachCount().toMutableMap()
|
||||
var misplaced = 0
|
||||
for (colour in guessLeft) {
|
||||
val left = counts[colour] ?: 0
|
||||
if (left > 0) {
|
||||
misplaced++
|
||||
counts[colour] = left - 1
|
||||
}
|
||||
}
|
||||
return Feedback(exact = exact, misplaced = misplaced)
|
||||
}
|
||||
|
||||
/** Every code the options allow, in a fixed order. */
|
||||
fun allCodes(options: MastermindOptions): List<List<Int>> {
|
||||
var codes = listOf(emptyList<Int>())
|
||||
repeat(options.length) {
|
||||
codes = codes.flatMap { prefix ->
|
||||
(0 until options.colours)
|
||||
.filter { options.allowDuplicates || it !in prefix }
|
||||
.map { prefix + it }
|
||||
}
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class MastermindPhase { GUESSING, GAME_OVER }
|
||||
|
||||
@Serializable
|
||||
enum class MastermindOutcome(val label: String) {
|
||||
FIRST_WINS("Broken first by the player who opened"),
|
||||
SECOND_WINS("Broken first by the player who replied"),
|
||||
DRAW_BOTH("A draw — both codes broken in the same number of guesses"),
|
||||
DRAW_NEITHER("A draw — neither code was broken in time"),
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class MastermindState(
|
||||
val options: MastermindOptions,
|
||||
/**
|
||||
* The code each seat is guarding, which is the one their opponent is trying
|
||||
* to break. Redacted before this ever leaves the host: see
|
||||
* [MastermindRules.viewFor].
|
||||
*/
|
||||
val secrets: List<List<Int>>,
|
||||
/** What each seat has guessed at their opponent's code, oldest first. */
|
||||
val guesses: List<List<Guess>>,
|
||||
val turn: Int,
|
||||
val phase: MastermindPhase,
|
||||
val outcome: MastermindOutcome?,
|
||||
) {
|
||||
fun broke(seat: Int): Boolean =
|
||||
guesses[seat].any { it.feedback.exact == options.length }
|
||||
|
||||
/** How many guesses it took, or null if this seat has not cracked it. */
|
||||
fun brokeIn(seat: Int): Int? = guesses[seat]
|
||||
.indexOfFirst { it.feedback.exact == options.length }
|
||||
.takeIf { it >= 0 }
|
||||
?.plus(1)
|
||||
|
||||
fun guessesLeft(seat: Int): Int = options.maxGuesses - guesses[seat].size
|
||||
|
||||
/** True where the secret has been stripped out for a client's view. */
|
||||
fun isHidden(seat: Int): Boolean = secrets[seat].isEmpty()
|
||||
}
|
||||
|
||||
const val FIRST_SEAT = 0
|
||||
const val SECOND_SEAT = 1
|
||||
|
||||
fun other(seat: Int): Int = 1 - seat
|
||||
@@ -0,0 +1,172 @@
|
||||
package org.prolibertate.games.game.mastermind
|
||||
|
||||
import kotlin.random.Random
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.engine.GameRules
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
|
||||
/** A guess at the opponent's code. */
|
||||
@Serializable
|
||||
data class MastermindMove(val code: List<Int>) {
|
||||
override fun toString(): String = code.joinToString("") { ('A' + it).toString() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Mastermind as a state machine. See RULES-mastermind.md.
|
||||
*
|
||||
* This is the one game here with anything to hide, so [viewFor] does real work:
|
||||
* the host holds both codes and sends each device only its own, which means an
|
||||
* opponent's code is never on the wire in the first place. Nothing else in this
|
||||
* file may leak it either — note that [summary] talks about counts rather than
|
||||
* colours.
|
||||
*/
|
||||
object MastermindRules : GameRules<MastermindState, MastermindMove> {
|
||||
|
||||
override val gameId: String = GameCatalog.MASTERMIND
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
override fun initialState(config: TableConfig): MastermindState {
|
||||
require(config.seats.size == 2) { "Mastermind is a two-handed game" }
|
||||
val options = json.decodeFromString<MastermindOptions>(config.optionsJson)
|
||||
return initialState(options, config.seed)
|
||||
}
|
||||
|
||||
fun initialState(options: MastermindOptions, seed: Long = 0L): MastermindState {
|
||||
require(options.length >= 1) { "a code needs at least one peg" }
|
||||
require(options.colours >= 2) { "a code needs at least two colours" }
|
||||
require(options.allowDuplicates || options.colours >= options.length) {
|
||||
"${options.length} different colours cannot be drawn from ${options.colours}"
|
||||
}
|
||||
val random = Random(seed)
|
||||
return MastermindState(
|
||||
options = options,
|
||||
// Both codes come off the table's own seed, so a game can be
|
||||
// replayed exactly from the config it started with.
|
||||
secrets = listOf(secretFrom(options, random), secretFrom(options, random)),
|
||||
guesses = listOf(emptyList(), emptyList()),
|
||||
turn = FIRST_SEAT,
|
||||
phase = MastermindPhase.GUESSING,
|
||||
outcome = null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun secretFrom(options: MastermindOptions, random: Random): List<Int> {
|
||||
if (options.allowDuplicates) {
|
||||
return List(options.length) { random.nextInt(options.colours) }
|
||||
}
|
||||
val bag = (0 until options.colours).toMutableList()
|
||||
return List(options.length) { bag.removeAt(random.nextInt(bag.size)) }
|
||||
}
|
||||
|
||||
override fun currentSeat(state: MastermindState): Int? =
|
||||
if (state.phase == MastermindPhase.GAME_OVER) null else state.turn
|
||||
|
||||
/**
|
||||
* Every code this seat has not already tried.
|
||||
*
|
||||
* Repeating a guess is not illegal by the rules of the game, but it can
|
||||
* only ever waste a turn, and leaving it out keeps the computer from
|
||||
* wasting one.
|
||||
*/
|
||||
override fun legalMoves(state: MastermindState, seat: Int): List<MastermindMove> {
|
||||
if (state.phase == MastermindPhase.GAME_OVER || state.turn != seat) return emptyList()
|
||||
val tried = state.guesses[seat].map { it.code }.toSet()
|
||||
return allCodes(state.options).filterNot { it in tried }.map { MastermindMove(it) }
|
||||
}
|
||||
|
||||
override fun applyMove(
|
||||
state: MastermindState,
|
||||
seat: Int,
|
||||
move: MastermindMove,
|
||||
): MastermindState {
|
||||
require(state.phase != MastermindPhase.GAME_OVER) { "The game is over" }
|
||||
require(state.turn == seat) { "It is not seat $seat's turn" }
|
||||
require(move.code.size == state.options.length) {
|
||||
"a guess is ${state.options.length} pegs long"
|
||||
}
|
||||
require(move.code.all { it in 0 until state.options.colours }) {
|
||||
"that colour is not in this game"
|
||||
}
|
||||
require(state.guesses[seat].none { it.code == move.code }) {
|
||||
"that guess has been made already"
|
||||
}
|
||||
val secret = state.secrets[other(seat)]
|
||||
require(secret.isNotEmpty()) {
|
||||
"this device does not hold that code — a guess is scored by the host"
|
||||
}
|
||||
|
||||
val scored = Guess(code = move.code, feedback = scoreGuess(secret, move.code))
|
||||
val guesses = state.guesses.toMutableList()
|
||||
guesses[seat] = guesses[seat] + scored
|
||||
|
||||
return state.copy(guesses = guesses, turn = other(seat)).withTerminalCheck()
|
||||
}
|
||||
|
||||
/**
|
||||
* The game ends at the foot of a round, never in the middle of one.
|
||||
*
|
||||
* Both players have had the same number of guesses only when the turn has
|
||||
* come back round to the opener, so that is the only place it is fair to
|
||||
* compare them — otherwise opening would be worth half a guess.
|
||||
*/
|
||||
private fun MastermindState.withTerminalCheck(): MastermindState {
|
||||
if (turn != FIRST_SEAT) return this
|
||||
|
||||
val first = broke(FIRST_SEAT)
|
||||
val second = broke(SECOND_SEAT)
|
||||
val outcome = when {
|
||||
first && second -> MastermindOutcome.DRAW_BOTH
|
||||
first -> MastermindOutcome.FIRST_WINS
|
||||
second -> MastermindOutcome.SECOND_WINS
|
||||
guessesLeft(FIRST_SEAT) <= 0 && guessesLeft(SECOND_SEAT) <= 0 ->
|
||||
MastermindOutcome.DRAW_NEITHER
|
||||
|
||||
else -> return this
|
||||
}
|
||||
return copy(phase = MastermindPhase.GAME_OVER, outcome = outcome)
|
||||
}
|
||||
|
||||
override fun isFinished(state: MastermindState): Boolean =
|
||||
state.phase == MastermindPhase.GAME_OVER
|
||||
|
||||
override fun summary(state: MastermindState): String = state.outcome?.label
|
||||
?: "Guess ${state.guesses[state.turn].size + 1} of ${state.options.maxGuesses}"
|
||||
|
||||
/**
|
||||
* Strips out the code this seat is not entitled to see.
|
||||
*
|
||||
* A seat keeps its own code — it is theirs to guard, and the screen shows
|
||||
* it once the game is over — and loses the one it is trying to break. The
|
||||
* feedback on every guess stays, because that is the game.
|
||||
*
|
||||
* Once the game is finished both codes are shown: there is nothing left to
|
||||
* protect, and a code breaker wants to see what they were up against.
|
||||
*/
|
||||
override fun viewFor(state: MastermindState, seat: Int): MastermindState {
|
||||
if (state.phase == MastermindPhase.GAME_OVER) return state
|
||||
val secrets = state.secrets.mapIndexed { index, code ->
|
||||
if (index == seat) code else emptyList()
|
||||
}
|
||||
return state.copy(secrets = secrets)
|
||||
}
|
||||
|
||||
override fun encodeState(state: MastermindState): String = json.encodeToString(state)
|
||||
override fun decodeState(json: String): MastermindState = this.json.decodeFromString(json)
|
||||
override fun encodeMove(move: MastermindMove): String = json.encodeToString(move)
|
||||
override fun decodeMove(json: String): MastermindMove = this.json.decodeFromString(json)
|
||||
|
||||
/** Codes still consistent with everything [seat] has been told so far. */
|
||||
fun consistentCodes(state: MastermindState, seat: Int): List<List<Int>> {
|
||||
val answers = state.guesses[seat]
|
||||
return allCodes(state.options).filter { candidate ->
|
||||
answers.all { scoreGuess(candidate, it.code) == it.feedback }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package org.prolibertate.games.game.pirates
|
||||
|
||||
import kotlin.random.Random
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
* A negamax search with alpha-beta pruning and iterative deepening, over the
|
||||
* same rules engine the game runs on.
|
||||
*
|
||||
* The interesting part of a hunt game is that the two sides want opposite
|
||||
* shapes out of the same board, and one evaluation has to speak for both. The
|
||||
* pirates want Bulgars gone and room to move; the Bulgars want to be many, to
|
||||
* be forward, and above all not to be jumpable. The terms below are written
|
||||
* from the Bulgars' side and negated for the pirates, so the two cannot drift
|
||||
* apart and start valuing the same position differently.
|
||||
*/
|
||||
class PiratesAi(private val level: PiratesLevel? = null) : GameAi<PiratesState, PiratesMove> {
|
||||
|
||||
private var nodeBudget = 0
|
||||
private var nodesUsed = 0
|
||||
|
||||
override fun chooseMove(state: PiratesState, seat: Int, legal: List<PiratesMove>): PiratesMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
val setting = level ?: state.options.level
|
||||
if (legal.size == 1) return legal.first()
|
||||
|
||||
nodeBudget = NODE_BUDGET
|
||||
nodesUsed = 0
|
||||
|
||||
val random = Random(state.moveLog.size * 31L + state.positionKey().hashCode())
|
||||
|
||||
var best = legal.first()
|
||||
var scored = legal.map { it to 0 }
|
||||
|
||||
for (depth in 1..setting.depth) {
|
||||
val results = mutableListOf<Pair<PiratesMove, Int>>()
|
||||
val ordered = order(legal, preferred = best)
|
||||
for (move in ordered) {
|
||||
val score = -negamax(
|
||||
state = PiratesRules.advanced(state, move),
|
||||
depth = depth - 1,
|
||||
alpha = -INFINITY,
|
||||
beta = INFINITY,
|
||||
ply = 1,
|
||||
)
|
||||
results += move to score
|
||||
if (nodesUsed >= nodeBudget) break
|
||||
}
|
||||
if (nodesUsed >= nodeBudget && results.size < ordered.size) break
|
||||
scored = results
|
||||
best = results.maxBy { it.second }.first
|
||||
}
|
||||
|
||||
val bestScore = scored.maxOf { it.second }
|
||||
val acceptable = scored.filter { it.second >= bestScore - setting.slack() }
|
||||
return acceptable[random.nextInt(acceptable.size)].first
|
||||
}
|
||||
|
||||
private fun PiratesLevel.slack(): Int = when (this) {
|
||||
PiratesLevel.CASUAL -> 60
|
||||
PiratesLevel.CLUB -> 15
|
||||
PiratesLevel.STRONG -> 0
|
||||
}
|
||||
|
||||
private fun negamax(state: PiratesState, depth: Int, alpha: Int, beta: Int, ply: Int): Int {
|
||||
nodesUsed++
|
||||
|
||||
if (state.phase == PiratesPhase.GAME_OVER) {
|
||||
return when (state.outcome) {
|
||||
PiratesOutcome.DRAW_STALEMATE, null -> 0
|
||||
// A finished game leaves the loser on the clock in two of the
|
||||
// three endings; the third — the stronghold filled — is a win
|
||||
// for the side that has just moved, so it is read off the
|
||||
// outcome rather than assumed.
|
||||
PiratesOutcome.BULGARS_STORM ->
|
||||
if (state.turn == BULGAR_SEAT) WIN - ply else -(WIN - ply)
|
||||
|
||||
else -> -(WIN - ply)
|
||||
}
|
||||
}
|
||||
if (depth <= 0) return evaluate(state)
|
||||
|
||||
val moves = PiratesRules.legalMoves(state)
|
||||
if (moves.isEmpty()) return -(WIN - ply)
|
||||
|
||||
var localAlpha = alpha
|
||||
for (move in order(moves, preferred = null)) {
|
||||
val score = -negamax(
|
||||
state = PiratesRules.advanced(state, move),
|
||||
depth = depth - 1,
|
||||
alpha = -beta,
|
||||
beta = -localAlpha,
|
||||
ply = ply + 1,
|
||||
)
|
||||
if (score >= beta) return beta
|
||||
if (score > localAlpha) localAlpha = score
|
||||
if (nodesUsed >= nodeBudget) break
|
||||
}
|
||||
return localAlpha
|
||||
}
|
||||
|
||||
/** Captures first, then moves into the stronghold: the two things that change anything. */
|
||||
private fun order(moves: List<PiratesMove>, preferred: PiratesMove?): List<PiratesMove> =
|
||||
moves.sortedByDescending { move ->
|
||||
when {
|
||||
move == preferred -> 1_000
|
||||
move.steps.size > 1 -> 200
|
||||
isJumpFrom(move.from, move.steps.first()) -> 100
|
||||
isStronghold(move.to) -> 20
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
/** The position from the point of view of the side to move. */
|
||||
private fun evaluate(state: PiratesState): Int {
|
||||
var bulgarView = 0
|
||||
|
||||
bulgarView += state.count(BULGAR_SEAT) * BULGAR
|
||||
bulgarView += state.stronghold() * IN_STRONGHOLD
|
||||
|
||||
// How far up the board the crowd has come. The stronghold is at the
|
||||
// top, so a low row number is ground gained.
|
||||
for (point in 0 until POINTS) {
|
||||
if (state.board[point] != BULGAR_SEAT) continue
|
||||
bulgarView += (GRID - 1 - rowOf(point)) * ADVANCE
|
||||
// A Bulgar with an empty point directly behind it on a line is one
|
||||
// a pirate can jump. Being unjumpable is most of staying alive.
|
||||
if (isExposed(state, point)) bulgarView -= EXPOSED
|
||||
}
|
||||
|
||||
// Room for the pirates to work in, which is what the Bulgars are
|
||||
// slowly taking away.
|
||||
bulgarView -= pirateMobility(state) * PIRATE_ROOM
|
||||
|
||||
return if (state.turn == BULGAR_SEAT) bulgarView else -bulgarView
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a pirate could jump this Bulgar: a pirate on one side of it along
|
||||
* a line, and an empty point on the other.
|
||||
*/
|
||||
private fun isExposed(state: PiratesState, point: Int): Boolean {
|
||||
for (neighbour in ADJACENCY[point]) {
|
||||
if (state.board[neighbour] != PIRATE_SEAT) continue
|
||||
val landing = pointAt(
|
||||
rowOf(point) + (rowOf(point) - rowOf(neighbour)),
|
||||
columnOf(point) + (columnOf(point) - columnOf(neighbour)),
|
||||
) ?: continue
|
||||
if (landing in ADJACENCY[point] && state.board[landing] == null) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun pirateMobility(state: PiratesState): Int =
|
||||
(0 until POINTS)
|
||||
.filter { state.board[it] == PIRATE_SEAT }
|
||||
.sumOf { point -> ADJACENCY[point].count { state.board[it] == null } }
|
||||
|
||||
private companion object {
|
||||
const val INFINITY = 1_000_000
|
||||
const val WIN = 100_000
|
||||
const val NODE_BUDGET = 150_000
|
||||
|
||||
const val BULGAR = 100
|
||||
const val IN_STRONGHOLD = 55
|
||||
const val ADVANCE = 4
|
||||
const val EXPOSED = 22
|
||||
const val PIRATE_ROOM = 3
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package org.prolibertate.games.game.pirates
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Dalmatian Pirates and the Volga Bulgars — Sid Sackson's name for the hunt
|
||||
* game usually called **Asalto**, and before that a cousin of Fox and Geese.
|
||||
*
|
||||
* Two pirates hold a stronghold at the head of a cross-shaped board. Twenty-four
|
||||
* Bulgars come at it from the other three arms, and cannot take anything: their
|
||||
* whole method is weight of numbers. The pirates cannot be driven off by force
|
||||
* either — they are taken only by being penned in — so each side is trying to
|
||||
* do something the other cannot do back.
|
||||
*
|
||||
* See RULES-pirates.md, which is honest about which parts of this are attested
|
||||
* and which had to be chosen: the book the theme comes from could not be
|
||||
* obtained, so the mechanics here are Asalto's.
|
||||
*
|
||||
* The board is a seven-by-seven grid with the four corners cut away, leaving
|
||||
* thirty-three points. Points are numbered in reading order.
|
||||
*/
|
||||
const val GRID = 7
|
||||
|
||||
/** True where the cross-shaped board has a point at all. */
|
||||
fun isOnBoard(row: Int, column: Int): Boolean =
|
||||
row in 0 until GRID && column in 0 until GRID && (row in 2..4 || column in 2..4)
|
||||
|
||||
/** The thirty-three points, in reading order. */
|
||||
val CELLS: List<Pair<Int, Int>> = (0 until GRID).flatMap { row ->
|
||||
(0 until GRID).mapNotNull { column -> (row to column).takeIf { isOnBoard(row, column) } }
|
||||
}
|
||||
|
||||
val POINTS = CELLS.size
|
||||
|
||||
fun rowOf(point: Int): Int = CELLS[point].first
|
||||
|
||||
fun columnOf(point: Int): Int = CELLS[point].second
|
||||
|
||||
fun pointAt(row: Int, column: Int): Int? =
|
||||
if (isOnBoard(row, column)) CELLS.indexOf(row to column).takeIf { it >= 0 } else null
|
||||
|
||||
/**
|
||||
* The stronghold: the nine points of the top arm.
|
||||
*
|
||||
* Twenty-four points are left outside it, which is exactly the number of
|
||||
* Bulgars — the board and the pieces are cut to each other, and the game opens
|
||||
* with every point occupied but the seven the pirates are not standing on.
|
||||
*/
|
||||
val STRONGHOLD: List<Int> = (0 until POINTS).filter { rowOf(it) <= 2 && columnOf(it) in 2..4 }
|
||||
|
||||
fun isStronghold(point: Int): Boolean = point in STRONGHOLD
|
||||
|
||||
/**
|
||||
* The lines drawn on the board.
|
||||
*
|
||||
* Orthogonal neighbours are always joined. Diagonals are drawn only where the
|
||||
* row and column add to an even number, which is the pattern printed on every
|
||||
* board of this family — it is why the centre of the board has eight lines out
|
||||
* of it and the point beside it has four.
|
||||
*/
|
||||
val ADJACENCY: List<List<Int>> = (0 until POINTS).map { point ->
|
||||
val row = rowOf(point)
|
||||
val column = columnOf(point)
|
||||
val steps = if ((row + column) % 2 == 0) {
|
||||
listOf(0 to 1, 0 to -1, 1 to 0, -1 to 0, 1 to 1, 1 to -1, -1 to 1, -1 to -1)
|
||||
} else {
|
||||
listOf(0 to 1, 0 to -1, 1 to 0, -1 to 0)
|
||||
}
|
||||
steps.mapNotNull { (rowStep, columnStep) -> pointAt(row + rowStep, column + columnStep) }
|
||||
}
|
||||
|
||||
/** Seat 0 is the crowd, seat 1 the pair. */
|
||||
const val BULGAR_SEAT = 0
|
||||
const val PIRATE_SEAT = 1
|
||||
|
||||
fun other(seat: Int): Int = 1 - seat
|
||||
|
||||
fun seatName(seat: Int): String = if (seat == BULGAR_SEAT) "the Bulgars" else "the pirates"
|
||||
|
||||
/** The point's name: files a–g left to right, ranks 7–1 top to bottom. */
|
||||
fun pointName(point: Int): String = "${'a' + columnOf(point)}${GRID - rowOf(point)}"
|
||||
|
||||
fun pointFromName(name: String): Int? =
|
||||
pointAt(GRID - (name[1] - '0'), name[0] - 'a')
|
||||
|
||||
@Serializable
|
||||
enum class PiratesPhase { PLAYING, GAME_OVER }
|
||||
|
||||
@Serializable
|
||||
enum class PiratesOutcome(val label: String) {
|
||||
BULGARS_STORM("The Bulgars win — the stronghold is full of them"),
|
||||
BULGARS_PEN_IN("The Bulgars win — the pirates have nowhere left to go"),
|
||||
PIRATES_CUT_DOWN("The pirates win — too few Bulgars are left to fill the stronghold"),
|
||||
DRAW_STALEMATE("A draw — neither side has got anywhere in a long time"),
|
||||
}
|
||||
|
||||
/** How hard the computer thinks about it. */
|
||||
@Serializable
|
||||
enum class PiratesLevel(val label: String, val depth: Int) {
|
||||
CASUAL("Casual", depth = 2),
|
||||
CLUB("Club", depth = 4),
|
||||
STRONG("Strong", depth = 6),
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class PiratesOptions(
|
||||
val level: PiratesLevel = PiratesLevel.CLUB,
|
||||
/**
|
||||
* A pirate who can take must take. The traditional way of enforcing this is
|
||||
* *huffing* — the other player removes a pirate that failed to capture —
|
||||
* which is the same rule with an extra step and a way to forget to use it.
|
||||
*/
|
||||
val captureIsCompulsory: Boolean = true,
|
||||
/**
|
||||
* Bulgars may not retreat: they move towards the stronghold or across, and
|
||||
* never back down the board. Turning this off makes them far harder to
|
||||
* pin, and much less like the game.
|
||||
*/
|
||||
val bulgarsMayNotRetreat: Boolean = true,
|
||||
/** Plies with nothing taken and no ground gained before it is called a draw. */
|
||||
val plyLimitWithoutProgress: Int = 60,
|
||||
)
|
||||
|
||||
/**
|
||||
* A move: where a piece started and where it landed, one entry per hop.
|
||||
*
|
||||
* A Bulgar's move is always a single step. A pirate's may be a chain of jumps,
|
||||
* which is one turn and one move object — a capture cannot be abandoned
|
||||
* halfway.
|
||||
*/
|
||||
@Serializable
|
||||
data class PiratesMove(val from: Int, val steps: List<Int>) {
|
||||
val to: Int get() = steps.last()
|
||||
|
||||
override fun toString(): String {
|
||||
val separator = if (steps.size > 1 || isJumpFrom(from, steps.first())) "x" else "-"
|
||||
return (listOf(from) + steps).joinToString(separator) { pointName(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether two points are a jump apart: two steps along one line. */
|
||||
fun isJumpFrom(from: Int, to: Int): Boolean = jumpedBetween(from, to) != null
|
||||
|
||||
/** The point hopped over, or null when the two are not two steps apart on a line. */
|
||||
fun jumpedBetween(from: Int, to: Int): Int? {
|
||||
val rowStep = rowOf(to) - rowOf(from)
|
||||
val columnStep = columnOf(to) - columnOf(from)
|
||||
if (kotlin.math.abs(rowStep) > 2 || kotlin.math.abs(columnStep) > 2) return null
|
||||
if (kotlin.math.abs(rowStep) != 2 && kotlin.math.abs(columnStep) != 2) return null
|
||||
// A jump must be along a line, so the two halves have to be steps of one.
|
||||
if (rowStep % 2 != 0 || columnStep % 2 != 0) return null
|
||||
val middle = pointAt(rowOf(from) + rowStep / 2, columnOf(from) + columnStep / 2) ?: return null
|
||||
return middle.takeIf { middle in ADJACENCY[from] && to in ADJACENCY[middle] }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class PiratesState(
|
||||
val options: PiratesOptions,
|
||||
/** Thirty-three points; null is empty, otherwise the seat standing there. */
|
||||
val board: List<Int?>,
|
||||
val turn: Int,
|
||||
val phase: PiratesPhase,
|
||||
val outcome: PiratesOutcome?,
|
||||
val lastMove: PiratesMove?,
|
||||
/** Plies since a pirate took something or a Bulgar entered the stronghold. */
|
||||
val pliesSinceProgress: Int,
|
||||
val moveLog: List<String>,
|
||||
) {
|
||||
fun count(seat: Int): Int = board.count { it == seat }
|
||||
|
||||
/** How many of the stronghold's nine points the Bulgars are standing on. */
|
||||
fun stronghold(): Int = STRONGHOLD.count { board[it] == BULGAR_SEAT }
|
||||
|
||||
fun positionKey(): String = buildString {
|
||||
for (point in 0 until POINTS) {
|
||||
append(
|
||||
when (board[point]) {
|
||||
BULGAR_SEAT -> 'b'
|
||||
PIRATE_SEAT -> 'P'
|
||||
else -> '.'
|
||||
}
|
||||
)
|
||||
}
|
||||
append(' ').append(turn)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The opening array: Bulgars on every point outside the stronghold, and the two
|
||||
* pirates on the stronghold's back corners — the deepest points they have, and
|
||||
* the ones a Bulgar has to come furthest to reach.
|
||||
*/
|
||||
fun startingBoard(): List<Int?> {
|
||||
val board = arrayOfNulls<Int>(POINTS)
|
||||
(0 until POINTS).forEach { point ->
|
||||
if (!isStronghold(point)) board[point] = BULGAR_SEAT
|
||||
}
|
||||
val corners = STRONGHOLD.filter { rowOf(it) == 0 && columnOf(it) != 3 }
|
||||
corners.forEach { board[it] = PIRATE_SEAT }
|
||||
return board.toList()
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package org.prolibertate.games.game.pirates
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.engine.GameRules
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
|
||||
/**
|
||||
* Pirates and Bulgars — Asalto — as a pure state machine. See RULES-pirates.md.
|
||||
*
|
||||
* The two sides do not play the same game, and almost every line here is one
|
||||
* side or the other rather than both. Pirates go anywhere along a line and take
|
||||
* by jumping, in chains, compulsorily. Bulgars go one step and never backwards,
|
||||
* and cannot take anything at all: they win by arriving.
|
||||
*
|
||||
* That asymmetry is the reason the win conditions are counted rather than
|
||||
* shared. Bulgars win by filling the stronghold or by leaving the pirates
|
||||
* nowhere to go; pirates win by cutting the Bulgars below the nine it takes to
|
||||
* fill it — at which point the attack cannot succeed however long it goes on.
|
||||
*/
|
||||
object PiratesRules : GameRules<PiratesState, PiratesMove> {
|
||||
|
||||
override val gameId: String = GameCatalog.PIRATES
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
override fun initialState(config: TableConfig): PiratesState {
|
||||
require(config.seats.size == 2) { "Pirates and Bulgars is a two-handed game" }
|
||||
val options = json.decodeFromString<PiratesOptions>(config.optionsJson)
|
||||
return initialState(options)
|
||||
}
|
||||
|
||||
fun initialState(options: PiratesOptions = PiratesOptions()): PiratesState = PiratesState(
|
||||
options = options,
|
||||
board = startingBoard(),
|
||||
// The attackers move first: the pirates are already where they want to be.
|
||||
turn = BULGAR_SEAT,
|
||||
phase = PiratesPhase.PLAYING,
|
||||
outcome = null,
|
||||
lastMove = null,
|
||||
pliesSinceProgress = 0,
|
||||
moveLog = emptyList(),
|
||||
)
|
||||
|
||||
override fun currentSeat(state: PiratesState): Int? =
|
||||
if (state.phase == PiratesPhase.GAME_OVER) null else state.turn
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Moves
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fun legalMoves(state: PiratesState): List<PiratesMove> = legalMoves(state, state.turn)
|
||||
|
||||
override fun legalMoves(state: PiratesState, seat: Int): List<PiratesMove> {
|
||||
if (state.phase == PiratesPhase.GAME_OVER || state.turn != seat) return emptyList()
|
||||
val mine = (0 until POINTS).filter { state.board[it] == seat }
|
||||
|
||||
if (seat == PIRATE_SEAT) {
|
||||
val jumps = mine.flatMap { from ->
|
||||
val board = state.board.toMutableList()
|
||||
board[from] = null
|
||||
jumpsFrom(board, from).map { PiratesMove(from = from, steps = it) }
|
||||
}
|
||||
// A pirate who can take must take — the rule huffing exists to
|
||||
// enforce, applied directly.
|
||||
if (jumps.isNotEmpty() && state.options.captureIsCompulsory) return jumps
|
||||
|
||||
val steps = mine.flatMap { from ->
|
||||
ADJACENCY[from].filter { state.board[it] == null }
|
||||
.map { PiratesMove(from = from, steps = listOf(it)) }
|
||||
}
|
||||
return jumps + steps
|
||||
}
|
||||
|
||||
return mine.flatMap { from ->
|
||||
ADJACENCY[from]
|
||||
.filter { state.board[it] == null && bulgarMayGo(state, from, it) }
|
||||
.map { PiratesMove(from = from, steps = listOf(it)) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a Bulgar may make this step.
|
||||
*
|
||||
* They press towards the stronghold and never retreat, which is what makes
|
||||
* the attack a commitment: a Bulgar that walks past a gap cannot come back
|
||||
* for it. The stronghold is at the top of the board, so "forwards" is up
|
||||
* the rows and "across" is along one.
|
||||
*/
|
||||
private fun bulgarMayGo(state: PiratesState, from: Int, to: Int): Boolean {
|
||||
if (!state.options.bulgarsMayNotRetreat) return true
|
||||
return rowOf(to) <= rowOf(from)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every complete chain of jumps from [at], as lists of landing points.
|
||||
*
|
||||
* [board] arrives with the jumping pirate already lifted, and is mutated as
|
||||
* the search recurses so a Bulgar cannot be jumped twice in one turn. A
|
||||
* chain only ends where no further jump exists, so a shorter prefix of a
|
||||
* longer capture is never offered on its own.
|
||||
*/
|
||||
private fun jumpsFrom(board: MutableList<Int?>, at: Int): List<List<Int>> {
|
||||
val sequences = mutableListOf<List<Int>>()
|
||||
|
||||
for (over in ADJACENCY[at]) {
|
||||
if (board[over] != BULGAR_SEAT) continue
|
||||
val landing = beyond(at, over) ?: continue
|
||||
if (board[landing] != null) continue
|
||||
|
||||
board[over] = null
|
||||
val continuations = jumpsFrom(board, landing)
|
||||
board[over] = BULGAR_SEAT
|
||||
|
||||
if (continuations.isEmpty()) {
|
||||
sequences += listOf(landing)
|
||||
} else {
|
||||
continuations.forEach { sequences += listOf(landing) + it }
|
||||
}
|
||||
}
|
||||
|
||||
return sequences
|
||||
}
|
||||
|
||||
/** The point directly past [over] as seen from [from], if the line runs on. */
|
||||
private fun beyond(from: Int, over: Int): Int? {
|
||||
val landing = pointAt(
|
||||
rowOf(over) + (rowOf(over) - rowOf(from)),
|
||||
columnOf(over) + (columnOf(over) - columnOf(from)),
|
||||
) ?: return null
|
||||
return landing.takeIf { it in ADJACENCY[over] }
|
||||
}
|
||||
|
||||
override fun applyMove(state: PiratesState, seat: Int, move: PiratesMove): PiratesState {
|
||||
require(state.phase != PiratesPhase.GAME_OVER) { "The game is over" }
|
||||
require(state.turn == seat) { "It is not seat $seat's turn" }
|
||||
require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" }
|
||||
|
||||
return make(state, seat, move)
|
||||
.copy(moveLog = state.moveLog + "${seatName(seat)}: $move")
|
||||
.withTerminalCheck(includeDraws = true)
|
||||
}
|
||||
|
||||
/** The position after [move] with the draw clock left off, for the search. */
|
||||
fun advanced(state: PiratesState, move: PiratesMove): PiratesState =
|
||||
make(state, state.turn, move).withTerminalCheck(includeDraws = false)
|
||||
|
||||
private fun make(state: PiratesState, seat: Int, move: PiratesMove): PiratesState {
|
||||
val board = state.board.toMutableList()
|
||||
board[move.from] = null
|
||||
|
||||
var taken = 0
|
||||
var at = move.from
|
||||
for (landing in move.steps) {
|
||||
jumpedBetween(at, landing)?.let { over ->
|
||||
if (board[over] != null) {
|
||||
board[over] = null
|
||||
taken++
|
||||
}
|
||||
}
|
||||
at = landing
|
||||
}
|
||||
board[move.to] = seat
|
||||
|
||||
// Progress is a pirate taking somebody, or a Bulgar getting into the
|
||||
// stronghold. Everything else is the two sides circling each other.
|
||||
val before = state.stronghold()
|
||||
val next = state.copy(board = board, turn = other(seat), lastMove = move)
|
||||
val gained = next.stronghold() > before
|
||||
|
||||
return next.copy(
|
||||
pliesSinceProgress = if (taken > 0 || gained) 0 else state.pliesSinceProgress + 1,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the game has ended.
|
||||
*
|
||||
* Order matters. Filling the stronghold is checked first because it is an
|
||||
* arrival rather than an absence — a board where the Bulgars have just
|
||||
* walked into the last square is a Bulgar win even if the pirates have also
|
||||
* run out of moves on it.
|
||||
*/
|
||||
private fun PiratesState.withTerminalCheck(includeDraws: Boolean): PiratesState {
|
||||
if (stronghold() == STRONGHOLD.size) {
|
||||
return copy(phase = PiratesPhase.GAME_OVER, outcome = PiratesOutcome.BULGARS_STORM)
|
||||
}
|
||||
|
||||
// Too few Bulgars left to fill it, so the assault can never succeed
|
||||
// however long the game runs.
|
||||
if (count(BULGAR_SEAT) < STRONGHOLD.size) {
|
||||
return copy(phase = PiratesPhase.GAME_OVER, outcome = PiratesOutcome.PIRATES_CUT_DOWN)
|
||||
}
|
||||
|
||||
// Whoever is on the clock with nothing to play has lost — which for the
|
||||
// pirates is being penned in, and for the Bulgars means they have run
|
||||
// themselves out of forward moves.
|
||||
if (legalMoves(this, turn).isEmpty()) {
|
||||
return copy(
|
||||
phase = PiratesPhase.GAME_OVER,
|
||||
outcome = if (turn == PIRATE_SEAT) {
|
||||
PiratesOutcome.BULGARS_PEN_IN
|
||||
} else {
|
||||
PiratesOutcome.PIRATES_CUT_DOWN
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (!includeDraws) return this
|
||||
|
||||
if (options.plyLimitWithoutProgress > 0 &&
|
||||
pliesSinceProgress >= options.plyLimitWithoutProgress
|
||||
) {
|
||||
return copy(phase = PiratesPhase.GAME_OVER, outcome = PiratesOutcome.DRAW_STALEMATE)
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Engine contract
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
override fun isFinished(state: PiratesState): Boolean = state.phase == PiratesPhase.GAME_OVER
|
||||
|
||||
override fun summary(state: PiratesState): String = state.outcome?.label ?: buildString {
|
||||
append(if (state.turn == BULGAR_SEAT) "The Bulgars" else "The pirates")
|
||||
append(" to move · ")
|
||||
append("${state.stronghold()} of ${STRONGHOLD.size} in the stronghold, ")
|
||||
append("${state.count(BULGAR_SEAT)} Bulgars left")
|
||||
}
|
||||
|
||||
/** Nothing is hidden: the whole board is on the table. */
|
||||
override fun viewFor(state: PiratesState, seat: Int): PiratesState = state
|
||||
|
||||
override fun encodeState(state: PiratesState): String = json.encodeToString(state)
|
||||
override fun decodeState(json: String): PiratesState = this.json.decodeFromString(json)
|
||||
override fun encodeMove(move: PiratesMove): String = json.encodeToString(move)
|
||||
override fun decodeMove(json: String): PiratesMove = this.json.decodeFromString(json)
|
||||
|
||||
/** Leaf nodes at [depth], as a check on the move generator. */
|
||||
fun perft(state: PiratesState, depth: Int): Long {
|
||||
if (depth == 0) return 1L
|
||||
val moves = legalMoves(state)
|
||||
if (depth == 1) return moves.size.toLong()
|
||||
return moves.sumOf { move ->
|
||||
val next = advanced(state, move)
|
||||
if (next.phase == PiratesPhase.GAME_OVER) 1L else perft(next, depth - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,14 +30,22 @@ import org.prolibertate.games.game.GameDescriptor
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import org.prolibertate.games.game.backgammon.BackgammonLevel
|
||||
import org.prolibertate.games.game.backgammon.BackgammonOptions
|
||||
import org.prolibertate.games.game.checkers.CheckersLevel
|
||||
import org.prolibertate.games.game.checkers.CheckersOptions
|
||||
import org.prolibertate.games.game.chess.ChessLevel
|
||||
import org.prolibertate.games.game.chess.ChessOptions
|
||||
import org.prolibertate.games.game.crazyeights.CrazyEightsOptions
|
||||
import org.prolibertate.games.game.euchre.EuchreOptions
|
||||
import org.prolibertate.games.game.golf.GolfOptions
|
||||
import org.prolibertate.games.game.kaiser.KaiserOptions
|
||||
import org.prolibertate.games.game.mastermind.MastermindLevel
|
||||
import org.prolibertate.games.game.mastermind.MastermindOptions
|
||||
import org.prolibertate.games.game.morris.MorrisLevel
|
||||
import org.prolibertate.games.game.morris.MorrisOptions
|
||||
import org.prolibertate.games.game.pirates.PiratesLevel
|
||||
import org.prolibertate.games.game.pirates.PiratesOptions
|
||||
import org.prolibertate.games.game.president.PresidentOptions
|
||||
import org.prolibertate.games.game.sequence.SequenceOptions
|
||||
import org.prolibertate.games.game.tayu.TayuLevel
|
||||
@@ -74,9 +82,16 @@ fun GameSetupScreen(
|
||||
var chess by remember { mutableStateOf(ChessOptions()) }
|
||||
var tayu by remember { mutableStateOf(TayuOptions()) }
|
||||
var morris by remember { mutableStateOf(MorrisOptions()) }
|
||||
var checkers by remember { mutableStateOf(CheckersOptions()) }
|
||||
var backgammon by remember { mutableStateOf(BackgammonOptions()) }
|
||||
var mastermind by remember { mutableStateOf(MastermindOptions()) }
|
||||
var pirates by remember { mutableStateOf(PiratesOptions()) }
|
||||
// Not an engine option: which seat the local player takes. Seat 0 is
|
||||
// always White, so choosing Black means sitting in seat 1.
|
||||
var playWhite by remember { mutableStateOf(true) }
|
||||
// Pirates and Bulgars is not symmetrical, so which side you take is the
|
||||
// first thing about the game rather than a detail of it.
|
||||
var playPirates by remember { mutableStateOf(false) }
|
||||
|
||||
val optionsJson = when (descriptor.id) {
|
||||
GameCatalog.EUCHRE -> setupJson.encodeToString(euchre)
|
||||
@@ -89,6 +104,10 @@ fun GameSetupScreen(
|
||||
GameCatalog.CHESS -> setupJson.encodeToString(chess)
|
||||
GameCatalog.TAYU -> setupJson.encodeToString(tayu)
|
||||
GameCatalog.MORRIS -> setupJson.encodeToString(morris)
|
||||
GameCatalog.CHECKERS -> setupJson.encodeToString(checkers)
|
||||
GameCatalog.BACKGAMMON -> setupJson.encodeToString(backgammon)
|
||||
GameCatalog.MASTERMIND -> setupJson.encodeToString(mastermind)
|
||||
GameCatalog.PIRATES -> setupJson.encodeToString(pirates)
|
||||
else -> "{}"
|
||||
}
|
||||
val seatCount = seatCountFor(descriptor.id, optionsJson)
|
||||
@@ -125,6 +144,19 @@ fun GameSetupScreen(
|
||||
|
||||
GameCatalog.MORRIS -> MorrisOptionsEditor(morris) { morris = it }
|
||||
|
||||
GameCatalog.CHECKERS -> CheckersOptionsEditor(checkers) { checkers = it }
|
||||
|
||||
GameCatalog.BACKGAMMON -> BackgammonOptionsEditor(backgammon) { backgammon = it }
|
||||
|
||||
GameCatalog.MASTERMIND -> MastermindOptionsEditor(mastermind) { mastermind = it }
|
||||
|
||||
GameCatalog.PIRATES -> PiratesOptionsEditor(
|
||||
options = pirates,
|
||||
playPirates = playPirates,
|
||||
onChange = { pirates = it },
|
||||
onSide = { playPirates = it },
|
||||
)
|
||||
|
||||
else -> Text("No options yet for this game.")
|
||||
}
|
||||
|
||||
@@ -137,9 +169,14 @@ fun GameSetupScreen(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
// Chess is the only game so far where the local player picks a
|
||||
// seat rather than always taking the first one.
|
||||
val localSeat = if (descriptor.id == GameCatalog.CHESS && !playWhite) 1 else 0
|
||||
// Two games let the local player pick a seat rather than always
|
||||
// taking the first one: chess by colour, and Pirates and Bulgars by
|
||||
// which side of a very lopsided fight they fancy.
|
||||
val localSeat = when {
|
||||
descriptor.id == GameCatalog.CHESS && !playWhite -> 1
|
||||
descriptor.id == GameCatalog.PIRATES && playPirates -> 1
|
||||
else -> 0
|
||||
}
|
||||
PrimaryAction(text = "Play against the computer") {
|
||||
onPlayOffline(
|
||||
offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat),
|
||||
@@ -202,7 +239,8 @@ fun seatCountFor(gameId: String, optionsJson: String): Int {
|
||||
GameCatalog.WIZARD ->
|
||||
setupJson.decodeFromString<WizardOptions>(optionsJson).playerCount
|
||||
|
||||
GameCatalog.CHESS, GameCatalog.MORRIS -> 2
|
||||
GameCatalog.CHESS, GameCatalog.MORRIS, GameCatalog.CHECKERS,
|
||||
GameCatalog.BACKGAMMON, GameCatalog.MASTERMIND, GameCatalog.PIRATES -> 2
|
||||
|
||||
GameCatalog.TAYU ->
|
||||
setupJson.decodeFromString<TayuOptions>(optionsJson).playerCount
|
||||
@@ -537,6 +575,178 @@ private fun ChessOptionsEditor(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CheckersOptionsEditor(options: CheckersOptions, onChange: (CheckersOptions) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = CheckersLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Flying kings",
|
||||
subtitle = "A king slides the length of a diagonal and takes from a distance, " +
|
||||
"as in the international game",
|
||||
checked = options.flyingKings,
|
||||
onChange = { onChange(options.copy(flyingKings = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Crowning ends the turn",
|
||||
subtitle = "A man jumping into the back row stops there. Turn this off and it " +
|
||||
"carries on jumping as a man",
|
||||
checked = options.crowningEndsTheTurn,
|
||||
onChange = { onChange(options.copy(crowningEndsTheTurn = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Threefold repetition",
|
||||
subtitle = "The same position three times over is a draw",
|
||||
checked = options.threefoldRepetition,
|
||||
onChange = { onChange(options.copy(threefoldRepetition = it)) },
|
||||
)
|
||||
Text(
|
||||
text = "Captures are always compulsory, and a multiple jump is one turn — " +
|
||||
"there is no version of this game where you may stop halfway.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackgammonOptionsEditor(
|
||||
options: BackgammonOptions,
|
||||
onChange: (BackgammonOptions) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = BackgammonLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Count gammons",
|
||||
subtitle = "A loser who bore nothing off has lost double, and one still on the " +
|
||||
"bar has lost treble",
|
||||
checked = options.countGammons,
|
||||
onChange = { onChange(options.copy(countGammons = it)) },
|
||||
)
|
||||
Text(
|
||||
text = "One game rather than a match, and no doubling cube: there is nothing " +
|
||||
"to double when nothing is being kept.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MastermindOptionsEditor(
|
||||
options: MastermindOptions,
|
||||
onChange: (MastermindOptions) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = MastermindLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ChipRow(
|
||||
label = "Colours",
|
||||
values = listOf(6, 8),
|
||||
selected = options.colours,
|
||||
display = { "$it" },
|
||||
onSelect = { colours ->
|
||||
onChange(
|
||||
options.copy(
|
||||
colours = colours,
|
||||
// A code of distinct colours cannot be longer than the
|
||||
// number of colours there are.
|
||||
length = if (options.allowDuplicates) {
|
||||
options.length
|
||||
} else {
|
||||
options.length.coerceAtMost(colours)
|
||||
},
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
ChipRow(
|
||||
label = "Pegs",
|
||||
values = listOf(3, 4, 5),
|
||||
selected = options.length,
|
||||
display = { "$it" },
|
||||
onSelect = { onChange(options.copy(length = it)) },
|
||||
)
|
||||
ChipRow(
|
||||
label = "Guesses each",
|
||||
values = listOf(8, 10, 12),
|
||||
selected = options.maxGuesses,
|
||||
display = { "$it" },
|
||||
onSelect = { onChange(options.copy(maxGuesses = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Repeated colours",
|
||||
subtitle = "A colour may appear more than once in the code",
|
||||
checked = options.allowDuplicates,
|
||||
onChange = { onChange(options.copy(allowDuplicates = it)) },
|
||||
)
|
||||
Text(
|
||||
text = "${options.codeSpace()} possible codes. Both players are set one and " +
|
||||
"both are breaking one, so cracking it in the same round as your " +
|
||||
"opponent is a draw.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PiratesOptionsEditor(
|
||||
options: PiratesOptions,
|
||||
playPirates: Boolean,
|
||||
onChange: (PiratesOptions) -> Unit,
|
||||
onSide: (Boolean) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "You play",
|
||||
values = listOf(false, true),
|
||||
selected = playPirates,
|
||||
display = { if (it) "the two pirates" else "the twenty-four Bulgars" },
|
||||
onSelect = onSide,
|
||||
)
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = PiratesLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "A pirate who can take must take",
|
||||
subtitle = "The rule that huffing exists to enforce, applied directly",
|
||||
checked = options.captureIsCompulsory,
|
||||
onChange = { onChange(options.copy(captureIsCompulsory = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Bulgars may not retreat",
|
||||
subtitle = "They press towards the stronghold or across, and never back",
|
||||
checked = options.bulgarsMayNotRetreat,
|
||||
onChange = { onChange(options.copy(bulgarsMayNotRetreat = it)) },
|
||||
)
|
||||
Text(
|
||||
text = "The two sides are not playing the same game. The pirates take by " +
|
||||
"jumping and win by cutting the Bulgars below the nine it takes to fill " +
|
||||
"the stronghold; the Bulgars cannot take anything at all, and win by " +
|
||||
"filling it or by leaving the pirates nowhere to go.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MorrisOptionsEditor(options: MorrisOptions, onChange: (MorrisOptions) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
|
||||
@@ -31,10 +31,26 @@ import org.prolibertate.games.game.kaiser.KaiserMove
|
||||
import org.prolibertate.games.game.kaiser.KaiserPhase
|
||||
import org.prolibertate.games.game.kaiser.KaiserRules
|
||||
import org.prolibertate.games.game.kaiser.KaiserState
|
||||
import org.prolibertate.games.game.backgammon.BackgammonAi
|
||||
import org.prolibertate.games.game.backgammon.BackgammonMove
|
||||
import org.prolibertate.games.game.backgammon.BackgammonRules
|
||||
import org.prolibertate.games.game.backgammon.BackgammonState
|
||||
import org.prolibertate.games.game.checkers.CheckersAi
|
||||
import org.prolibertate.games.game.checkers.CheckersMove
|
||||
import org.prolibertate.games.game.checkers.CheckersRules
|
||||
import org.prolibertate.games.game.checkers.CheckersState
|
||||
import org.prolibertate.games.game.mastermind.MastermindAi
|
||||
import org.prolibertate.games.game.mastermind.MastermindMove
|
||||
import org.prolibertate.games.game.mastermind.MastermindRules
|
||||
import org.prolibertate.games.game.mastermind.MastermindState
|
||||
import org.prolibertate.games.game.morris.MorrisAi
|
||||
import org.prolibertate.games.game.morris.MorrisMove
|
||||
import org.prolibertate.games.game.morris.MorrisRules
|
||||
import org.prolibertate.games.game.morris.MorrisState
|
||||
import org.prolibertate.games.game.pirates.PiratesAi
|
||||
import org.prolibertate.games.game.pirates.PiratesMove
|
||||
import org.prolibertate.games.game.pirates.PiratesRules
|
||||
import org.prolibertate.games.game.pirates.PiratesState
|
||||
import org.prolibertate.games.game.president.PresidentAi
|
||||
import org.prolibertate.games.game.president.PresidentMove
|
||||
import org.prolibertate.games.game.president.PresidentPhase
|
||||
@@ -55,12 +71,16 @@ import org.prolibertate.games.game.wizard.WizardRules
|
||||
import org.prolibertate.games.game.wizard.WizardState
|
||||
import org.prolibertate.games.net.MatchController
|
||||
import org.prolibertate.games.settings.Settings
|
||||
import org.prolibertate.games.ui.game.BackgammonScreen
|
||||
import org.prolibertate.games.ui.game.CheckersScreen
|
||||
import org.prolibertate.games.ui.game.ChessScreen
|
||||
import org.prolibertate.games.ui.game.CrazyEightsScreen
|
||||
import org.prolibertate.games.ui.game.EuchreScreen
|
||||
import org.prolibertate.games.ui.game.GolfScreen
|
||||
import org.prolibertate.games.ui.game.KaiserScreen
|
||||
import org.prolibertate.games.ui.game.MastermindScreen
|
||||
import org.prolibertate.games.ui.game.MorrisScreen
|
||||
import org.prolibertate.games.ui.game.PiratesScreen
|
||||
import org.prolibertate.games.ui.game.PresidentScreen
|
||||
import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS
|
||||
import org.prolibertate.games.ui.game.SequenceScreen
|
||||
@@ -307,6 +327,76 @@ fun PlayScreen(
|
||||
TayuScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||
}
|
||||
|
||||
GameCatalog.CHECKERS -> {
|
||||
val controller = remember(route) {
|
||||
MatchController<CheckersState, CheckersMove>(
|
||||
rules = CheckersRules,
|
||||
ai = CheckersAi(),
|
||||
config = route.config,
|
||||
scope = scope,
|
||||
role = role,
|
||||
localSeats = setOf(route.localSeat),
|
||||
primarySeat = route.localSeat,
|
||||
aiThinkingMillis = { settings.scaled(250L) },
|
||||
)
|
||||
}
|
||||
StartMatch(env, controller, route)
|
||||
CheckersScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||
}
|
||||
|
||||
GameCatalog.BACKGAMMON -> {
|
||||
val controller = remember(route) {
|
||||
MatchController<BackgammonState, BackgammonMove>(
|
||||
rules = BackgammonRules,
|
||||
ai = BackgammonAi(),
|
||||
config = route.config,
|
||||
scope = scope,
|
||||
role = role,
|
||||
localSeats = setOf(route.localSeat),
|
||||
primarySeat = route.localSeat,
|
||||
// A checker at a time, so the pause is per checker rather
|
||||
// than per turn — half of it, or a double would take an age.
|
||||
aiThinkingMillis = { settings.scaled(350L) },
|
||||
)
|
||||
}
|
||||
StartMatch(env, controller, route)
|
||||
BackgammonScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||
}
|
||||
|
||||
GameCatalog.MASTERMIND -> {
|
||||
val controller = remember(route) {
|
||||
MatchController<MastermindState, MastermindMove>(
|
||||
rules = MastermindRules,
|
||||
ai = MastermindAi(),
|
||||
config = route.config,
|
||||
scope = scope,
|
||||
role = role,
|
||||
localSeats = setOf(route.localSeat),
|
||||
primarySeat = route.localSeat,
|
||||
aiThinkingMillis = { settings.scaled(900L) },
|
||||
)
|
||||
}
|
||||
StartMatch(env, controller, route)
|
||||
MastermindScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||
}
|
||||
|
||||
GameCatalog.PIRATES -> {
|
||||
val controller = remember(route) {
|
||||
MatchController<PiratesState, PiratesMove>(
|
||||
rules = PiratesRules,
|
||||
ai = PiratesAi(),
|
||||
config = route.config,
|
||||
scope = scope,
|
||||
role = role,
|
||||
localSeats = setOf(route.localSeat),
|
||||
primarySeat = route.localSeat,
|
||||
aiThinkingMillis = { settings.scaled(250L) },
|
||||
)
|
||||
}
|
||||
StartMatch(env, controller, route)
|
||||
PiratesScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||
}
|
||||
|
||||
GameCatalog.MORRIS -> {
|
||||
val controller = remember(route) {
|
||||
MatchController<MorrisState, MorrisMove>(
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.prolibertate.games.game.backgammon.BAR
|
||||
import org.prolibertate.games.game.backgammon.BackgammonMove
|
||||
import org.prolibertate.games.game.backgammon.BackgammonPhase
|
||||
import org.prolibertate.games.game.backgammon.BackgammonState
|
||||
import org.prolibertate.games.game.backgammon.CHECKERS
|
||||
import org.prolibertate.games.game.backgammon.WHITE_SEAT
|
||||
import org.prolibertate.games.game.backgammon.other
|
||||
import org.prolibertate.games.game.backgammon.seatName
|
||||
import org.prolibertate.games.net.MatchController
|
||||
import org.prolibertate.games.ui.ScreenScaffold
|
||||
|
||||
private val BoardField = Color(0xFF7A5230)
|
||||
private val PointLight = Color(0xFFE8CFA9)
|
||||
private val PointDark = Color(0xFF9C3B2E)
|
||||
private val WhiteChecker = Color(0xFFF7F2E7)
|
||||
private val BlackChecker = Color(0xFF23201C)
|
||||
private val CheckerEdge = Color(0x66000000)
|
||||
private val SelectedPoint = Color(0x8830C030)
|
||||
private val TargetPoint = Color(0x66FFC107)
|
||||
|
||||
/**
|
||||
* The backgammon board, drawn as two banks of twelve points with the bar down
|
||||
* the middle.
|
||||
*
|
||||
* Tap a point to pick a checker up and tap where it goes; if there is only one
|
||||
* thing that checker can do, tapping it plays it. The dice left to play are
|
||||
* shown above the board and go out as they are used.
|
||||
*
|
||||
* Points are drawn in the engine's own numbering — 1 to 24 with White running
|
||||
* down — rather than being flipped for whoever is sitting there. A backgammon
|
||||
* board is symmetrical and both players read the same numbers off it.
|
||||
*/
|
||||
@Composable
|
||||
fun BackgammonScreen(
|
||||
controller: MatchController<BackgammonState, BackgammonMove>,
|
||||
localSeat: Int,
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val legal by controller.legalMoves.collectAsState()
|
||||
val abandoned by controller.abandoned.collectAsState()
|
||||
|
||||
var selected by remember { mutableStateOf<Int?>(null) }
|
||||
var confirmingEnd by remember { mutableStateOf(false) }
|
||||
|
||||
val endGameAction: @Composable () -> Unit = {
|
||||
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
|
||||
}
|
||||
|
||||
val current = state
|
||||
if (current == null) {
|
||||
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||
Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") }
|
||||
abandoned?.let { LeftBoardDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) LeaveDialog(onExit) { confirmingEnd = false }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val fromSelected = legal.filter { it.from == selected }
|
||||
val targets = fromSelected.mapNotNull { move ->
|
||||
org.prolibertate.games.game.backgammon.BackgammonRules
|
||||
.destinationOf(current.turn, move.from, move.die)
|
||||
}.toSet()
|
||||
|
||||
val onPointTapped: (Int) -> Unit = { point ->
|
||||
val landing = fromSelected.firstOrNull { move ->
|
||||
org.prolibertate.games.game.backgammon.BackgammonRules
|
||||
.destinationOf(current.turn, move.from, move.die) == point
|
||||
}
|
||||
when {
|
||||
landing != null -> {
|
||||
controller.submit(landing)
|
||||
selected = null
|
||||
}
|
||||
|
||||
legal.any { it.from == point } -> {
|
||||
val fromHere = legal.filter { it.from == point }
|
||||
// Nothing to choose between means nothing to ask about.
|
||||
if (fromHere.size == 1) {
|
||||
controller.submit(fromHere.single())
|
||||
selected = null
|
||||
} else {
|
||||
selected = point
|
||||
}
|
||||
}
|
||||
|
||||
else -> selected = null
|
||||
}
|
||||
}
|
||||
|
||||
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Status(current, localSeat)
|
||||
DiceRow(current)
|
||||
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
||||
val boardWidth = minOf(maxWidth, maxHeight * 1.25f)
|
||||
Board(
|
||||
state = current,
|
||||
width = boardWidth,
|
||||
height = minOf(maxHeight, boardWidth * 0.8f),
|
||||
selected = selected,
|
||||
targets = targets,
|
||||
onTap = onPointTapped,
|
||||
)
|
||||
}
|
||||
|
||||
OffAndBar(current, localSeat) { onPointTapped(BAR) }
|
||||
}
|
||||
|
||||
val over = abandoned
|
||||
when {
|
||||
over != null -> LeftBoardDialog(over, onExit, controller::dismissAbandoned)
|
||||
confirmingEnd -> LeaveDialog(onExit) { confirmingEnd = false }
|
||||
current.phase == BackgammonPhase.GAME_OVER -> ResultDialog(current, onExit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val TITLE = "Backgammon"
|
||||
|
||||
@Composable
|
||||
private fun Status(state: BackgammonState, localSeat: Int) {
|
||||
val yours = state.turn == localSeat && state.phase == BackgammonPhase.PLAYING
|
||||
Column {
|
||||
Text(
|
||||
text = state.outcome?.label ?: when {
|
||||
yours && state.bar[localSeat] > 0 -> "Come in off the bar"
|
||||
yours -> "Your roll to play"
|
||||
else -> "Thinking…"
|
||||
},
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = "You play ${seatName(localSeat)} · " +
|
||||
"${state.pipCount(localSeat)} pips against ${state.pipCount(other(localSeat))}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The dice, with the ones already played greyed out rather than removed. */
|
||||
@Composable
|
||||
private fun DiceRow(state: BackgammonState) {
|
||||
val left = state.dice.toMutableList()
|
||||
val shown = if (state.roll.size == 2 && state.roll[0] == state.roll[1]) {
|
||||
List(4) { state.roll[0] }
|
||||
} else {
|
||||
state.roll
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
shown.forEach { die ->
|
||||
val unplayed = left.remove(die)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(30.dp)
|
||||
.background(if (unplayed) PointLight else Color(0x33808080))
|
||||
.border(1.dp, CheckerEdge),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "$die",
|
||||
color = Color(0xFF23201C),
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Board(
|
||||
state: BackgammonState,
|
||||
width: Dp,
|
||||
height: Dp,
|
||||
selected: Int?,
|
||||
targets: Set<Int>,
|
||||
onTap: (Int) -> Unit,
|
||||
) {
|
||||
val columnWidth = width / 13
|
||||
val bankHeight = height / 2
|
||||
|
||||
Column(modifier = Modifier.width(width).height(height).background(BoardField)) {
|
||||
// The top bank runs 13 to 24 left to right, the bottom 12 down to 1 —
|
||||
// the standard layout, with White's home at the bottom right.
|
||||
Bank(
|
||||
points = (12..23).toList(),
|
||||
state = state,
|
||||
columnWidth = columnWidth,
|
||||
bankHeight = bankHeight,
|
||||
pointingDown = true,
|
||||
selected = selected,
|
||||
targets = targets,
|
||||
onTap = onTap,
|
||||
)
|
||||
Bank(
|
||||
points = (11 downTo 0).toList(),
|
||||
state = state,
|
||||
columnWidth = columnWidth,
|
||||
bankHeight = bankHeight,
|
||||
pointingDown = false,
|
||||
selected = selected,
|
||||
targets = targets,
|
||||
onTap = onTap,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Bank(
|
||||
points: List<Int>,
|
||||
state: BackgammonState,
|
||||
columnWidth: Dp,
|
||||
bankHeight: Dp,
|
||||
pointingDown: Boolean,
|
||||
selected: Int?,
|
||||
targets: Set<Int>,
|
||||
onTap: (Int) -> Unit,
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth().height(bankHeight)) {
|
||||
points.forEachIndexed { index, point ->
|
||||
// The bar sits between the two halves of each bank.
|
||||
if (index == 6) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(columnWidth)
|
||||
.fillMaxHeight()
|
||||
.background(BoardField)
|
||||
.border(1.dp, Color(0x33000000))
|
||||
)
|
||||
}
|
||||
PointColumn(
|
||||
point = point,
|
||||
state = state,
|
||||
width = columnWidth,
|
||||
height = bankHeight,
|
||||
light = index % 2 == 0,
|
||||
pointingDown = pointingDown,
|
||||
selected = point == selected,
|
||||
target = point in targets,
|
||||
onTap = { onTap(point) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PointColumn(
|
||||
point: Int,
|
||||
state: BackgammonState,
|
||||
width: Dp,
|
||||
height: Dp,
|
||||
light: Boolean,
|
||||
pointingDown: Boolean,
|
||||
selected: Boolean,
|
||||
target: Boolean,
|
||||
onTap: () -> Unit,
|
||||
) {
|
||||
val white = state.countOn(point, WHITE_SEAT)
|
||||
val black = state.countOn(point, 1 - WHITE_SEAT)
|
||||
val count = white + black
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(width)
|
||||
.height(height)
|
||||
.background(if (light) PointLight.copy(alpha = 0.35f) else PointDark.copy(alpha = 0.5f))
|
||||
.then(if (selected) Modifier.background(SelectedPoint) else Modifier)
|
||||
.then(if (target) Modifier.background(TargetPoint) else Modifier)
|
||||
.border(1.dp, Color(0x22000000))
|
||||
.clickable { onTap() },
|
||||
contentAlignment = if (pointingDown) Alignment.TopCenter else Alignment.BottomCenter,
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = if (pointingDown) Arrangement.Top else Arrangement.Bottom,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
// Five checkers is as many as fit; anything beyond that is written
|
||||
// on the last one, which is what a crowded board does anyway.
|
||||
val drawn = minOf(count, 5)
|
||||
repeat(drawn) { index ->
|
||||
val isLast = index == drawn - 1
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width * 0.82f)
|
||||
.clip(CircleShape)
|
||||
.background(if (white > 0) WhiteChecker else BlackChecker)
|
||||
.border(1.dp, CheckerEdge, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (isLast && count > 5) {
|
||||
Text(
|
||||
text = "$count",
|
||||
color = if (white > 0) Color(0xFF23201C) else Color(0xFFF7F2E7),
|
||||
fontSize = (width.value * 0.36f).sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${point + 1}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color(0x99000000),
|
||||
modifier = Modifier
|
||||
.align(if (pointingDown) Alignment.BottomCenter else Alignment.TopCenter)
|
||||
.padding(1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** What is on the bar and what is safely off, with the bar tappable to come in. */
|
||||
@Composable
|
||||
private fun OffAndBar(state: BackgammonState, localSeat: Int, onBarTapped: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(onClick = onBarTapped, enabled = state.bar[localSeat] > 0) {
|
||||
Text("Bar: ${state.bar[localSeat]}")
|
||||
}
|
||||
Text(
|
||||
text = "Borne off — you ${state.off[localSeat]} of $CHECKERS, " +
|
||||
"them ${state.off[other(localSeat)]}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LeaveDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onKeepPlaying,
|
||||
title = { Text("End this game?") },
|
||||
text = {
|
||||
Text(
|
||||
"This game ends here and the position is not saved. Anyone else " +
|
||||
"at the table is told you have left."
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LeftBoardDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onStay,
|
||||
title = { Text("The game has ended") },
|
||||
text = { Text("$notice There are no more moves to play.") },
|
||||
confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } },
|
||||
dismissButton = { TextButton(onClick = onStay) { Text("Look at the board") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResultDialog(state: BackgammonState, onExit: () -> Unit) {
|
||||
var dismissed by remember(state.outcome) { mutableStateOf(false) }
|
||||
if (dismissed) return
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { dismissed = true },
|
||||
title = { Text("Game over") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(state.outcome?.label.orEmpty(), fontWeight = FontWeight.Bold)
|
||||
state.outcome?.let {
|
||||
Text(
|
||||
text = "Worth ${it.points} point${if (it.points == 1) "" else "s"}.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } },
|
||||
dismissButton = { TextButton(onClick = { dismissed = true }) { Text("Look at the board") } },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.prolibertate.games.game.checkers.BLACK_SEAT
|
||||
import org.prolibertate.games.game.checkers.CheckersMove
|
||||
import org.prolibertate.games.game.checkers.CheckersPhase
|
||||
import org.prolibertate.games.game.checkers.CheckersState
|
||||
import org.prolibertate.games.game.checkers.seatName
|
||||
import org.prolibertate.games.game.checkers.squareAt
|
||||
import org.prolibertate.games.net.MatchController
|
||||
import org.prolibertate.games.ui.ScreenScaffold
|
||||
|
||||
private val LightSquare = Color(0xFFEEDAB4)
|
||||
private val DarkSquare = Color(0xFF9A7248)
|
||||
private val BlackPiece = Color(0xFF1E1B18)
|
||||
private val WhitePiece = Color(0xFFF7F2E7)
|
||||
private val PieceEdge = Color(0x66000000)
|
||||
private val SelectedSquare = Color(0x8830C030)
|
||||
private val PathSquare = Color(0x66FFC107)
|
||||
private val MoveDot = Color(0x66000000)
|
||||
private val CrownInk = Color(0xFFFFC107)
|
||||
|
||||
/**
|
||||
* The checkers board.
|
||||
*
|
||||
* Tap a piece, then tap where it goes. A multiple jump is one move as far as
|
||||
* the rules are concerned, so it is tapped out one hop at a time and sent when
|
||||
* the chain is complete — a player cannot stop in the middle of it, and the
|
||||
* board will not let them try.
|
||||
*
|
||||
* The board is drawn from the local player's side, so a game as White is played
|
||||
* up the screen the way it would be across a real table.
|
||||
*/
|
||||
@Composable
|
||||
fun CheckersScreen(
|
||||
controller: MatchController<CheckersState, CheckersMove>,
|
||||
localSeat: Int,
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val legal by controller.legalMoves.collectAsState()
|
||||
val abandoned by controller.abandoned.collectAsState()
|
||||
|
||||
var selected by remember { mutableStateOf<Int?>(null) }
|
||||
// The hops tapped out so far of a jump that is not finished.
|
||||
var path by remember { mutableStateOf(emptyList<Int>()) }
|
||||
var confirmingEnd by remember { mutableStateOf(false) }
|
||||
|
||||
val endGameAction: @Composable () -> Unit = {
|
||||
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
|
||||
}
|
||||
|
||||
val current = state
|
||||
if (current == null) {
|
||||
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||
Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") }
|
||||
abandoned?.let { LeftTableDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) EndGameDialog(onExit) { confirmingEnd = false }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Every move still possible given the piece picked up and the hops tapped.
|
||||
val following = legal.filter { move ->
|
||||
move.from == selected && move.steps.take(path.size) == path
|
||||
}
|
||||
val nextHops = following.mapNotNull { it.steps.getOrNull(path.size) }.toSet()
|
||||
|
||||
val onSquareTapped: (Int) -> Unit = { square ->
|
||||
when {
|
||||
square in nextHops -> {
|
||||
val extended = path + square
|
||||
val done = following.firstOrNull { it.steps == extended }
|
||||
if (done != null) {
|
||||
controller.submit(done)
|
||||
selected = null
|
||||
path = emptyList()
|
||||
} else {
|
||||
path = extended
|
||||
}
|
||||
}
|
||||
|
||||
// Picking up a different piece, which is what a misplaced tap means.
|
||||
legal.any { it.from == square } -> {
|
||||
selected = square
|
||||
path = emptyList()
|
||||
}
|
||||
|
||||
else -> {
|
||||
selected = null
|
||||
path = emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
val wide = maxWidth > maxHeight
|
||||
val side = if (wide) minOf(maxHeight, maxWidth * 0.62f) else minOf(maxWidth, maxHeight * 0.72f)
|
||||
|
||||
val board: @Composable () -> Unit = {
|
||||
Board(
|
||||
state = current,
|
||||
flipped = localSeat != BLACK_SEAT,
|
||||
side = side,
|
||||
selected = selected,
|
||||
path = path,
|
||||
destinations = nextHops,
|
||||
onTap = onSquareTapped,
|
||||
)
|
||||
}
|
||||
val panel: @Composable (Modifier) -> Unit = { panelModifier ->
|
||||
Column(modifier = panelModifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Status(current, localSeat, path.isNotEmpty())
|
||||
MoveLog(current, modifier = Modifier.weight(1f, fill = false))
|
||||
}
|
||||
}
|
||||
|
||||
if (wide) {
|
||||
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
board()
|
||||
panel(Modifier.weight(1f).fillMaxSize())
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
board()
|
||||
panel(Modifier.fillMaxWidth().weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
val over = abandoned
|
||||
when {
|
||||
over != null -> LeftTableDialog(over, onExit, controller::dismissAbandoned)
|
||||
confirmingEnd -> EndGameDialog(onExit) { confirmingEnd = false }
|
||||
current.phase == CheckersPhase.GAME_OVER -> ResultDialog(current, onExit)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val TITLE = "Checkers"
|
||||
|
||||
@Composable
|
||||
private fun Board(
|
||||
state: CheckersState,
|
||||
flipped: Boolean,
|
||||
side: Dp,
|
||||
selected: Int?,
|
||||
path: List<Int>,
|
||||
destinations: Set<Int>,
|
||||
onTap: (Int) -> Unit,
|
||||
) {
|
||||
val cell = side / 8
|
||||
Column(modifier = Modifier.size(side).border(2.dp, DarkSquare)) {
|
||||
for (displayRow in 0 until 8) {
|
||||
Row(modifier = Modifier.weight(1f)) {
|
||||
for (displayColumn in 0 until 8) {
|
||||
// Turning the board round is one subtraction, so nothing
|
||||
// below this has to know which way up it is drawn.
|
||||
val row = if (flipped) 7 - displayRow else displayRow
|
||||
val column = if (flipped) 7 - displayColumn else displayColumn
|
||||
val square = squareAt(row, column)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxSize()
|
||||
.background(if (square == null) LightSquare else DarkSquare)
|
||||
.then(
|
||||
if (square != null && square in path) {
|
||||
Modifier.background(PathSquare)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (square != null && square == selected) {
|
||||
Modifier.background(SelectedSquare)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (square != null) Modifier.clickable { onTap(square) } else Modifier
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val piece = square?.let { state.board[it] }
|
||||
if (piece != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(cell * 0.74f)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (piece.seat == BLACK_SEAT) BlackPiece else WhitePiece
|
||||
)
|
||||
.border(1.dp, PieceEdge, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// A crown rather than a second disc: two discs
|
||||
// stacked is what a wooden set does and what a
|
||||
// small screen cannot show.
|
||||
if (piece.king) {
|
||||
Text(
|
||||
text = "♛",
|
||||
color = CrownInk,
|
||||
fontSize = (cell.value * 0.42f).sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (square != null && square in destinations) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(cell * 0.28f)
|
||||
.clip(CircleShape)
|
||||
.background(MoveDot)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Status(state: CheckersState, localSeat: Int, midJump: Boolean) {
|
||||
val yours = state.turn == localSeat && state.phase == CheckersPhase.PLAYING
|
||||
Column {
|
||||
Text(
|
||||
text = state.outcome?.label ?: when {
|
||||
midJump -> "Keep jumping — the capture has to be finished"
|
||||
yours -> "Your move"
|
||||
else -> "Thinking…"
|
||||
},
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = "You play ${seatName(localSeat)} · " +
|
||||
"${state.count(localSeat)} left (${state.kings(localSeat)} crowned) " +
|
||||
"against ${state.count(1 - localSeat)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
if (state.phase == CheckersPhase.PLAYING) {
|
||||
Text(
|
||||
text = "A capture must be taken when one is on the board.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MoveLog(state: CheckersState, modifier: Modifier = Modifier) {
|
||||
if (state.moveLog.isEmpty()) {
|
||||
Text("No moves yet.", style = MaterialTheme.typography.bodySmall, modifier = modifier)
|
||||
return
|
||||
}
|
||||
Column(modifier = modifier.verticalScroll(rememberScrollState())) {
|
||||
state.moveLog.chunked(2).forEachIndexed { index, pair ->
|
||||
Text(
|
||||
text = "${index + 1}. ${pair.joinToString(" ")}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EndGameDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onKeepPlaying,
|
||||
title = { Text("End this game?") },
|
||||
text = {
|
||||
Text(
|
||||
"This game ends here and the position is not saved. Anyone else " +
|
||||
"at the table is told you have left."
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LeftTableDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onStay,
|
||||
title = { Text("The game has ended") },
|
||||
text = { Text("$notice There are no more moves to play.") },
|
||||
confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } },
|
||||
dismissButton = { TextButton(onClick = onStay) { Text("Look at the board") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResultDialog(state: CheckersState, onExit: () -> Unit) {
|
||||
var dismissed by remember(state.outcome) { mutableStateOf(false) }
|
||||
if (dismissed) return
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { dismissed = true },
|
||||
title = { Text("Game over") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(state.outcome?.label.orEmpty(), fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
text = "${state.moveLog.size} moves played.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } },
|
||||
dismissButton = { TextButton(onClick = { dismissed = true }) { Text("Look at the board") } },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.prolibertate.games.game.mastermind.Feedback
|
||||
import org.prolibertate.games.game.mastermind.Guess
|
||||
import org.prolibertate.games.game.mastermind.MastermindMove
|
||||
import org.prolibertate.games.game.mastermind.MastermindPhase
|
||||
import org.prolibertate.games.game.mastermind.MastermindState
|
||||
import org.prolibertate.games.game.mastermind.other
|
||||
import org.prolibertate.games.net.MatchController
|
||||
import org.prolibertate.games.ui.PrimaryAction
|
||||
import org.prolibertate.games.ui.ScreenScaffold
|
||||
|
||||
/**
|
||||
* The eight code pegs.
|
||||
*
|
||||
* Kept well apart in hue *and* in lightness, because a code you cannot read is
|
||||
* not a puzzle, it is an eye test — and one player in twelve cannot tell the
|
||||
* red from the green.
|
||||
*/
|
||||
private val PegColours = listOf(
|
||||
Color(0xFFD32F2F),
|
||||
Color(0xFF1976D2),
|
||||
Color(0xFFFBC02D),
|
||||
Color(0xFF388E3C),
|
||||
Color(0xFFF57C00),
|
||||
Color(0xFF7B1FA2),
|
||||
Color(0xFF00ACC1),
|
||||
Color(0xFFFAFAFA),
|
||||
)
|
||||
|
||||
private val EmptySlot = Color(0x33808080)
|
||||
private val ExactPeg = Color(0xFF212121)
|
||||
private val MisplacedPeg = Color(0xFFFFFFFF)
|
||||
|
||||
/**
|
||||
* Mastermind, played as a duel: both players are guarding a code and breaking
|
||||
* one, a guess each in turn.
|
||||
*
|
||||
* Tap the colours to build a guess and press the button to commit it. The
|
||||
* answer to each guess is the little pegs beside it — a black one for a colour
|
||||
* in the right place, a white one for a colour in the wrong place, and no
|
||||
* indication whatever of which is which.
|
||||
*/
|
||||
@Composable
|
||||
fun MastermindScreen(
|
||||
controller: MatchController<MastermindState, MastermindMove>,
|
||||
localSeat: Int,
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val abandoned by controller.abandoned.collectAsState()
|
||||
|
||||
val building = remember { mutableStateListOf<Int>() }
|
||||
var confirmingEnd by remember { mutableStateOf(false) }
|
||||
|
||||
val endGameAction: @Composable () -> Unit = {
|
||||
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
|
||||
}
|
||||
|
||||
val current = state
|
||||
if (current == null) {
|
||||
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||
Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") }
|
||||
abandoned?.let { LeftDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) EndDialog(onExit) { confirmingEnd = false }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val yourTurn = current.turn == localSeat && current.phase == MastermindPhase.GUESSING
|
||||
|
||||
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||
Column(
|
||||
modifier = modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = when {
|
||||
current.outcome != null -> current.outcome!!.label
|
||||
yourTurn -> "Your guess — ${current.guessesLeft(localSeat)} left"
|
||||
else -> "Thinking…"
|
||||
},
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = "A dark peg is a colour in the right place, a light one a colour " +
|
||||
"somewhere else. Which is which is for you to work out.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
Text("Your guesses", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
|
||||
GuessList(current, localSeat)
|
||||
|
||||
if (current.phase == MastermindPhase.GUESSING) {
|
||||
Builder(
|
||||
length = current.options.length,
|
||||
colours = current.options.colours,
|
||||
building = building,
|
||||
enabled = yourTurn,
|
||||
onSubmit = {
|
||||
controller.submit(MastermindMove(building.toList()))
|
||||
building.clear()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Against you",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
Text(
|
||||
text = "${current.guesses[other(localSeat)].size} guesses at your code so far.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
GuessList(current, other(localSeat))
|
||||
|
||||
// Your own code, which is yours to see all along — you are guarding
|
||||
// it, not guessing it.
|
||||
if (current.secrets[localSeat].isNotEmpty()) {
|
||||
Text(
|
||||
text = "The code you are guarding",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
PegRow(current.secrets[localSeat])
|
||||
}
|
||||
}
|
||||
|
||||
val over = abandoned
|
||||
when {
|
||||
over != null -> LeftDialog(over, onExit, controller::dismissAbandoned)
|
||||
confirmingEnd -> EndDialog(onExit) { confirmingEnd = false }
|
||||
current.phase == MastermindPhase.GAME_OVER -> ResultDialog(current, localSeat, onExit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val TITLE = "Mastermind"
|
||||
|
||||
@Composable
|
||||
private fun GuessList(state: MastermindState, seat: Int) {
|
||||
val guesses = state.guesses[seat]
|
||||
if (guesses.isEmpty()) {
|
||||
Text("Nothing yet.", style = MaterialTheme.typography.bodySmall)
|
||||
return
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
guesses.forEachIndexed { index, guess ->
|
||||
GuessRow(index + 1, guess, state.options.length)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GuessRow(number: Int, guess: Guess, length: Int) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "$number.",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.size(width = 22.dp, height = 20.dp),
|
||||
)
|
||||
PegRow(guess.code)
|
||||
Box(modifier = Modifier.padding(start = 10.dp)) {
|
||||
FeedbackPegs(guess.feedback, length)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PegRow(code: List<Int>) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
code.forEach { colour -> Peg(colour) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Peg(colour: Int?, size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
.clip(CircleShape)
|
||||
.background(colour?.let { PegColours[it % PegColours.size] } ?: EmptySlot)
|
||||
.border(1.dp, Color(0x55000000), CircleShape)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The answer, as pegs rather than as numbers.
|
||||
*
|
||||
* Deliberately not laid out one-per-code-peg: the pegs say how many, never
|
||||
* which, and putting them in a tidy row under the code would invite reading a
|
||||
* position into them that is not there.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun FeedbackPegs(feedback: Feedback, length: Int) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
maxItemsInEachRow = (length + 1) / 2,
|
||||
) {
|
||||
repeat(feedback.exact) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.clip(CircleShape)
|
||||
.background(ExactPeg)
|
||||
.border(1.dp, Color(0x55000000), CircleShape)
|
||||
)
|
||||
}
|
||||
repeat(feedback.misplaced) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MisplacedPeg)
|
||||
.border(1.dp, Color(0x55000000), CircleShape)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The guess being built: tap a colour to add it, tap a peg to take it back. */
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun Builder(
|
||||
length: Int,
|
||||
colours: Int,
|
||||
building: MutableList<Int>,
|
||||
enabled: Boolean,
|
||||
onSubmit: () -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
repeat(length) { slot ->
|
||||
Box(
|
||||
modifier = Modifier.clickable(enabled = enabled && slot < building.size) {
|
||||
// Taking a peg back removes it and everything after it,
|
||||
// which is what a physical board does when you lift one
|
||||
// out of the middle of a row you are still filling.
|
||||
while (building.size > slot) building.removeAt(building.size - 1)
|
||||
}
|
||||
) {
|
||||
Peg(building.getOrNull(slot), size = 32.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
repeat(colours) { colour ->
|
||||
Box(
|
||||
modifier = Modifier.clickable(enabled = enabled && building.size < length) {
|
||||
building.add(colour)
|
||||
}
|
||||
) {
|
||||
Peg(colour, size = 34.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PrimaryAction(
|
||||
text = "Guess",
|
||||
enabled = enabled && building.size == length,
|
||||
onClick = onSubmit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EndDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onKeepPlaying,
|
||||
title = { Text("End this game?") },
|
||||
text = { Text("The game ends here. Anyone else at the table is told you have left.") },
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LeftDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onStay,
|
||||
title = { Text("The game has ended") },
|
||||
text = { Text("$notice There are no more guesses to make.") },
|
||||
confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } },
|
||||
dismissButton = { TextButton(onClick = onStay) { Text("Look at the board") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResultDialog(state: MastermindState, localSeat: Int, onExit: () -> Unit) {
|
||||
var dismissed by remember(state.outcome) { mutableStateOf(false) }
|
||||
if (dismissed) return
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { dismissed = true },
|
||||
title = { Text("Game over") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(state.outcome?.label.orEmpty(), fontWeight = FontWeight.Bold)
|
||||
// Both codes are shown now: there is nothing left to protect,
|
||||
// and everyone wants to see what they were up against.
|
||||
Text("The code you were breaking", style = MaterialTheme.typography.bodySmall)
|
||||
PegRow(state.secrets[other(localSeat)])
|
||||
state.brokeIn(localSeat)?.let {
|
||||
Text("You broke it in $it.", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
state.brokeIn(other(localSeat))?.let {
|
||||
Text("They broke yours in $it.", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } },
|
||||
dismissButton = { TextButton(onClick = { dismissed = true }) { Text("Look at the board") } },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.prolibertate.games.game.pirates.ADJACENCY
|
||||
import org.prolibertate.games.game.pirates.BULGAR_SEAT
|
||||
import org.prolibertate.games.game.pirates.GRID
|
||||
import org.prolibertate.games.game.pirates.PIRATE_SEAT
|
||||
import org.prolibertate.games.game.pirates.POINTS
|
||||
import org.prolibertate.games.game.pirates.PiratesMove
|
||||
import org.prolibertate.games.game.pirates.PiratesPhase
|
||||
import org.prolibertate.games.game.pirates.PiratesState
|
||||
import org.prolibertate.games.game.pirates.STRONGHOLD
|
||||
import org.prolibertate.games.game.pirates.columnOf
|
||||
import org.prolibertate.games.game.pirates.isOnBoard
|
||||
import org.prolibertate.games.game.pirates.isStronghold
|
||||
import org.prolibertate.games.game.pirates.pointAt
|
||||
import org.prolibertate.games.game.pirates.rowOf
|
||||
import org.prolibertate.games.net.MatchController
|
||||
import org.prolibertate.games.ui.ScreenScaffold
|
||||
|
||||
private val BoardField = Color(0xFFE7D6B8)
|
||||
private val StrongholdField = Color(0xFFCBA96B)
|
||||
private val BoardLine = Color(0xFF5C4326)
|
||||
private val BulgarStone = Color(0xFF3C6E47)
|
||||
private val PirateStone = Color(0xFF7A1E1E)
|
||||
private val StoneEdge = Color(0x66000000)
|
||||
private val VacantPoint = Color(0x442B2B2B)
|
||||
private val SelectedRing = Color(0xFF2E9E2E)
|
||||
private val PathRing = Color(0xFFE0A800)
|
||||
private val DestinationDot = Color(0x66000000)
|
||||
|
||||
/**
|
||||
* Pirates and Bulgars, on the cross-shaped board.
|
||||
*
|
||||
* Tap one of your pieces and then where it goes. A pirate's chain of jumps is
|
||||
* one turn, so it is tapped out hop by hop and sent when there is nothing left
|
||||
* to take — the capture cannot be abandoned halfway, and the board will not let
|
||||
* a player try.
|
||||
*
|
||||
* The stronghold is the shaded arm at the top. Both sides look at it the same
|
||||
* way up: the board is not turned round, because what each side is trying to do
|
||||
* with it is not the same and swapping ends would only disguise that.
|
||||
*/
|
||||
@Composable
|
||||
fun PiratesScreen(
|
||||
controller: MatchController<PiratesState, PiratesMove>,
|
||||
localSeat: Int,
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val legal by controller.legalMoves.collectAsState()
|
||||
val abandoned by controller.abandoned.collectAsState()
|
||||
|
||||
var selected by remember { mutableStateOf<Int?>(null) }
|
||||
var path by remember { mutableStateOf(emptyList<Int>()) }
|
||||
var confirmingEnd by remember { mutableStateOf(false) }
|
||||
|
||||
val endGameAction: @Composable () -> Unit = {
|
||||
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
|
||||
}
|
||||
|
||||
val current = state
|
||||
if (current == null) {
|
||||
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||
Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") }
|
||||
abandoned?.let { LeftFieldDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) LeaveFieldDialog(onExit) { confirmingEnd = false }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val following = legal.filter { it.from == selected && it.steps.take(path.size) == path }
|
||||
val nextHops = following.mapNotNull { it.steps.getOrNull(path.size) }.toSet()
|
||||
|
||||
val onPointTapped: (Int) -> Unit = { point ->
|
||||
when {
|
||||
point in nextHops -> {
|
||||
val extended = path + point
|
||||
val done = following.firstOrNull { it.steps == extended }
|
||||
if (done != null) {
|
||||
controller.submit(done)
|
||||
selected = null
|
||||
path = emptyList()
|
||||
} else {
|
||||
path = extended
|
||||
}
|
||||
}
|
||||
|
||||
legal.any { it.from == point } -> {
|
||||
selected = point
|
||||
path = emptyList()
|
||||
}
|
||||
|
||||
else -> {
|
||||
selected = null
|
||||
path = emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
val wide = maxWidth > maxHeight
|
||||
val side = if (wide) {
|
||||
minOf(maxHeight, maxWidth * 0.6f)
|
||||
} else {
|
||||
minOf(maxWidth, maxHeight * 0.68f)
|
||||
}
|
||||
|
||||
val board: @Composable () -> Unit = {
|
||||
Board(
|
||||
state = current,
|
||||
side = side,
|
||||
selected = selected,
|
||||
path = path.toSet(),
|
||||
destinations = nextHops,
|
||||
onTap = onPointTapped,
|
||||
)
|
||||
}
|
||||
val panel: @Composable (Modifier) -> Unit = { panelModifier ->
|
||||
Column(modifier = panelModifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Status(current, localSeat, path.isNotEmpty())
|
||||
MoveLog(current, modifier = Modifier.weight(1f, fill = false))
|
||||
}
|
||||
}
|
||||
|
||||
if (wide) {
|
||||
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
board()
|
||||
panel(Modifier.weight(1f).fillMaxSize())
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
board()
|
||||
panel(Modifier.fillMaxWidth().weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
val over = abandoned
|
||||
when {
|
||||
over != null -> LeftFieldDialog(over, onExit, controller::dismissAbandoned)
|
||||
confirmingEnd -> LeaveFieldDialog(onExit) { confirmingEnd = false }
|
||||
current.phase == PiratesPhase.GAME_OVER -> ResultDialog(current, onExit)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val TITLE = "Pirates and Bulgars"
|
||||
|
||||
@Composable
|
||||
private fun Board(
|
||||
state: PiratesState,
|
||||
side: Dp,
|
||||
selected: Int?,
|
||||
path: Set<Int>,
|
||||
destinations: Set<Int>,
|
||||
onTap: (Int) -> Unit,
|
||||
) {
|
||||
Box(modifier = Modifier.size(side)) {
|
||||
Lines(modifier = Modifier.fillMaxSize())
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
for (row in 0 until GRID) {
|
||||
Row(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
for (column in 0 until GRID) {
|
||||
val point = pointAt(row, column)
|
||||
Box(
|
||||
modifier = Modifier.weight(1f).fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (point != null) {
|
||||
Point(
|
||||
state = state,
|
||||
point = point,
|
||||
cell = side / GRID,
|
||||
selected = point == selected,
|
||||
onPath = point in path,
|
||||
isDestination = point in destinations,
|
||||
onTap = { onTap(point) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The board itself: the cross, the stronghold shaded, and a line between every
|
||||
* pair of points that are joined.
|
||||
*
|
||||
* The lines are drawn from the adjacency table rather than from a picture of a
|
||||
* board, so what a player can see is exactly what the rules allow — there is no
|
||||
* way for the drawing and the moves to disagree about where the diagonals are.
|
||||
*/
|
||||
@Composable
|
||||
private fun Lines(modifier: Modifier) {
|
||||
Canvas(modifier = modifier) {
|
||||
val cell = size.width / GRID
|
||||
val stroke = (cell * 0.045f).coerceAtLeast(2f)
|
||||
fun centre(point: Int) = Offset(
|
||||
x = (columnOf(point) + 0.5f) * cell,
|
||||
y = (rowOf(point) + 0.5f) * cell,
|
||||
)
|
||||
|
||||
// The cross, filled square by square so the cut corners come out right.
|
||||
for (row in 0 until GRID) {
|
||||
for (column in 0 until GRID) {
|
||||
if (!isOnBoard(row, column)) continue
|
||||
val inStronghold = pointAt(row, column)?.let { isStronghold(it) } == true
|
||||
drawRect(
|
||||
color = if (inStronghold) StrongholdField else BoardField,
|
||||
topLeft = Offset(column * cell, row * cell),
|
||||
size = androidx.compose.ui.geometry.Size(cell, cell),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (from in 0 until POINTS) {
|
||||
for (to in ADJACENCY[from]) {
|
||||
// Each line once rather than twice over.
|
||||
if (to <= from) continue
|
||||
drawLine(
|
||||
color = BoardLine,
|
||||
start = centre(from),
|
||||
end = centre(to),
|
||||
strokeWidth = stroke,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Point(
|
||||
state: PiratesState,
|
||||
point: Int,
|
||||
cell: Dp,
|
||||
selected: Boolean,
|
||||
onPath: Boolean,
|
||||
isDestination: Boolean,
|
||||
onTap: () -> Unit,
|
||||
) {
|
||||
val occupant = state.board[point]
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().clickable { onTap() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
when {
|
||||
occupant != null -> Box(
|
||||
modifier = Modifier
|
||||
.size(cell * 0.7f)
|
||||
.clip(CircleShape)
|
||||
.background(if (occupant == PIRATE_SEAT) PirateStone else BulgarStone)
|
||||
.border(
|
||||
width = when {
|
||||
selected -> cell * 0.07f
|
||||
onPath -> cell * 0.05f
|
||||
else -> 1.dp
|
||||
},
|
||||
color = when {
|
||||
selected -> SelectedRing
|
||||
onPath -> PathRing
|
||||
else -> StoneEdge
|
||||
},
|
||||
shape = CircleShape,
|
||||
)
|
||||
)
|
||||
|
||||
else -> Box(
|
||||
modifier = Modifier
|
||||
.size(if (isDestination) cell * 0.32f else cell * 0.14f)
|
||||
.clip(CircleShape)
|
||||
.background(if (isDestination) DestinationDot else VacantPoint)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Status(state: PiratesState, localSeat: Int, midJump: Boolean) {
|
||||
val yours = state.turn == localSeat && state.phase == PiratesPhase.PLAYING
|
||||
Column {
|
||||
Text(
|
||||
text = state.outcome?.label ?: when {
|
||||
midJump -> "Keep jumping — the capture has to be finished"
|
||||
yours -> "Your move"
|
||||
else -> "Thinking…"
|
||||
},
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = "You play ${if (localSeat == PIRATE_SEAT) "the pirates" else "the Bulgars"} · " +
|
||||
"${state.stronghold()} of ${STRONGHOLD.size} points held, " +
|
||||
"${state.count(BULGAR_SEAT)} Bulgars left",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Text(
|
||||
text = if (localSeat == PIRATE_SEAT) {
|
||||
"Cut them below ${STRONGHOLD.size} and the assault is finished. " +
|
||||
"You must take when you can."
|
||||
} else {
|
||||
"Fill the stronghold or pen the pirates in. You cannot take anything, " +
|
||||
"and you cannot go back."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MoveLog(state: PiratesState, modifier: Modifier = Modifier) {
|
||||
if (state.moveLog.isEmpty()) {
|
||||
Text("No moves yet.", style = MaterialTheme.typography.bodySmall, modifier = modifier)
|
||||
return
|
||||
}
|
||||
Column(modifier = modifier.verticalScroll(rememberScrollState())) {
|
||||
state.moveLog.takeLast(30).forEach {
|
||||
Text(text = it, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LeaveFieldDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onKeepPlaying,
|
||||
title = { Text("End this game?") },
|
||||
text = {
|
||||
Text(
|
||||
"This game ends here and the position is not saved. Anyone else " +
|
||||
"at the table is told you have left."
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LeftFieldDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onStay,
|
||||
title = { Text("The game has ended") },
|
||||
text = { Text("$notice There are no more moves to play.") },
|
||||
confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } },
|
||||
dismissButton = { TextButton(onClick = onStay) { Text("Look at the board") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResultDialog(state: PiratesState, onExit: () -> Unit) {
|
||||
var dismissed by remember(state.outcome) { mutableStateOf(false) }
|
||||
if (dismissed) return
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { dismissed = true },
|
||||
title = { Text("Game over") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(state.outcome?.label.orEmpty(), fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
text = "${state.moveLog.size} moves played.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } },
|
||||
dismissButton = { TextButton(onClick = { dismissed = true }) { Text("Look at the board") } },
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user