Merge dev: Kaiser, Crazy 8s, Wizard and Chess

Four more games, taking the app from four playable to eight. All four
are listed in a production build, so a release now offers Euchre,
Kaiser, President, Golf, Wizard, Crazy 8s, Sequence and Chess.

Also carries two changes the chess search forced: the AI now thinks off
the caller's thread rather than on the screen's scope, and offline setup
can seat the local player somewhere other than seat 0 — which is what
lets you play Black.

Mastermind, Backgammon and Checkers remain dev-only placeholders.
This commit is contained in:
Claude
2026-08-06 13:42:07 +00:00
35 changed files with 6744 additions and 38 deletions
+11 -3
View File
@@ -5,12 +5,12 @@ or against other people over Wi-Fi (LAN) or Bluetooth.
## What works today ## What works today
The app shell is complete and four games are playable end to end: The app shell is complete and eight games are playable end to end:
| | | | | |
| --- | --- | | --- | --- |
| **Playable** | Euchre, Sequence, President, Golf | | **Playable** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s, Sequence, Chess |
| **Listed, not yet implemented** | Kaiser, Wizard, Crazy 8s, Mastermind, Backgammon, Chess, Checkers | | **Listed, not yet implemented** | Mastermind, Backgammon, Checkers |
Unimplemented games appear in the menu marked *Coming soon* **on dev builds Unimplemented games appear in the menu marked *Coming soon* **on dev builds
only** — a production release lists just the games that can actually be played, only** — a production release lists just the games that can actually be played,
@@ -148,8 +148,12 @@ game:
- [Euchre](docs/RULES-euchre.md) - [Euchre](docs/RULES-euchre.md)
- [Sequence](docs/RULES-sequence.md) — **note the caveat about the board layout** - [Sequence](docs/RULES-sequence.md) — **note the caveat about the board layout**
- [Kaiser](docs/RULES-kaiser.md)
- [President](docs/RULES-president.md) - [President](docs/RULES-president.md)
- [Golf](docs/RULES-golf.md) - [Golf](docs/RULES-golf.md)
- [Wizard](docs/RULES-wizard.md)
- [Crazy 8s](docs/RULES-crazy8s.md)
- [Chess](docs/RULES-chess.md)
## Layout ## Layout
@@ -158,9 +162,13 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested
cards/ deck, suits, ranks cards/ deck, suits, ranks
engine/ GameRules / GameAi contracts, seats, table config engine/ GameRules / GameAi contracts, seats, table config
euchre/ Euchre model, rules, AI euchre/ Euchre model, rules, AI
kaiser/ Kaiser model, bidding, rules, AI
sequence/ Sequence model, board, rules, AI sequence/ Sequence model, board, rules, AI
president/ President model, rules, AI president/ President model, rules, AI
golf/ Golf model, scoring, rules, AI golf/ Golf model, scoring, rules, AI
wizard/ Wizard model, rules, AI
crazyeights/ Crazy 8s model, rules, AI
chess/ Chess model, FEN, move generation, search
net/ wire protocol, LAN and Bluetooth transports, lobby, match driver net/ wire protocol, LAN and Bluetooth transports, lobby, match driver
settings/ DataStore-backed preferences settings/ DataStore-backed preferences
update/ GitHub Releases OTA updater update/ GitHub Releases OTA updater
@@ -58,7 +58,7 @@ object GameCatalog {
maxPlayers = 4, maxPlayers = 4,
teamBased = true, teamBased = true,
blurb = "Prairie trick-taking with bidding. The 5 of hearts and 3 of spades decide it.", blurb = "Prairie trick-taking with bidding. The 5 of hearts and 3 of spades decide it.",
available = false, available = true,
), ),
GameDescriptor( GameDescriptor(
id = PRESIDENT, id = PRESIDENT,
@@ -88,7 +88,7 @@ object GameCatalog {
maxPlayers = 6, maxPlayers = 6,
teamBased = false, teamBased = false,
blurb = "Bid your tricks exactly. Wizards always win, jesters always lose.", blurb = "Bid your tricks exactly. Wizards always win, jesters always lose.",
available = false, available = true,
), ),
GameDescriptor( GameDescriptor(
id = CRAZY_EIGHTS, id = CRAZY_EIGHTS,
@@ -98,7 +98,7 @@ object GameCatalog {
maxPlayers = 6, maxPlayers = 6,
teamBased = false, teamBased = false,
blurb = "Match rank or suit, and eights change everything.", blurb = "Match rank or suit, and eights change everything.",
available = false, available = true,
), ),
GameDescriptor( GameDescriptor(
id = SEQUENCE, id = SEQUENCE,
@@ -138,7 +138,7 @@ object GameCatalog {
maxPlayers = 2, maxPlayers = 2,
teamBased = false, teamBased = false,
blurb = "The old game. Full legal moves, castling, en passant, promotion.", blurb = "The old game. Full legal moves, castling, en passant, promotion.",
available = false, available = true,
), ),
GameDescriptor( GameDescriptor(
id = CHECKERS, id = CHECKERS,
@@ -19,8 +19,14 @@ enum class Suit(val symbol: String, val isRed: Boolean) {
} }
} }
/**
* [standard] marks the fifty-two cards every ordinary deck holds. The two
* extras belong to Wizard alone, so anything building a normal deck must filter
* on this rather than walking the whole enum — otherwise every other game
* quietly gains eight cards it has no rules for.
*/
@Serializable @Serializable
enum class Rank(val short: String, val order: Int) { enum class Rank(val short: String, val order: Int, val standard: Boolean = true) {
TWO("2", 2), TWO("2", 2),
THREE("3", 3), THREE("3", 3),
FOUR("4", 4), FOUR("4", 4),
@@ -34,6 +40,18 @@ enum class Rank(val short: String, val order: Int) {
QUEEN("Q", 12), QUEEN("Q", 12),
KING("K", 13), KING("K", 13),
ACE("A", 14), ACE("A", 14),
/** Wizard only: beats everything, including trump. */
WIZARD("Wz", 100, standard = false),
/** Wizard only: loses to everything, and leads no suit. */
JESTER("Je", 0, standard = false),
;
companion object {
/** The thirteen ranks of an ordinary deck. */
val standard: List<Rank> get() = entries.filter { it.standard }
}
} }
@Serializable @Serializable
@@ -61,7 +79,7 @@ object Decks {
/** Standard 52-card deck. */ /** Standard 52-card deck. */
fun standard52(): List<Card> = fun standard52(): List<Card> =
Suit.entries.flatMap { suit -> Rank.entries.map { rank -> Card(rank, suit) } } Suit.entries.flatMap { suit -> Rank.standard.map { rank -> Card(rank, suit) } }
/** /**
* Euchre decks. 24 cards (9 through ace) is the common game; 32 cards * Euchre decks. 24 cards (9 through ace) is the common game; 32 cards
@@ -74,12 +92,38 @@ object Decks {
else -> throw IllegalArgumentException("Unsupported Euchre deck size: $size") else -> throw IllegalArgumentException("Unsupported Euchre deck size: $size")
} }
return Suit.entries.flatMap { suit -> return Suit.entries.flatMap { suit ->
Rank.entries.filter { it.order >= lowest.order }.map { rank -> Card(rank, suit) } Rank.standard.filter { it.order >= lowest.order }.map { rank -> Card(rank, suit) }
} }
} }
/**
* The 60-card Wizard deck: an ordinary pack plus four wizards and four
* jesters, one of each per suit so they fit the same card model.
*/
fun wizard(): List<Card> = standard52() +
Suit.entries.map { Card(Rank.WIZARD, it) } +
Suit.entries.map { Card(Rank.JESTER, it) }
/** Two full decks shuffled together, as Sequence uses. */ /** Two full decks shuffled together, as Sequence uses. */
fun double52(): List<Card> = standard52() + standard52() fun double52(): List<Card> = standard52() + standard52()
/**
* The 32-card Kaiser deck: eight to ace in every suit, plus the seven of
* clubs and the seven of diamonds — and, in place of the other two sevens,
* the five of hearts and the three of spades, which are what the whole game
* is played for.
*/
fun kaiser(): List<Card> {
val high = Suit.entries.flatMap { suit ->
Rank.standard.filter { it.order >= Rank.EIGHT.order }.map { Card(it, suit) }
}
return high + listOf(
Card(Rank.SEVEN, Suit.CLUBS),
Card(Rank.SEVEN, Suit.DIAMONDS),
Card(Rank.FIVE, Suit.HEARTS),
Card(Rank.THREE, Suit.SPADES),
)
}
} }
/** /**
@@ -0,0 +1,281 @@
package org.prolibertate.games.game.chess
import kotlin.math.abs
import kotlin.random.Random
import org.prolibertate.games.game.engine.GameAi
/**
* A negamax search with alpha-beta pruning, iterative deepening and a capture-
* only quiescence tail.
*
* The quiescence tail is what stops it hanging pieces: a fixed-depth search that
* stops in the middle of an exchange scores the position as though the recapture
* never happens, and every such search plays like it is being robbed.
*
* Search depth comes from the table's own options unless a level is passed in,
* so the setup screen sets the strength without the caller having to thread it
* through.
*/
class ChessAi(private val level: ChessLevel? = null) : GameAi<ChessState, ChessMove> {
/**
* Ceiling on how much thinking any one move gets. A phone should not stall
* on a sharp middlegame, so the search stops where it is and plays the best
* move from the last depth it finished.
*/
private var nodeBudget = 0
private var nodesUsed = 0
override fun chooseMove(state: ChessState, seat: Int, legal: List<ChessMove>): ChessMove {
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
// Deterministic per position, so a host and a client replaying the same
// game reach the same move, but varied enough that two games from the
// opening array do not run identically.
val random = Random(state.repetitionKeys.size * 31L + state.toFen().hashCode())
var best = legal.first()
var scored = legal.map { it to 0 }
for (depth in 1..setting.depth) {
val results = mutableListOf<Pair<ChessMove, Int>>()
// Search the previous iteration's best first, so the sub-searches
// below it have a decent bound to prune against.
val ordered = orderMoves(state, legal, preferred = best)
for (move in ordered) {
// Every root move gets a full window rather than being cut off
// against the best so far. It costs some pruning, but it is
// what makes the scores comparable — the weaker levels pick
// from among the near-best moves, and a move that returned only
// an upper bound cannot be compared with one that did not.
val score = -negamax(
state = ChessRules.advanced(state, move),
depth = depth - 1,
alpha = -INFINITY,
beta = INFINITY,
ply = 1,
)
results += move to score
if (nodesUsed >= nodeBudget) break
}
// A half-finished iteration has scored only some of the moves, so
// it is thrown away rather than compared against a full one.
if (nodesUsed >= nodeBudget && results.size < ordered.size) break
scored = results
best = results.maxBy { it.second }.first
}
// Weaker levels take any move that is nearly as good, which is what
// makes them beatable without making them play nonsense.
val bestScore = scored.maxOf { it.second }
val acceptable = scored.filter { it.second >= bestScore - setting.slack() }
return acceptable[random.nextInt(acceptable.size)].first
}
private fun ChessLevel.slack(): Int = when (this) {
ChessLevel.CASUAL -> 70
ChessLevel.CLUB -> 20
ChessLevel.STRONG -> 0
}
// -----------------------------------------------------------------------
// Search
// -----------------------------------------------------------------------
private fun negamax(state: ChessState, depth: Int, alpha: Int, beta: Int, ply: Int): Int {
nodesUsed++
val moves = ChessRules.legalMoves(state)
if (moves.isEmpty()) {
// Mate is scored by distance, so the search prefers mating sooner
// and, when it is losing, delays as long as it can.
return if (ChessRules.inCheck(state)) -(MATE - ply) else 0
}
if (state.halfmoveClock >= 100) return 0
if (depth <= 0) return quiescence(state, alpha, beta, ply)
var localAlpha = alpha
for (move in orderMoves(state, moves, preferred = null)) {
val score = -negamax(ChessRules.advanced(state, move), depth - 1, -beta, -localAlpha, ply + 1)
if (score >= beta) return beta
if (score > localAlpha) localAlpha = score
if (nodesUsed >= nodeBudget) break
}
return localAlpha
}
/**
* Keeps searching while pieces are still being taken.
*
* Standing pat is allowed because the side to move is never obliged to
* capture — the position as it stands is a lower bound on what it can get.
*/
private fun quiescence(state: ChessState, alpha: Int, beta: Int, ply: Int): Int {
nodesUsed++
val standPat = evaluate(state)
if (standPat >= beta) return beta
var localAlpha = maxOf(alpha, standPat)
if (ply >= MAX_QUIESCENCE_PLY) return localAlpha
val captures = ChessRules.legalMoves(state).filter {
ChessRules.isCapture(state, it) || it.promotion != null
}
for (move in orderMoves(state, captures, preferred = null)) {
val score = -quiescence(ChessRules.advanced(state, move), -beta, -localAlpha, ply + 1)
if (score >= beta) return beta
if (score > localAlpha) localAlpha = score
if (nodesUsed >= nodeBudget) break
}
return localAlpha
}
/** Captures first, biggest victim by smallest attacker — cheap and effective. */
private fun orderMoves(
state: ChessState,
moves: List<ChessMove>,
preferred: ChessMove?,
): List<ChessMove> = moves.sortedByDescending { move ->
var score = 0
if (move == preferred) score += 1_000_000
val victim = state.board[move.to]
if (victim != null) {
score += 10_000 + victim.kind.value - (state.board[move.from]?.kind?.value ?: 0) / 10
}
move.promotion?.let { score += 9_000 + it.value }
score
}
// -----------------------------------------------------------------------
// Evaluation
// -----------------------------------------------------------------------
/** Centipawns, from the point of view of whoever is to move. */
fun evaluate(state: ChessState): Int {
var white = 0
var black = 0
var whitePieces = 0
var blackPieces = 0
for (square in 0 until 64) {
val piece = state.board[square] ?: continue
val table = tableFor(piece.kind)
// Black reads the same table from the other end of the board.
val placement = table[if (piece.white) square else square xor 56]
if (piece.white) {
white += piece.kind.value + placement
if (piece.kind != PieceKind.PAWN && piece.kind != PieceKind.KING) whitePieces++
} else {
black += piece.kind.value + placement
if (piece.kind != PieceKind.PAWN && piece.kind != PieceKind.KING) blackPieces++
}
}
// In a bare endgame the tables stop meaning much and driving the enemy
// king to the edge starts to; without this a won endgame shuffles.
if (whitePieces + blackPieces <= 2) {
state.kingSquare(white = false)?.let { white += edgeDistance(it) * 8 }
state.kingSquare(white = true)?.let { black += edgeDistance(it) * 8 }
}
val score = white - black
return if (state.whiteToMove) score else -score
}
/** How far a king has been pushed towards the rim, 0 in the centre. */
private fun edgeDistance(square: Int): Int {
val file = fileOf(square)
val rank = rankOf(square)
return maxOf(abs(file * 2 - 7), abs(rank * 2 - 7))
}
private fun tableFor(kind: PieceKind): IntArray = when (kind) {
PieceKind.PAWN -> PAWN_TABLE
PieceKind.KNIGHT -> KNIGHT_TABLE
PieceKind.BISHOP -> BISHOP_TABLE
PieceKind.ROOK -> ROOK_TABLE
PieceKind.QUEEN -> QUEEN_TABLE
PieceKind.KING -> KING_TABLE
}
private companion object {
const val INFINITY = 1_000_000
const val MATE = 100_000
const val NODE_BUDGET = 120_000
const val MAX_QUIESCENCE_PLY = 24
/**
* Placement bonuses in centipawns, written from White's side with rank
* eight on the top row — the same reading order the board uses, so the
* table can be laid out to look like a board.
*/
val PAWN_TABLE = intArrayOf(
0, 0, 0, 0, 0, 0, 0, 0,
50, 50, 50, 50, 50, 50, 50, 50,
10, 10, 20, 30, 30, 20, 10, 10,
5, 5, 10, 25, 25, 10, 5, 5,
0, 0, 0, 20, 20, 0, 0, 0,
5, -5, -10, 0, 0, -10, -5, 5,
5, 10, 10, -20, -20, 10, 10, 5,
0, 0, 0, 0, 0, 0, 0, 0,
)
val KNIGHT_TABLE = intArrayOf(
-50, -40, -30, -30, -30, -30, -40, -50,
-40, -20, 0, 0, 0, 0, -20, -40,
-30, 0, 10, 15, 15, 10, 0, -30,
-30, 5, 15, 20, 20, 15, 5, -30,
-30, 0, 15, 20, 20, 15, 0, -30,
-30, 5, 10, 15, 15, 10, 5, -30,
-40, -20, 0, 5, 5, 0, -20, -40,
-50, -40, -30, -30, -30, -30, -40, -50,
)
val BISHOP_TABLE = intArrayOf(
-20, -10, -10, -10, -10, -10, -10, -20,
-10, 0, 0, 0, 0, 0, 0, -10,
-10, 0, 5, 10, 10, 5, 0, -10,
-10, 5, 5, 10, 10, 5, 5, -10,
-10, 0, 10, 10, 10, 10, 0, -10,
-10, 10, 10, 10, 10, 10, 10, -10,
-10, 5, 0, 0, 0, 0, 5, -10,
-20, -10, -10, -10, -10, -10, -10, -20,
)
val ROOK_TABLE = intArrayOf(
0, 0, 0, 0, 0, 0, 0, 0,
5, 10, 10, 10, 10, 10, 10, 5,
-5, 0, 0, 0, 0, 0, 0, -5,
-5, 0, 0, 0, 0, 0, 0, -5,
-5, 0, 0, 0, 0, 0, 0, -5,
-5, 0, 0, 0, 0, 0, 0, -5,
-5, 0, 0, 0, 0, 0, 0, -5,
0, 0, 0, 5, 5, 0, 0, 0,
)
val QUEEN_TABLE = intArrayOf(
-20, -10, -10, -5, -5, -10, -10, -20,
-10, 0, 0, 0, 0, 0, 0, -10,
-10, 0, 5, 5, 5, 5, 0, -10,
-5, 0, 5, 5, 5, 5, 0, -5,
0, 0, 5, 5, 5, 5, 0, -5,
-10, 5, 5, 5, 5, 5, 0, -10,
-10, 0, 5, 0, 0, 0, 0, -10,
-20, -10, -10, -5, -5, -10, -10, -20,
)
val KING_TABLE = intArrayOf(
-30, -40, -40, -50, -50, -40, -40, -30,
-30, -40, -40, -50, -50, -40, -40, -30,
-30, -40, -40, -50, -50, -40, -40, -30,
-30, -40, -40, -50, -50, -40, -40, -30,
-20, -30, -30, -40, -40, -30, -30, -20,
-10, -20, -20, -20, -20, -20, -20, -10,
20, 20, 0, 0, 0, 20, 20, 20,
20, 30, 10, 0, 0, 10, 30, 20,
)
}
}
@@ -0,0 +1,258 @@
package org.prolibertate.games.game.chess
import kotlinx.serialization.Serializable
/**
* Squares are numbered the way a FEN string reads them: 0 is a8, 7 is h8, and
* 63 is h1. Keeping the board in reading order means parsing and printing a
* position are both straight loops, and the screen only has to flip the index
* when it draws from Black's side.
*/
fun squareOf(file: Int, rank: Int): Int = (7 - rank) * 8 + file
/** 0 is the a-file. */
fun fileOf(square: Int): Int = square % 8
/** 0 is rank one. */
fun rankOf(square: Int): Int = 7 - square / 8
fun squareName(square: Int): String =
"${'a' + fileOf(square)}${rankOf(square) + 1}"
fun squareFromName(name: String): Int =
squareOf(name[0] - 'a', name[1] - '1')
/** True for the light squares, which is all the screen needs to colour a board. */
fun isLightSquare(square: Int): Boolean = (fileOf(square) + rankOf(square)) % 2 == 1
@Serializable
enum class PieceKind(val letter: Char, val glyphIndex: Int, val value: Int) {
PAWN('P', 5, 100),
KNIGHT('N', 4, 320),
BISHOP('B', 3, 330),
ROOK('R', 2, 500),
QUEEN('Q', 1, 900),
/**
* The king has no material value: it is never captured, and a search that
* could trade it for anything would happily do so.
*/
KING('K', 0, 0),
;
companion object {
fun fromLetter(letter: Char): PieceKind? =
entries.firstOrNull { it.letter == letter.uppercaseChar() }
}
}
@Serializable
data class Piece(val kind: PieceKind, val white: Boolean) {
/** FEN notation: white in upper case, black in lower. */
val letter: Char get() = if (white) kind.letter else kind.letter.lowercaseChar()
/** The Unicode chess figurine, which is how the board is drawn. */
val glyph: String get() = (if (white) WHITE_GLYPHS else BLACK_GLYPHS)[kind.glyphIndex].toString()
}
private const val WHITE_GLYPHS = "♔♕♖♗♘♙"
private const val BLACK_GLYPHS = "♚♛♜♝♞♟"
/** Which castles are still available, regardless of whether they are legal now. */
@Serializable
data class CastlingRights(
val whiteKingSide: Boolean = true,
val whiteQueenSide: Boolean = true,
val blackKingSide: Boolean = true,
val blackQueenSide: Boolean = true,
) {
fun kingSide(white: Boolean): Boolean = if (white) whiteKingSide else blackKingSide
fun queenSide(white: Boolean): Boolean = if (white) whiteQueenSide else blackQueenSide
/** The FEN field, or "-" when nobody may castle. */
fun toFen(): String = buildString {
if (whiteKingSide) append('K')
if (whiteQueenSide) append('Q')
if (blackKingSide) append('k')
if (blackQueenSide) append('q')
if (isEmpty()) append('-')
}
companion object {
fun fromFen(field: String): CastlingRights = CastlingRights(
whiteKingSide = field.contains('K'),
whiteQueenSide = field.contains('Q'),
blackKingSide = field.contains('k'),
blackQueenSide = field.contains('q'),
)
}
}
/**
* A move is only ever from one square to another, plus what a pawn turns into.
* Castling is recorded as the king's two-square step and en passant as the
* pawn's diagonal — the rules work out the rest from the position.
*/
@Serializable
data class ChessMove(
val from: Int,
val to: Int,
val promotion: PieceKind? = null,
) {
/** Long algebraic, as used by every engine protocol: e2e4, e7e8q. */
override fun toString(): String =
squareName(from) + squareName(to) + (promotion?.letter?.lowercaseChar() ?: "")
}
@Serializable
enum class ChessPhase { PLAYING, GAME_OVER }
@Serializable
enum class ChessOutcome(val label: String) {
// Named for who wins rather than who was mated: every caller wants the
// result, and "White mated" reads either way round.
WHITE_WINS("White wins by checkmate"),
BLACK_WINS("Black wins by checkmate"),
STALEMATE("Draw by stalemate"),
FIFTY_MOVE("Draw by the fifty-move rule"),
REPETITION("Draw by threefold repetition"),
INSUFFICIENT_MATERIAL("Draw — neither side can mate"),
}
/** How hard the computer thinks about it. */
@Serializable
enum class ChessLevel(val label: String, val depth: Int) {
CASUAL("Casual", depth = 2),
CLUB("Club", depth = 3),
STRONG("Strong", depth = 4),
}
@Serializable
data class ChessOptions(
val level: ChessLevel = ChessLevel.CLUB,
/** A hundred half-moves with no capture and no pawn move is a draw. */
val fiftyMoveRule: Boolean = true,
/** The same position three times over is a draw. */
val threefoldRepetition: Boolean = true,
/**
* Where the game starts, in FEN. Blank means the ordinary opening array;
* anything else lets a table set up a puzzle or resume an adjourned game.
*/
val startingFen: String = "",
)
@Serializable
data class ChessState(
val options: ChessOptions,
/** Sixty-four squares in FEN reading order; null is an empty square. */
val board: List<Piece?>,
val whiteToMove: Boolean,
val castling: CastlingRights,
/** The square a pawn just skipped over, which is where it may be taken. */
val enPassant: Int?,
val halfmoveClock: Int,
val fullmoveNumber: Int,
val phase: ChessPhase,
val outcome: ChessOutcome?,
val lastMove: ChessMove?,
/**
* One entry per position reached, in the four-field form that decides
* repetition — the clocks are deliberately left out, since a position
* repeats whether or not the same number of moves has been played.
*/
val repetitionKeys: List<String>,
/** The game score in algebraic notation, one entry per half-move. */
val moveLog: List<String>,
) {
/** Seat 0 is White, seat 1 is Black. */
val turnSeat: Int get() = if (whiteToMove) WHITE_SEAT else BLACK_SEAT
fun kingSquare(white: Boolean): Int? =
board.indexOfFirst { it != null && it.white == white && it.kind == PieceKind.KING }
.takeIf { it >= 0 }
}
const val WHITE_SEAT = 0
const val BLACK_SEAT = 1
fun seatIsWhite(seat: Int): Boolean = seat == WHITE_SEAT
const val START_FEN = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
/** A position with everything needed to resume it: board, side, rights, clocks. */
data class FenPosition(
val board: List<Piece?>,
val whiteToMove: Boolean,
val castling: CastlingRights,
val enPassant: Int?,
val halfmoveClock: Int,
val fullmoveNumber: Int,
)
/**
* Parses a FEN string.
*
* This is what the tests are built on — the standard perft positions are all
* published as FEN — and it is also what lets a game start from something other
* than the opening array.
*/
fun parseFen(fen: String): FenPosition {
val fields = fen.trim().split(" ")
require(fields.size >= 4) { "FEN needs at least four fields: $fen" }
val board = arrayOfNulls<Piece>(64)
var index = 0
for (character in fields[0]) {
when {
character == '/' -> Unit
character.isDigit() -> index += character - '0'
else -> {
val kind = requireNotNull(PieceKind.fromLetter(character)) {
"Unknown piece '$character' in $fen"
}
require(index < 64) { "Too many squares in $fen" }
board[index] = Piece(kind, white = character.isUpperCase())
index++
}
}
}
require(index == 64) { "FEN describes $index squares, not 64: $fen" }
return FenPosition(
board = board.toList(),
whiteToMove = fields[1] == "w",
castling = CastlingRights.fromFen(fields[2]),
enPassant = fields[3].takeIf { it != "-" }?.let { squareFromName(it) },
halfmoveClock = fields.getOrNull(4)?.toIntOrNull() ?: 0,
fullmoveNumber = fields.getOrNull(5)?.toIntOrNull() ?: 1,
)
}
/** The first four FEN fields — the part that decides whether a position repeats. */
fun positionKey(
board: List<Piece?>,
whiteToMove: Boolean,
castling: CastlingRights,
enPassant: Int?,
): String = buildString {
for (rank in 0 until 8) {
var empty = 0
for (file in 0 until 8) {
val piece = board[rank * 8 + file]
if (piece == null) {
empty++
} else {
if (empty > 0) { append(empty); empty = 0 }
append(piece.letter)
}
}
if (empty > 0) append(empty)
if (rank < 7) append('/')
}
append(' ').append(if (whiteToMove) 'w' else 'b')
append(' ').append(castling.toFen())
append(' ').append(enPassant?.let { squareName(it) } ?: "-")
}
fun ChessState.toFen(): String =
positionKey(board, whiteToMove, castling, enPassant) + " $halfmoveClock $fullmoveNumber"
@@ -0,0 +1,577 @@
package org.prolibertate.games.game.chess
import kotlin.math.abs
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
/**
* Chess as a pure state machine. See RULES-chess.md.
*
* Move generation is pseudo-legal followed by a king-safety filter: every
* candidate is played onto a copy of the board and thrown away if it leaves the
* mover in check. That is slower than tracking pins, and it is the reason the
* perft counts in the tests match published figures exactly — there is no
* special case to get subtly wrong.
*/
object ChessRules : GameRules<ChessState, ChessMove> {
override val gameId: String = GameCatalog.CHESS
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
override fun initialState(config: TableConfig): ChessState {
require(config.seats.size == 2) { "Chess is a two-handed game" }
val options = json.decodeFromString<ChessOptions>(config.optionsJson)
return fromPosition(options, options.startingFen.ifBlank { START_FEN })
}
/** Builds a state from a FEN string, which is also how a puzzle is loaded. */
fun fromPosition(options: ChessOptions, fen: String): ChessState {
val position = parseFen(fen)
return ChessState(
options = options,
board = position.board,
whiteToMove = position.whiteToMove,
castling = position.castling,
enPassant = position.enPassant,
halfmoveClock = position.halfmoveClock,
fullmoveNumber = position.fullmoveNumber,
phase = ChessPhase.PLAYING,
outcome = null,
lastMove = null,
repetitionKeys = listOf(
positionKey(
position.board,
position.whiteToMove,
position.castling,
position.enPassant,
)
),
moveLog = emptyList(),
).withTerminalCheck()
}
override fun currentSeat(state: ChessState): Int? =
if (state.phase == ChessPhase.PLAYING) state.turnSeat else null
// -----------------------------------------------------------------------
// Attacks
// -----------------------------------------------------------------------
private val KNIGHT_STEPS =
listOf(1 to 2, 2 to 1, 2 to -1, 1 to -2, -1 to -2, -2 to -1, -2 to 1, -1 to 2)
private val KING_STEPS =
listOf(1 to 0, 1 to 1, 0 to 1, -1 to 1, -1 to 0, -1 to -1, 0 to -1, 1 to -1)
private val ROOK_DIRS = listOf(1 to 0, -1 to 0, 0 to 1, 0 to -1)
private val BISHOP_DIRS = listOf(1 to 1, 1 to -1, -1 to 1, -1 to -1)
private fun pieceAt(board: List<Piece?>, file: Int, rank: Int): Piece? =
if (file in 0..7 && rank in 0..7) board[squareOf(file, rank)] else null
/** Whether [square] is attacked by the side [byWhite], king included. */
fun isAttacked(board: List<Piece?>, square: Int, byWhite: Boolean): Boolean {
val file = fileOf(square)
val rank = rankOf(square)
// A pawn attacks forwards, so an attacker stands one rank behind.
val pawnRank = rank - if (byWhite) 1 else -1
for (side in intArrayOf(-1, 1)) {
val found = pieceAt(board, file + side, pawnRank)
if (found != null && found.white == byWhite && found.kind == PieceKind.PAWN) return true
}
for ((df, dr) in KNIGHT_STEPS) {
val found = pieceAt(board, file + df, rank + dr)
if (found != null && found.white == byWhite && found.kind == PieceKind.KNIGHT) {
return true
}
}
for ((df, dr) in KING_STEPS) {
val found = pieceAt(board, file + df, rank + dr)
if (found != null && found.white == byWhite && found.kind == PieceKind.KING) return true
}
if (raysHit(board, file, rank, byWhite, ROOK_DIRS, PieceKind.ROOK)) return true
if (raysHit(board, file, rank, byWhite, BISHOP_DIRS, PieceKind.BISHOP)) return true
return false
}
/** Walks each direction until something blocks, looking for [kind] or a queen. */
private fun raysHit(
board: List<Piece?>,
file: Int,
rank: Int,
byWhite: Boolean,
directions: List<Pair<Int, Int>>,
kind: PieceKind,
): Boolean {
for ((df, dr) in directions) {
var f = file + df
var r = rank + dr
while (f in 0..7 && r in 0..7) {
val found = board[squareOf(f, r)]
if (found != null) {
if (found.white == byWhite &&
(found.kind == kind || found.kind == PieceKind.QUEEN)
) {
return true
}
break
}
f += df
r += dr
}
}
return false
}
/** Whether the side to move is in check. */
fun inCheck(state: ChessState): Boolean = inCheck(state.board, state.whiteToMove)
fun inCheck(board: List<Piece?>, white: Boolean): Boolean {
val king = board.indexOfFirst { it != null && it.white == white && it.kind == PieceKind.KING }
// A position with no king cannot be in check. Only reachable from a
// hand-written FEN, but it must not throw.
return king >= 0 && isAttacked(board, king, byWhite = !white)
}
// -----------------------------------------------------------------------
// Move generation
// -----------------------------------------------------------------------
override fun legalMoves(state: ChessState, seat: Int): List<ChessMove> {
if (currentSeat(state) != seat) return emptyList()
return legalMoves(state)
}
/** Every move the side to move may actually play. */
fun legalMoves(state: ChessState): List<ChessMove> {
if (state.phase != ChessPhase.PLAYING) return emptyList()
val white = state.whiteToMove
return pseudoMoves(state).filter { move ->
!inCheck(make(state, move).board, white)
}
}
/** Moves that obey the way pieces travel, but may leave the king in check. */
private fun pseudoMoves(state: ChessState): List<ChessMove> {
val moves = mutableListOf<ChessMove>()
val white = state.whiteToMove
for (square in 0 until 64) {
val piece = state.board[square] ?: continue
if (piece.white != white) continue
when (piece.kind) {
PieceKind.PAWN -> pawnMoves(state, square, white, moves)
PieceKind.KNIGHT -> stepMoves(state, square, white, KNIGHT_STEPS, moves)
PieceKind.KING -> {
stepMoves(state, square, white, KING_STEPS, moves)
castlingMoves(state, square, white, moves)
}
PieceKind.BISHOP -> slideMoves(state, square, white, BISHOP_DIRS, moves)
PieceKind.ROOK -> slideMoves(state, square, white, ROOK_DIRS, moves)
PieceKind.QUEEN ->
slideMoves(state, square, white, ROOK_DIRS + BISHOP_DIRS, moves)
}
}
return moves
}
private val PROMOTIONS =
listOf(PieceKind.QUEEN, PieceKind.ROOK, PieceKind.BISHOP, PieceKind.KNIGHT)
private fun pawnMoves(
state: ChessState,
from: Int,
white: Boolean,
moves: MutableList<ChessMove>,
) {
val board = state.board
val file = fileOf(from)
val rank = rankOf(from)
val forward = if (white) 1 else -1
val startRank = if (white) 1 else 6
val lastRank = if (white) 7 else 0
fun addPawnMove(to: Int) {
if (rankOf(to) == lastRank) {
PROMOTIONS.forEach { moves += ChessMove(from, to, it) }
} else {
moves += ChessMove(from, to)
}
}
val ahead = rank + forward
if (ahead in 0..7 && board[squareOf(file, ahead)] == null) {
addPawnMove(squareOf(file, ahead))
val twoAhead = rank + 2 * forward
if (rank == startRank && board[squareOf(file, twoAhead)] == null) {
moves += ChessMove(from, squareOf(file, twoAhead))
}
}
if (ahead !in 0..7) return
for (side in intArrayOf(-1, 1)) {
val captureFile = file + side
if (captureFile !in 0..7) continue
val to = squareOf(captureFile, ahead)
val occupant = board[to]
when {
occupant != null && occupant.white != white -> addPawnMove(to)
// The square behind a pawn that has just stepped two.
occupant == null && to == state.enPassant -> moves += ChessMove(from, to)
}
}
}
private fun stepMoves(
state: ChessState,
from: Int,
white: Boolean,
steps: List<Pair<Int, Int>>,
moves: MutableList<ChessMove>,
) {
val file = fileOf(from)
val rank = rankOf(from)
for ((df, dr) in steps) {
val f = file + df
val r = rank + dr
if (f !in 0..7 || r !in 0..7) continue
val to = squareOf(f, r)
val occupant = state.board[to]
if (occupant == null || occupant.white != white) moves += ChessMove(from, to)
}
}
private fun slideMoves(
state: ChessState,
from: Int,
white: Boolean,
directions: List<Pair<Int, Int>>,
moves: MutableList<ChessMove>,
) {
val file = fileOf(from)
val rank = rankOf(from)
for ((df, dr) in directions) {
var f = file + df
var r = rank + dr
while (f in 0..7 && r in 0..7) {
val to = squareOf(f, r)
val occupant = state.board[to]
if (occupant == null) {
moves += ChessMove(from, to)
} else {
if (occupant.white != white) moves += ChessMove(from, to)
break
}
f += df
r += dr
}
}
}
private fun castlingMoves(
state: ChessState,
from: Int,
white: Boolean,
moves: MutableList<ChessMove>,
) {
val homeRank = if (white) 0 else 7
// A hand-written FEN can claim rights for a king that has moved, so the
// king's own square is checked rather than trusted.
if (from != squareOf(4, homeRank)) return
// Castling out of check is never allowed.
if (isAttacked(state.board, from, byWhite = !white)) return
fun clear(vararg files: Int) = files.all { state.board[squareOf(it, homeRank)] == null }
fun safe(vararg files: Int) = files.all {
!isAttacked(state.board, squareOf(it, homeRank), byWhite = !white)
}
fun rookAt(file: Int): Boolean {
val piece = state.board[squareOf(file, homeRank)]
return piece != null && piece.white == white && piece.kind == PieceKind.ROOK
}
if (state.castling.kingSide(white) && rookAt(7) && clear(5, 6) && safe(5, 6)) {
moves += ChessMove(from, squareOf(6, homeRank))
}
// The b-file square must be empty but may be attacked — the king does
// not pass over it.
if (state.castling.queenSide(white) && rookAt(0) && clear(1, 2, 3) && safe(2, 3)) {
moves += ChessMove(from, squareOf(2, homeRank))
}
}
// -----------------------------------------------------------------------
// Making a move
// -----------------------------------------------------------------------
/** The position a move produces, before any terminal test. */
private data class Made(
val board: List<Piece?>,
val castling: CastlingRights,
val enPassant: Int?,
val halfmoveClock: Int,
val captured: Piece?,
val castled: Boolean,
)
private fun make(state: ChessState, move: ChessMove): Made {
val board = state.board.toMutableList()
val piece = requireNotNull(board[move.from]) { "No piece on ${squareName(move.from)}" }
var captured = board[move.to]
// Taking en passant removes a pawn that is not on the target square.
if (piece.kind == PieceKind.PAWN && move.to == state.enPassant && captured == null) {
val victim = squareOf(fileOf(move.to), rankOf(move.from))
captured = board[victim]
board[victim] = null
}
board[move.from] = null
board[move.to] = move.promotion?.let { Piece(it, piece.white) } ?: piece
val castled = piece.kind == PieceKind.KING &&
abs(fileOf(move.to) - fileOf(move.from)) == 2
if (castled) {
val homeRank = rankOf(move.from)
val rookFrom = squareOf(if (fileOf(move.to) == 6) 7 else 0, homeRank)
val rookTo = squareOf(if (fileOf(move.to) == 6) 5 else 3, homeRank)
board[rookTo] = board[rookFrom]
board[rookFrom] = null
}
val doubleStep = piece.kind == PieceKind.PAWN &&
abs(rankOf(move.to) - rankOf(move.from)) == 2
val enPassant = if (doubleStep) {
squareOf(fileOf(move.from), (rankOf(move.from) + rankOf(move.to)) / 2)
} else {
null
}
var castling = state.castling
if (piece.kind == PieceKind.KING) {
castling = if (piece.white) {
castling.copy(whiteKingSide = false, whiteQueenSide = false)
} else {
castling.copy(blackKingSide = false, blackQueenSide = false)
}
}
// A rook that moves loses its right; so does one that is captured on
// its own corner, which is the case that is easy to forget.
castling = clearCornerRight(castling, move.from)
castling = clearCornerRight(castling, move.to)
val resetsClock = piece.kind == PieceKind.PAWN || captured != null
return Made(
board = board,
castling = castling,
enPassant = enPassant,
halfmoveClock = if (resetsClock) 0 else state.halfmoveClock + 1,
captured = captured,
castled = castled,
)
}
private fun clearCornerRight(rights: CastlingRights, square: Int): CastlingRights =
when (square) {
squareOf(0, 0) -> rights.copy(whiteQueenSide = false)
squareOf(7, 0) -> rights.copy(whiteKingSide = false)
squareOf(0, 7) -> rights.copy(blackQueenSide = false)
squareOf(7, 7) -> rights.copy(blackKingSide = false)
else -> rights
}
override fun applyMove(state: ChessState, seat: Int, move: ChessMove): ChessState {
require(currentSeat(state) == seat) { "Not seat $seat's turn" }
require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" }
val notation = baseNotation(state, move)
val made = make(state, move)
val key = positionKey(made.board, !state.whiteToMove, made.castling, made.enPassant)
val next = state.copy(
board = made.board,
whiteToMove = !state.whiteToMove,
castling = made.castling,
enPassant = made.enPassant,
halfmoveClock = made.halfmoveClock,
fullmoveNumber = state.fullmoveNumber + if (state.whiteToMove) 0 else 1,
lastMove = move,
repetitionKeys = state.repetitionKeys + key,
).withTerminalCheck()
val suffix = when {
next.outcome == ChessOutcome.WHITE_WINS || next.outcome == ChessOutcome.BLACK_WINS ->
"#"
inCheck(next) -> "+"
else -> ""
}
return next.copy(moveLog = state.moveLog + (notation + suffix))
}
/** Applies the terminal tests to a position whose side to move is set. */
private fun ChessState.withTerminalCheck(): ChessState {
if (legalMoves(this).isEmpty()) {
val outcome = when {
// Whoever is to move has been mated, so the other side wins.
inCheck(this) -> if (whiteToMove) {
ChessOutcome.BLACK_WINS
} else {
ChessOutcome.WHITE_WINS
}
else -> ChessOutcome.STALEMATE
}
return copy(phase = ChessPhase.GAME_OVER, outcome = outcome)
}
if (insufficientMaterial(board)) {
return copy(phase = ChessPhase.GAME_OVER, outcome = ChessOutcome.INSUFFICIENT_MATERIAL)
}
if (options.fiftyMoveRule && halfmoveClock >= 100) {
return copy(phase = ChessPhase.GAME_OVER, outcome = ChessOutcome.FIFTY_MOVE)
}
if (options.threefoldRepetition &&
repetitionKeys.count { it == repetitionKeys.last() } >= 3
) {
return copy(phase = ChessPhase.GAME_OVER, outcome = ChessOutcome.REPETITION)
}
return this
}
/** Positions from which no sequence of legal moves can produce mate. */
fun insufficientMaterial(board: List<Piece?>): Boolean {
val pieces = board.withIndex().mapNotNull { (square, piece) ->
piece?.takeIf { it.kind != PieceKind.KING }?.let { square to it }
}
if (pieces.any {
it.second.kind == PieceKind.PAWN ||
it.second.kind == PieceKind.ROOK ||
it.second.kind == PieceKind.QUEEN
}
) {
return false
}
return when (pieces.size) {
// Bare kings, or a king and one minor piece.
0, 1 -> true
// Two bishops on the same colour square can never mate, whichever
// sides they belong to.
2 -> pieces.all { it.second.kind == PieceKind.BISHOP } &&
isLightSquare(pieces[0].first) == isLightSquare(pieces[1].first)
else -> false
}
}
// -----------------------------------------------------------------------
// Notation
// -----------------------------------------------------------------------
/** Algebraic notation without the check or mate suffix, which needs the result. */
private fun baseNotation(state: ChessState, move: ChessMove): String {
val piece = state.board[move.from] ?: return move.toString()
if (piece.kind == PieceKind.KING && abs(fileOf(move.to) - fileOf(move.from)) == 2) {
return if (fileOf(move.to) == 6) "O-O" else "O-O-O"
}
val captures = state.board[move.to] != null ||
(piece.kind == PieceKind.PAWN && move.to == state.enPassant)
return buildString {
if (piece.kind == PieceKind.PAWN) {
if (captures) append('a' + fileOf(move.from))
} else {
append(piece.kind.letter)
append(disambiguation(state, move, piece.kind))
}
if (captures) append('x')
append(squareName(move.to))
move.promotion?.let { append('=').append(it.letter) }
}
}
/**
* The least that distinguishes this move from another of the same kind to
* the same square — the file if that is enough, otherwise the rank, and
* both when two pieces share a file and a rank with a third.
*/
private fun disambiguation(state: ChessState, move: ChessMove, kind: PieceKind): String {
val rivals = legalMoves(state).filter { other ->
other.to == move.to &&
other.from != move.from &&
state.board[other.from]?.kind == kind
}
if (rivals.isEmpty()) return ""
val fileIsUnique = rivals.none { fileOf(it.from) == fileOf(move.from) }
if (fileIsUnique) return ('a' + fileOf(move.from)).toString()
val rankIsUnique = rivals.none { rankOf(it.from) == rankOf(move.from) }
if (rankIsUnique) return (rankOf(move.from) + 1).toString()
return squareName(move.from)
}
// -----------------------------------------------------------------------
// Engine contract
// -----------------------------------------------------------------------
override fun isFinished(state: ChessState): Boolean = state.phase == ChessPhase.GAME_OVER
override fun summary(state: ChessState): String = state.outcome?.label
?: if (state.whiteToMove) "White to move" else "Black to move"
/** Chess hides nothing: both players see the same board. */
override fun viewFor(state: ChessState, seat: Int): ChessState = state
override fun encodeState(state: ChessState): String = json.encodeToString(state)
override fun decodeState(json: String): ChessState = this.json.decodeFromString(json)
override fun encodeMove(move: ChessMove): String = json.encodeToString(move)
override fun decodeMove(json: String): ChessMove = this.json.decodeFromString(json)
/**
* Counts the leaf nodes at [depth]. Only used by the tests, but it lives
* here because it is the single most valuable thing that can be said about
* a move generator: the published counts for a given position are exact, so
* a generator that matches them at depth four has essentially no room left
* for a rule it gets wrong.
*/
fun perft(state: ChessState, depth: Int): Long {
if (depth == 0) return 1L
val moves = legalMoves(state)
if (depth == 1) return moves.size.toLong()
return moves.sumOf { perft(advanced(state, it), depth - 1) }
}
/**
* The position after [move] with none of the terminal tests applied.
*
* The search walks millions of positions it will never keep, and running
* the repetition and fifty-move checks on every one of them would cost more
* than the search itself. Anything that ends up on the board goes through
* [applyMove] instead.
*/
fun advanced(state: ChessState, move: ChessMove): ChessState {
val made = make(state, move)
return state.copy(
board = made.board,
whiteToMove = !state.whiteToMove,
castling = made.castling,
enPassant = made.enPassant,
halfmoveClock = made.halfmoveClock,
lastMove = move,
)
}
/** Whether [move] takes something — including en passant, which takes nothing on its square. */
fun isCapture(state: ChessState, move: ChessMove): Boolean =
state.board[move.to] != null ||
(state.board[move.from]?.kind == PieceKind.PAWN && move.to == state.enPassant)
}
@@ -0,0 +1,52 @@
package org.prolibertate.games.game.crazyeights
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.engine.GameAi
/**
* A heuristic Crazy 8s opponent.
*
* It sheds expensive cards first, keeps its eights back for when it is stuck,
* and when it does play one it names the suit it holds most of.
*/
class CrazyEightsAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
GameAi<CrazyEightsState, CrazyEightsMove> {
enum class Difficulty(
/** Play an eight anyway once the hand is down to this size. */
val eightEndgame: Int,
) {
EASY(eightEndgame = 99),
NORMAL(eightEndgame = 3),
CAUTIOUS(eightEndgame = 2),
}
override fun chooseMove(
state: CrazyEightsState,
seat: Int,
legal: List<CrazyEightsMove>,
): CrazyEightsMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
val plays = legal.filterIsInstance<PlayCard>()
if (plays.isEmpty()) return legal.first()
val hand = state.hands[seat]
val ordinary = plays.filter { !isWild(it.card) }
// An eight is the only card that always plays, so it is worth more held
// than spent — unless the hand is nearly gone, or nothing else works.
val useEight = ordinary.isEmpty() || hand.size <= difficulty.eightEndgame
val pool = if (useEight) plays else ordinary
val best = pool
.filter { !isWild(it.card) }
.maxByOrNull { crazyEightsPenalty(it.card) }
if (best != null) return best
// Playing an eight: switch the game to whatever we hold most of.
val strongest = Suit.entries.maxByOrNull { suit -> hand.count { it.suit == suit } }
?: Suit.SPADES
return pool.firstOrNull { it.nominatedSuit == strongest } ?: pool.first()
}
}
@@ -0,0 +1,90 @@
package org.prolibertate.games.game.crazyeights
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
@Serializable
data class CrazyEightsOptions(
val playerCount: Int = 4,
/** Zero means the usual count: seven for two players, five otherwise. */
val startingHand: Int = 0,
/** Keep drawing until something is playable, rather than drawing one and passing. */
val drawUntilPlayable: Boolean = true,
val roundsToPlay: Int = 3,
) {
init {
require(playerCount in 2..6) { "Crazy 8s seats two to six" }
require(roundsToPlay >= 1) { "roundsToPlay must be positive" }
}
fun handSize(): Int = if (startingHand > 0) startingHand else if (playerCount == 2) 7 else 5
}
/** Eights are wild and expensive; pictures cost ten; everything else is face value. */
fun crazyEightsPenalty(card: Card): Int = when (card.rank) {
Rank.EIGHT -> 50
Rank.KING, Rank.QUEEN, Rank.JACK, Rank.TEN -> 10
Rank.ACE -> 1
else -> card.rank.order
}
fun isWild(card: Card): Boolean = card.rank == Rank.EIGHT
@Serializable
enum class CrazyEightsPhase { PLAYING, ROUND_OVER, GAME_OVER }
@Serializable
data class CrazyEightsState(
val options: CrazyEightsOptions,
val seed: Long,
val roundNumber: Int,
val hands: List<List<Card>>,
val handCounts: List<Int>,
val stock: List<Card>,
val discard: List<Card>,
/**
* The suit that must be followed. Usually the top card's suit, but an eight
* is wild and its player names this instead — so it cannot be read off the
* discard pile.
*/
val suitInForce: Suit,
val turn: Int,
/** Cards drawn so far this turn, which limits how much more may be drawn. */
val drawnThisTurn: Int,
/** Passes in a row. Reaching the player count means the round is blocked. */
val consecutivePasses: Int = 0,
val scores: List<Int>,
val roundWinner: Int?,
val phase: CrazyEightsPhase,
val log: List<String>,
) {
val topCard: Card? get() = discard.lastOrNull()
}
@Serializable
sealed interface CrazyEightsMove
/** [nominatedSuit] is set only when playing an eight. */
@Serializable
@SerialName("play")
data class PlayCard(val card: Card, val nominatedSuit: Suit? = null) : CrazyEightsMove
@Serializable
@SerialName("draw")
data object DrawCard : CrazyEightsMove
@Serializable
@SerialName("pass")
data object PassTurn : CrazyEightsMove
/** Whether [card] may be played on a pile showing [top] with [suitInForce]. */
fun canPlay(card: Card, top: Card?, suitInForce: Suit): Boolean = when {
isWild(card) -> true
top == null -> true
card.suit == suitInForce -> true
card.rank == top.rank -> true
else -> false
}
@@ -0,0 +1,253 @@
package org.prolibertate.games.game.crazyeights
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Decks
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.cards.shuffledWith
import org.prolibertate.games.game.engine.GameRules
import org.prolibertate.games.game.engine.TableConfig
import kotlin.random.Random
/**
* Crazy 8s as a pure state machine. See RULES-crazy8s.md.
*/
object CrazyEightsRules : GameRules<CrazyEightsState, CrazyEightsMove> {
override val gameId: String = GameCatalog.CRAZY_EIGHTS
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
override fun initialState(config: TableConfig): CrazyEightsState {
val options = json.decodeFromString<CrazyEightsOptions>(config.optionsJson)
require(config.seats.size == options.playerCount) {
"Seat count ${config.seats.size} does not match ${options.playerCount} players"
}
return deal(options, config.seed, 0, List(options.playerCount) { 0 }, listOf("Round 1."))
}
private fun deal(
options: CrazyEightsOptions,
seed: Long,
roundNumber: Int,
scores: List<Int>,
log: List<String>,
): CrazyEightsState {
val random = Random(seed + roundNumber * 7919L)
var deck = Decks.standard52().shuffledWith(random)
val hands = mutableListOf<List<Card>>()
repeat(options.playerCount) {
hands += deck.take(options.handSize())
deck = deck.drop(options.handSize())
}
// An eight on top at the start would leave nobody having named a suit,
// so the first upcard is taken from further down if need be.
val firstIndex = deck.indexOfFirst { !isWild(it) }.takeIf { it >= 0 } ?: 0
val upCard = deck[firstIndex]
val stock = deck.filterIndexed { index, _ -> index != firstIndex }
return CrazyEightsState(
options = options,
seed = seed,
roundNumber = roundNumber,
hands = hands,
handCounts = hands.map { it.size },
stock = stock,
discard = listOf(upCard),
suitInForce = upCard.suit,
turn = roundNumber % options.playerCount,
drawnThisTurn = 0,
consecutivePasses = 0,
scores = scores,
roundWinner = null,
phase = CrazyEightsPhase.PLAYING,
log = log,
)
}
/** Advances from [CrazyEightsPhase.ROUND_OVER] into the next deal. */
fun nextRound(state: CrazyEightsState): CrazyEightsState {
check(state.phase == CrazyEightsPhase.ROUND_OVER) { "Round is not over" }
return deal(
options = state.options,
seed = state.seed,
roundNumber = state.roundNumber + 1,
scores = state.scores,
log = state.log + "Round ${state.roundNumber + 2}.",
)
}
override fun currentSeat(state: CrazyEightsState): Int? =
if (state.phase == CrazyEightsPhase.PLAYING) state.turn else null
override fun legalMoves(state: CrazyEightsState, seat: Int): List<CrazyEightsMove> {
if (currentSeat(state) != seat) return emptyList()
val hand = state.hands[seat]
val playable = hand.filter { canPlay(it, state.topCard, state.suitInForce) }
if (playable.isNotEmpty()) {
return playable.flatMap { card ->
if (isWild(card)) {
// Playing an eight is really four different moves: the card
// plus the suit it turns the game to.
Suit.entries.map { PlayCard(card, it) }
} else {
listOf(PlayCard(card))
}
}
}
// Nothing to play. There is something to draw as long as the stock has
// a card or the discards can be recycled into one — the pile in play
// does not count, since it stays put.
val somethingToDraw = state.stock.isNotEmpty() || state.discard.size > 1
val mayDraw = somethingToDraw &&
(state.options.drawUntilPlayable || state.drawnThisTurn == 0)
return if (mayDraw) listOf(DrawCard) else listOf(PassTurn)
}
override fun applyMove(
state: CrazyEightsState,
seat: Int,
move: CrazyEightsMove,
): CrazyEightsState {
require(currentSeat(state) == seat) { "Not seat $seat's turn" }
require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" }
return when (move) {
is PlayCard -> applyPlay(state, seat, move)
is DrawCard -> applyDraw(state, seat)
is PassTurn -> applyPass(state, seat)
}
}
private fun applyPlay(
state: CrazyEightsState,
seat: Int,
move: PlayCard,
): CrazyEightsState {
val hands = state.hands.toMutableList()
hands[seat] = hands[seat] - move.card
val suit = if (isWild(move.card)) {
requireNotNull(move.nominatedSuit) { "An eight must name a suit" }
} else {
move.card.suit
}
val played = state.copy(
hands = hands,
handCounts = hands.map { it.size },
discard = state.discard + move.card,
suitInForce = suit,
drawnThisTurn = 0,
consecutivePasses = 0,
log = state.log + buildString {
append("Seat $seat plays ${move.card.label}")
if (isWild(move.card)) append(" and calls ${suit.symbol}")
append(".")
},
)
if (hands[seat].isEmpty()) return scoreRound(played, seat)
return advance(played, seat)
}
private fun applyDraw(state: CrazyEightsState, seat: Int): CrazyEightsState {
var stock = state.stock
var discard = state.discard
if (stock.isEmpty()) {
// Recycle everything but the card in play.
if (discard.size <= 1) return advance(state, seat)
val top = discard.last()
stock = discard.dropLast(1).shuffledWith(Random(state.seed + state.log.size))
discard = listOf(top)
}
val hands = state.hands.toMutableList()
hands[seat] = hands[seat] + stock.first()
return state.copy(
hands = hands,
handCounts = hands.map { it.size },
stock = stock.drop(1),
discard = discard,
drawnThisTurn = state.drawnThisTurn + 1,
consecutivePasses = 0,
log = state.log + "Seat $seat draws.",
)
}
/**
* Everyone passing in succession means nobody can move and the deck is
* spent. The round is blocked: it ends there and the smallest hand takes it.
*/
private fun applyPass(state: CrazyEightsState, seat: Int): CrazyEightsState {
val passes = state.consecutivePasses + 1
val passed = state.copy(
consecutivePasses = passes,
log = state.log + "Seat $seat passes.",
)
if (passes >= state.options.playerCount) {
val smallest = passed.hands.indices
.minByOrNull { passed.hands[it].sumOf { card -> crazyEightsPenalty(card) } }
return scoreRound(
passed.copy(log = passed.log + "Nobody can move — the round is blocked."),
smallest,
)
}
return advance(passed, seat)
}
private fun advance(state: CrazyEightsState, seat: Int): CrazyEightsState = state.copy(
turn = (seat + 1) % state.options.playerCount,
drawnThisTurn = 0,
)
/**
* The player who went out scores nothing; everyone else is charged for what
* they are still holding.
*/
private fun scoreRound(state: CrazyEightsState, winner: Int?): CrazyEightsState {
val scores = state.scores.mapIndexed { seat, total ->
total + state.hands[seat].sumOf { crazyEightsPenalty(it) }
}
val lastRound = state.roundNumber + 1 >= state.options.roundsToPlay
return state.copy(
scores = scores,
roundWinner = winner,
phase = if (lastRound) CrazyEightsPhase.GAME_OVER else CrazyEightsPhase.ROUND_OVER,
log = state.log + if (winner != null) "Seat $winner goes out." else "Round ends.",
)
}
override fun isFinished(state: CrazyEightsState): Boolean =
state.phase == CrazyEightsPhase.GAME_OVER
override fun summary(state: CrazyEightsState): String = when (state.phase) {
CrazyEightsPhase.GAME_OVER -> {
val best = state.scores.indices.minByOrNull { state.scores[it] } ?: 0
"Seat $best wins on ${state.scores[best]}"
}
else -> state.scores.mapIndexed { seat, s -> "S$seat:$s" }.joinToString(" ")
}
override fun viewFor(state: CrazyEightsState, seat: Int): CrazyEightsState = state.copy(
hands = state.hands.mapIndexed { index, hand -> if (index == seat) hand else emptyList() },
handCounts = state.handCounts,
stock = emptyList(),
)
override fun encodeState(state: CrazyEightsState): String = json.encodeToString(state)
override fun decodeState(json: String): CrazyEightsState = this.json.decodeFromString(json)
override fun encodeMove(move: CrazyEightsMove): String = json.encodeToString(move)
override fun decodeMove(json: String): CrazyEightsMove = this.json.decodeFromString(json)
}
@@ -0,0 +1,117 @@
package org.prolibertate.games.game.kaiser
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.engine.GameAi
/**
* A heuristic Kaiser opponent.
*
* It rates a hand by its best suit, bids only when it can see the points, and
* in play chases the five of hearts while trying not to be the one holding the
* trick the three of spades lands in.
*/
class KaiserAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
GameAi<KaiserState, KaiserMove> {
enum class Difficulty(
/** Hand strength needed before bidding above the floor. */
val bidThreshold: Int,
) {
EASY(bidThreshold = 12),
NORMAL(bidThreshold = 16),
CAUTIOUS(bidThreshold = 19),
}
override fun chooseMove(state: KaiserState, seat: Int, legal: List<KaiserMove>): KaiserMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
return when (state.phase) {
KaiserPhase.BIDDING -> chooseBid(state, seat, legal)
KaiserPhase.PLAYING -> choosePlay(state, seat, legal)
else -> legal.first()
}
}
/** Trump length and top cards, plus a nod to holding the counters. */
private fun suitValue(hand: List<Card>, trump: Suit): Int {
var value = hand.count { it.suit == trump } * 3
for (card in hand) {
value += when {
card.suit == trump && card.rank == Rank.ACE -> 4
card.suit == trump && card.rank == Rank.KING -> 3
card == FIVE_OF_HEARTS -> 3
card.rank == Rank.ACE -> 2
else -> 0
}
}
// Being long in spades makes the three easier to bury.
if (hand.contains(THREE_OF_SPADES)) value -= 2
return value
}
private fun chooseBid(state: KaiserState, seat: Int, legal: List<KaiserMove>): KaiserMove {
val naming = legal.filterIsInstance<NameTrump>()
if (naming.isNotEmpty()) {
val hand = state.hands[seat]
val best = naming
.filter { it.suit != null }
.maxByOrNull { suitValue(hand, it.suit!!) }
return best ?: naming.first()
}
val hand = state.hands[seat]
val bestSuit = Suit.entries.maxByOrNull { suitValue(hand, it) } ?: Suit.SPADES
val strength = suitValue(hand, bestSuit)
val bids = legal.filterIsInstance<MakeBid>().filter { !it.bid.noTrump }
if (strength >= difficulty.bidThreshold && bids.isNotEmpty()) {
// Bid the floor rather than reaching; the contract is what matters.
return bids.minBy { it.bid.points }
}
return legal.firstOrNull { it is PassBid } ?: bids.minByOrNull { it.bid.points } ?: legal.first()
}
private fun choosePlay(state: KaiserState, seat: Int, legal: List<KaiserMove>): KaiserMove {
val cards = legal.filterIsInstance<PlayCard>().map { it.card }
if (cards.size == 1) return PlayCard(cards.first())
if (state.trick.isEmpty()) {
// Leading: pull with the top trump, else lead an ace.
val trump = state.trump
val topTrump = cards.filter { trump != null && it.suit == trump }
.maxByOrNull { it.rank.order }
if (topTrump != null && topTrump.rank.order >= Rank.KING.order) return PlayCard(topTrump)
val ace = cards.filter { it.rank == Rank.ACE }.maxByOrNull { it.rank.order }
if (ace != null) return PlayCard(ace)
return PlayCard(cards.minBy { it.rank.order })
}
val ledSuit = state.trick.first().card.suit
val best = state.trick.maxBy { trickStrength(it.card, state.trump, ledSuit) }
val partnerWinning = partnerOf(best.seat) == seat
val bestStrength = trickStrength(best.card, state.trump, ledSuit)
val winners = cards.filter { trickStrength(it, state.trump, ledSuit) > bestStrength }
// The five of hearts is worth more than the trick it rides in on.
val fiveOnTable = state.trick.any { it.card == FIVE_OF_HEARTS }
val threeOnTable = state.trick.any { it.card == THREE_OF_SPADES }
return when {
// Partner has it and the trick is worth taking: let them have it,
// unless the three of spades is in there.
partnerWinning && !threeOnTable -> PlayCard(cards.minBy { it.rank.order })
fiveOnTable && winners.isNotEmpty() ->
PlayCard(winners.maxBy { trickStrength(it, state.trump, ledSuit) })
// Nobody wants the three; duck if the trick is already lost.
threeOnTable && winners.isEmpty() -> PlayCard(cards.minBy { it.rank.order })
winners.isNotEmpty() && !threeOnTable ->
PlayCard(winners.minBy { trickStrength(it, state.trump, ledSuit) })
else -> PlayCard(cards.minBy { it.rank.order })
}
}
}
@@ -0,0 +1,127 @@
package org.prolibertate.games.game.kaiser
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
@Serializable
data class KaiserOptions(
/** Lowest opening bid. Five to eight, depending on the table. */
val minimumBid: Int = 6,
/** A no-trump bid outranks the same number bid with a trump suit. */
val allowNoTrump: Boolean = true,
val pointsToWin: Int = 52,
/**
* Going out the bottom loses. Without a floor a table that keeps bidding
* and going down never reaches a result at all.
*/
val losingScore: Int = -52,
) {
init {
require(minimumBid in 5..8) { "Kaiser opens between five and eight" }
require(pointsToWin > 0) { "pointsToWin must be positive" }
require(losingScore < 0) { "losingScore must be negative" }
}
}
/**
* The most a hand can be worth: eight tricks, plus five for the five of hearts,
* less three for the three of spades. Bidding above it is bidding to fail, so
* nothing above it is offered.
*/
const val MAX_BID = 10
/** The five of hearts, worth five to whoever takes the trick it falls in. */
val FIVE_OF_HEARTS: Card = Card(Rank.FIVE, Suit.HEARTS)
/** The three of spades, which costs three. */
val THREE_OF_SPADES: Card = Card(Rank.THREE, Suit.SPADES)
/**
* Points in a trick: one for the trick itself, plus or minus whatever the two
* counters are worth. Eight tricks and the two counters make ten points a hand.
*/
fun trickValue(cards: List<Card>): Int {
var value = 1
if (cards.contains(FIVE_OF_HEARTS)) value += 5
if (cards.contains(THREE_OF_SPADES)) value -= 3
return value
}
fun partnerOf(seat: Int): Int = (seat + 2) % 4
fun teamOf(seat: Int): Int = seat % 2
@Serializable
enum class KaiserPhase { BIDDING, PLAYING, HAND_OVER, GAME_OVER }
@Serializable
data class PlayedCard(val seat: Int, val card: Card)
/** A bid of [points] tricks-worth, optionally in no trump. */
@Serializable
data class Bid(val points: Int, val noTrump: Boolean) {
/** No trump beats the same number with a suit. */
fun beats(other: Bid?): Boolean = when {
other == null -> true
points != other.points -> points > other.points
else -> noTrump && !other.noTrump
}
}
@Serializable
data class KaiserState(
val options: KaiserOptions,
val seed: Long,
val handNumber: Int,
val dealer: Int,
val phase: KaiserPhase,
val hands: List<List<Card>>,
val handCounts: List<Int>,
val turn: Int,
val passes: Int,
val highBid: Bid?,
val highBidder: Int?,
val trump: Suit?,
val trick: List<PlayedCard>,
/** Held on the table so a finished trick can be read before it is swept. */
val completedTrick: List<PlayedCard> = emptyList(),
val leader: Int,
val lastTrickWinner: Int?,
/** Points taken this hand, per team. */
val handPoints: List<Int>,
val scores: List<Int>,
val log: List<String>,
)
@Serializable
sealed interface KaiserMove
@Serializable
@SerialName("bid")
data class MakeBid(val bid: Bid) : KaiserMove
@Serializable
@SerialName("pass")
data object PassBid : KaiserMove
/** Only the winning bidder names trump, and only after the bidding. */
@Serializable
@SerialName("name_trump")
data class NameTrump(val suit: Suit?) : KaiserMove
@Serializable
@SerialName("play")
data class PlayCard(val card: Card) : KaiserMove
/**
* Trick ranking. Trump beats everything else; otherwise only the led suit can
* win. There are no bowers — Kaiser ranks straight down from the ace.
*/
fun trickStrength(card: Card, trump: Suit?, ledSuit: Suit): Int = when {
trump != null && card.suit == trump -> 1000 + card.rank.order
card.suit == ledSuit -> 100 + card.rank.order
else -> 0
}
@@ -0,0 +1,304 @@
package org.prolibertate.games.game.kaiser
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Decks
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.cards.shuffledWith
import org.prolibertate.games.game.engine.GameRules
import org.prolibertate.games.game.engine.TableConfig
import kotlin.random.Random
/**
* Kaiser as a pure state machine. See RULES-kaiser.md.
*/
object KaiserRules : GameRules<KaiserState, KaiserMove> {
override val gameId: String = GameCatalog.KAISER
private const val CARDS_PER_HAND = 8
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
override fun initialState(config: TableConfig): KaiserState {
require(config.seats.size == 4) { "Kaiser is a four-handed game" }
val options = json.decodeFromString<KaiserOptions>(config.optionsJson)
return deal(options, config.seed, 0, 0, listOf(0, 0), listOf("Hand 1."))
}
private fun deal(
options: KaiserOptions,
seed: Long,
handNumber: Int,
dealer: Int,
scores: List<Int>,
log: List<String>,
): KaiserState {
val random = Random(seed + handNumber * 7919L)
val deck = Decks.kaiser().shuffledWith(random)
val hands = (0 until 4).map { seat ->
deck.subList(seat * CARDS_PER_HAND, (seat + 1) * CARDS_PER_HAND).toList()
}
return KaiserState(
options = options,
seed = seed,
handNumber = handNumber,
dealer = dealer,
phase = KaiserPhase.BIDDING,
hands = hands,
handCounts = hands.map { it.size },
turn = (dealer + 1) % 4,
passes = 0,
highBid = null,
highBidder = null,
trump = null,
trick = emptyList(),
completedTrick = emptyList(),
leader = (dealer + 1) % 4,
lastTrickWinner = null,
handPoints = listOf(0, 0),
scores = scores,
log = log,
)
}
/** Advances from [KaiserPhase.HAND_OVER] into the next deal. */
fun nextHand(state: KaiserState): KaiserState {
check(state.phase == KaiserPhase.HAND_OVER) { "Hand is not over" }
val dealer = (state.dealer + 1) % 4
return deal(
options = state.options,
seed = state.seed,
handNumber = state.handNumber + 1,
dealer = dealer,
scores = state.scores,
log = state.log + "Hand ${state.handNumber + 2}.",
)
}
override fun currentSeat(state: KaiserState): Int? = when (state.phase) {
KaiserPhase.BIDDING, KaiserPhase.PLAYING -> state.turn
KaiserPhase.HAND_OVER, KaiserPhase.GAME_OVER -> null
}
// -----------------------------------------------------------------------
// Legal moves
// -----------------------------------------------------------------------
override fun legalMoves(state: KaiserState, seat: Int): List<KaiserMove> {
if (currentSeat(state) != seat) return emptyList()
return when (state.phase) {
KaiserPhase.BIDDING -> biddingMoves(state, seat)
KaiserPhase.PLAYING -> playableCards(state, seat).map { PlayCard(it) }
KaiserPhase.HAND_OVER, KaiserPhase.GAME_OVER -> emptyList()
}
}
private fun biddingMoves(state: KaiserState, seat: Int): List<KaiserMove> {
// Trump is named as a separate move once the bidding is settled.
if (state.highBidder == seat && state.trump == null && biddingClosed(state)) {
return buildList {
Suit.entries.forEach { add(NameTrump(it)) }
if (state.highBid?.noTrump == true) {
// A no-trump bid has already committed to none.
clear()
add(NameTrump(null))
}
}
}
val moves = mutableListOf<KaiserMove>()
val floor = state.options.minimumBid
val ceiling = MAX_BID
for (points in floor..ceiling) {
val plain = Bid(points, noTrump = false)
if (plain.beats(state.highBid)) moves += MakeBid(plain)
if (state.options.allowNoTrump) {
val noTrump = Bid(points, noTrump = true)
if (noTrump.beats(state.highBid)) moves += MakeBid(noTrump)
}
}
// The dealer is stuck with the minimum if the bidding would die out.
val dealerMustBid = seat == state.dealer && state.highBid == null && state.passes == 3
if (!dealerMustBid) moves += PassBid
return moves
}
private fun biddingClosed(state: KaiserState): Boolean =
state.highBid != null && state.passes >= 3
/** Follow the led suit if you hold it. */
fun playableCards(state: KaiserState, seat: Int): List<Card> {
val hand = state.hands[seat]
val led = state.trick.firstOrNull() ?: return hand
val following = hand.filter { it.suit == led.card.suit }
return following.ifEmpty { hand }
}
// -----------------------------------------------------------------------
// Move application
// -----------------------------------------------------------------------
override fun applyMove(state: KaiserState, seat: Int, move: KaiserMove): KaiserState {
require(currentSeat(state) == seat) { "Not seat $seat's turn" }
require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" }
return when (move) {
is MakeBid -> applyBid(state, seat, move.bid)
is PassBid -> applyPass(state, seat)
is NameTrump -> applyNameTrump(state, move.suit)
is PlayCard -> applyPlay(state, seat, move.card)
}.normalised()
}
private fun KaiserState.normalised(): KaiserState = copy(handCounts = hands.map { it.size })
private fun applyBid(state: KaiserState, seat: Int, bid: Bid): KaiserState {
val next = state.copy(
highBid = bid,
highBidder = seat,
// A fresh bid restarts the count of players willing to give way.
passes = 0,
turn = (seat + 1) % 4,
log = state.log + "Seat $seat bids ${bid.points}${if (bid.noTrump) " no trump" else ""}.",
)
return if (biddingClosed(next)) next.copy(turn = seat) else next
}
private fun applyPass(state: KaiserState, seat: Int): KaiserState {
val passes = state.passes + 1
val passed = state.copy(passes = passes, log = state.log + "Seat $seat passes.")
if (passes >= 4 && passed.highBid == null) {
// Nobody wanted it: throw the hand in.
return deal(
options = state.options,
seed = state.seed,
handNumber = state.handNumber + 1,
dealer = (state.dealer + 1) % 4,
scores = state.scores,
log = passed.log + "Hand thrown in; redealing.",
)
}
// Bidding is settled once everyone else has given way.
if (passed.highBid != null && passes >= 3) {
return passed.copy(turn = passed.highBidder!!)
}
return passed.copy(turn = (seat + 1) % 4)
}
private fun applyNameTrump(state: KaiserState, suit: Suit?): KaiserState {
val leader = state.highBidder!!
return state.copy(
phase = KaiserPhase.PLAYING,
trump = suit,
turn = leader,
leader = leader,
trick = emptyList(),
completedTrick = emptyList(),
log = state.log + if (suit == null) {
"No trump."
} else {
"${suit.symbol} are trump."
},
)
}
private fun applyPlay(state: KaiserState, seat: Int, card: Card): KaiserState {
val hands = state.hands.toMutableList()
hands[seat] = hands[seat] - card
val trick = state.trick + PlayedCard(seat, card)
val played = state.copy(hands = hands, trick = trick, completedTrick = emptyList())
if (trick.size < 4) return played.copy(turn = (seat + 1) % 4)
val ledSuit = trick.first().card.suit
val winner = trick.maxBy { trickStrength(it.card, state.trump, ledSuit) }.seat
val points = trickValue(trick.map { it.card })
val handPoints = played.handPoints.toMutableList()
handPoints[teamOf(winner)] = handPoints[teamOf(winner)] + points
val afterTrick = played.copy(
trick = emptyList(),
completedTrick = trick,
handPoints = handPoints,
leader = winner,
turn = winner,
lastTrickWinner = winner,
log = state.log + "Seat $winner takes the trick ($points).",
)
return if (afterTrick.hands.all { it.isEmpty() }) scoreHand(afterTrick) else afterTrick
}
// -----------------------------------------------------------------------
// Scoring
// -----------------------------------------------------------------------
private fun scoreHand(state: KaiserState): KaiserState {
val bidder = requireNotNull(state.highBidder)
val bid = requireNotNull(state.highBid)
val bidderTeam = teamOf(bidder)
val defenders = 1 - bidderTeam
val taken = state.handPoints[bidderTeam]
val scores = state.scores.toMutableList()
val note: String
if (taken >= bid.points) {
// No-trump contracts pay double, which is what makes them worth bidding.
val gained = if (bid.noTrump) taken * 2 else taken
scores[bidderTeam] = scores[bidderTeam] + gained
note = "Team $bidderTeam made ${bid.points} with $taken."
} else {
val lost = if (bid.noTrump) bid.points * 2 else bid.points
scores[bidderTeam] = scores[bidderTeam] - lost
note = "Team $bidderTeam went down ${bid.points}, taking only $taken."
}
// Defenders always keep what they took.
scores[defenders] = scores[defenders] + state.handPoints[defenders]
// A game ends either by climbing to the target or by falling through
// the floor, and the floor is what guarantees it ends at all.
val reachedTarget = scores.indexOfFirst { it >= state.options.pointsToWin }
val fellThrough = scores.indexOfFirst { it <= state.options.losingScore }
val over = reachedTarget >= 0 || fellThrough >= 0
val winner = when {
reachedTarget >= 0 -> reachedTarget
fellThrough >= 0 -> 1 - fellThrough
else -> -1
}
return state.copy(
phase = if (over) KaiserPhase.GAME_OVER else KaiserPhase.HAND_OVER,
scores = scores,
log = state.log + note + if (over) listOf("Team $winner wins.") else emptyList(),
)
}
override fun isFinished(state: KaiserState): Boolean = state.phase == KaiserPhase.GAME_OVER
override fun summary(state: KaiserState): String = when (state.phase) {
KaiserPhase.GAME_OVER -> {
val winner = state.scores.indices.maxByOrNull { state.scores[it] } ?: 0
"Team $winner wins ${state.scores[0]}–${state.scores[1]}"
}
else -> "We ${state.scores[0]} — They ${state.scores[1]}"
}
override fun viewFor(state: KaiserState, seat: Int): KaiserState = state.copy(
hands = state.hands.mapIndexed { index, hand -> if (index == seat) hand else emptyList() },
handCounts = state.handCounts,
)
override fun encodeState(state: KaiserState): String = json.encodeToString(state)
override fun decodeState(json: String): KaiserState = this.json.decodeFromString(json)
override fun encodeMove(move: KaiserMove): String = json.encodeToString(move)
override fun decodeMove(json: String): KaiserMove = this.json.decodeFromString(json)
}
@@ -0,0 +1,151 @@
package org.prolibertate.games.game.wizard
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.engine.GameAi
import kotlin.math.abs
import kotlin.math.roundToInt
/**
* A heuristic Wizard opponent.
*
* Bidding counts the tricks the hand can reasonably expect: a wizard is one,
* a jester is none, and everything else is worth a fraction depending on how
* high it is and whether it is trump. In play it chases tricks while it is
* short of its bid and ducks once it has them, which is the whole game.
*/
class WizardAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
GameAi<WizardState, WizardMove> {
enum class Difficulty(
/**
* Added to the expected-trick count before rounding. Optimists overbid
* and go down; pessimists underbid and get caught taking tricks they
* did not want.
*/
val bidBias: Double,
) {
EASY(bidBias = 0.35),
NORMAL(bidBias = 0.0),
CAUTIOUS(bidBias = -0.25),
}
override fun chooseMove(state: WizardState, seat: Int, legal: List<WizardMove>): WizardMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
return when (state.phase) {
WizardPhase.BIDDING -> chooseBid(state, seat, legal)
WizardPhase.PLAYING -> choosePlay(state, seat, legal)
else -> legal.first()
}
}
// -----------------------------------------------------------------------
// Bidding
// -----------------------------------------------------------------------
private fun chooseBid(state: WizardState, seat: Int, legal: List<WizardMove>): WizardMove {
val naming = legal.filterIsInstance<ChooseTrump>()
if (naming.isNotEmpty()) {
val hand = state.hands[seat]
val best = naming.maxByOrNull { suitStrength(hand, it.suit) }
return best ?: naming.first()
}
val bids = legal.filterIsInstance<MakeBid>()
if (bids.isEmpty()) return legal.first()
val wanted = (expectedTricks(state.hands[seat], state.trump) + difficulty.bidBias)
.roundToInt()
.coerceIn(0, state.cardsThisRound)
// Screw-the-dealer may have taken the number we wanted away, so settle
// for the nearest one still on offer.
return bids.minBy { abs(it.tricks - wanted) }
}
/** How much of a hand a given trump suit would be worth. */
private fun suitStrength(hand: List<Card>, suit: Suit): Double =
expectedTricks(hand, suit)
/**
* Roughly how many tricks a hand should take. Deliberately blunt: it does
* not know how many cards are out, only how good its own are.
*/
private fun expectedTricks(hand: List<Card>, trump: Suit?): Double {
var expected = 0.0
for (card in hand) {
expected += when {
isWizardCard(card) -> 1.0
isJester(card) -> 0.0
trump != null && card.suit == trump -> when {
card.rank.order >= Rank.KING.order -> 0.9
card.rank.order >= Rank.JACK.order -> 0.6
else -> 0.3
}
card.rank == Rank.ACE -> 0.75
card.rank == Rank.KING -> 0.45
card.rank == Rank.QUEEN -> 0.2
else -> 0.0
}
}
return expected
}
// -----------------------------------------------------------------------
// Play
// -----------------------------------------------------------------------
private fun choosePlay(state: WizardState, seat: Int, legal: List<WizardMove>): WizardMove {
val cards = legal.filterIsInstance<PlayCard>().map { it.card }
if (cards.size == 1) return PlayCard(cards.first())
val bid = state.bids[seat] ?: 0
val stillNeeded = bid - state.tricksWon[seat]
val tricksLeft = state.hands[seat].size
// Chase while we are short, and only while there are enough tricks left
// to be worth chasing.
val wantsTrick = stillNeeded > 0 && stillNeeded <= tricksLeft
if (state.trick.isEmpty()) return PlayCard(chooseLead(cards, state.trump, wantsTrick))
val ledSuit = ledSuitOf(state.trick)
val order = state.trick.size
val bestOnTable = state.trick
.withIndex()
.maxOf { (index, played) -> trickStrength(played.card, index, state.trump, ledSuit) }
// A card played now takes the position after everything already down.
val strengthOf = { card: Card -> trickStrength(card, order, state.trump, ledSuit) }
val winners = cards.filter { strengthOf(it) > bestOnTable }
val losers = cards.filter { strengthOf(it) <= bestOnTable }
return when {
// Take it as cheaply as we can.
wantsTrick && winners.isNotEmpty() -> PlayCard(winners.minBy { strengthOf(it) })
// Cannot take it, or do not want it: throw the least useful card.
losers.isNotEmpty() -> PlayCard(losers.minBy { strengthOf(it) })
// Everything we hold wins, and we did not want the trick. Take it
// with the weakest winner so the good cards stay for later.
else -> PlayCard(cards.minBy { strengthOf(it) })
}
}
private fun chooseLead(cards: List<Card>, trump: Suit?, wantsTrick: Boolean): Card {
if (wantsTrick) {
// A led wizard is an unloseable trick.
cards.firstOrNull { isWizardCard(it) }?.let { return it }
val topTrump = cards.filter { trump != null && it.suit == trump }
.maxByOrNull { it.rank.order }
if (topTrump != null && topTrump.rank.order >= Rank.QUEEN.order) return topTrump
cards.firstOrNull { it.rank == Rank.ACE && !isJester(it) }?.let { return it }
return cards.filterNot { isJester(it) }.maxByOrNull { it.rank.order } ?: cards.first()
}
// Ducking: a led jester cannot take anything at all.
cards.firstOrNull { isJester(it) }?.let { return it }
return cards.filterNot { isWizardCard(it) }
.minByOrNull { it.rank.order + if (trump != null && it.suit == trump) 20 else 0 }
?: cards.first()
}
}
@@ -0,0 +1,123 @@
package org.prolibertate.games.game.wizard
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
@Serializable
data class WizardOptions(
val playerCount: Int = 4,
/**
* Zero plays the full game: sixty cards divided by the table, so four
* players get fifteen rounds. A smaller number shortens the evening.
*/
val rounds: Int = 0,
/**
* The dealer may not bid the number that would make the bids add up to the
* tricks available, so somebody always misses. A common house rule.
*/
val screwTheDealer: Boolean = false,
) {
init {
require(playerCount in 3..6) { "Wizard seats three to six" }
require(rounds >= 0) { "rounds cannot be negative" }
}
/** Sixty cards share out into this many rounds. */
fun totalRounds(): Int {
val full = 60 / playerCount
return if (rounds in 1..full) rounds else full
}
}
fun isWizardCard(card: Card): Boolean = card.rank == Rank.WIZARD
fun isJester(card: Card): Boolean = card.rank == Rank.JESTER
@Serializable
enum class WizardPhase { BIDDING, PLAYING, ROUND_OVER, GAME_OVER }
@Serializable
data class PlayedCard(val seat: Int, val card: Card)
@Serializable
data class WizardState(
val options: WizardOptions,
val seed: Long,
/** Zero-based; round n deals n+1 cards. */
val round: Int,
val dealer: Int,
val phase: WizardPhase,
val hands: List<List<Card>>,
val handCounts: List<Int>,
/** Turned up after the deal to set trump. Null in the last round. */
val trumpCard: Card?,
val trump: Suit?,
/** Null until a seat has bid; one entry per seat. */
val bids: List<Int?>,
val tricksWon: List<Int>,
val turn: Int,
val trick: List<PlayedCard>,
/** Held on the table so a finished trick can be read before it is swept. */
val completedTrick: List<PlayedCard> = emptyList(),
val leader: Int,
val lastTrickWinner: Int?,
val scores: List<Int>,
val roundScores: List<Int>,
val log: List<String>,
) {
val cardsThisRound: Int get() = round + 1
}
@Serializable
sealed interface WizardMove
@Serializable
@SerialName("bid")
data class MakeBid(val tricks: Int) : WizardMove
@Serializable
@SerialName("play")
data class PlayCard(val card: Card) : WizardMove
/** The dealer picks trump when a wizard is turned up. */
@Serializable
@SerialName("choose_trump")
data class ChooseTrump(val suit: Suit) : WizardMove
/**
* The suit a trick is being played to.
*
* A jester leads nothing — if the first card is a jester the suit is set by the
* next ordinary card instead. A wizard reaching the front of that queue means
* no suit is led at all and anything may be played.
*/
fun ledSuitOf(trick: List<PlayedCard>): Suit? {
for (played in trick) {
if (isWizardCard(played.card)) return null
if (isJester(played.card)) continue
return played.card.suit
}
return null
}
/**
* Ranks a card within a trick. The first wizard played takes it outright;
* jesters never win unless nothing else was played.
*/
fun trickStrength(card: Card, order: Int, trump: Suit?, ledSuit: Suit?): Int = when {
// Earlier wizards beat later ones, so subtract the position.
isWizardCard(card) -> 10_000 - order
// A jester only ever takes a trick of nothing but jesters, and then it is
// the first one played that wins.
isJester(card) -> 1
trump != null && card.suit == trump -> 1_000 + card.rank.order
ledSuit != null && card.suit == ledSuit -> 100 + card.rank.order
// Off-suit and not trump: cannot win, but still beats a jester.
else -> 2
}
/** Exact bids pay twenty plus ten a trick; missing costs ten a trick out. */
fun scoreRound(bid: Int, won: Int): Int =
if (bid == won) 20 + 10 * won else -10 * kotlin.math.abs(bid - won)
@@ -0,0 +1,263 @@
package org.prolibertate.games.game.wizard
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Decks
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.cards.shuffledWith
import org.prolibertate.games.game.engine.GameRules
import org.prolibertate.games.game.engine.TableConfig
import kotlin.random.Random
/**
* Wizard as a pure state machine. See RULES-wizard.md.
*/
object WizardRules : GameRules<WizardState, WizardMove> {
override val gameId: String = GameCatalog.WIZARD
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
override fun initialState(config: TableConfig): WizardState {
val options = json.decodeFromString<WizardOptions>(config.optionsJson)
require(config.seats.size == options.playerCount) {
"Seat count ${config.seats.size} does not match ${options.playerCount} players"
}
return deal(options, config.seed, 0, 0, List(options.playerCount) { 0 }, listOf("Round 1."))
}
private fun deal(
options: WizardOptions,
seed: Long,
round: Int,
dealer: Int,
scores: List<Int>,
log: List<String>,
): WizardState {
val random = Random(seed + round * 7919L)
var deck = Decks.wizard().shuffledWith(random)
val cards = round + 1
val hands = mutableListOf<List<Card>>()
repeat(options.playerCount) {
hands += deck.take(cards)
deck = deck.drop(cards)
}
// Trump comes off the top of what is left. In the last round the whole
// deck has been dealt, so there is nothing to turn and no trump.
val trumpCard = deck.firstOrNull()
val trump = when {
trumpCard == null -> null
isWizardCard(trumpCard) -> null // dealer chooses; see BIDDING below
isJester(trumpCard) -> null
else -> trumpCard.suit
}
return WizardState(
options = options,
seed = seed,
round = round,
dealer = dealer,
phase = WizardPhase.BIDDING,
hands = hands,
handCounts = hands.map { it.size },
trumpCard = trumpCard,
trump = trump,
bids = List(options.playerCount) { null },
tricksWon = List(options.playerCount) { 0 },
// A turned wizard means the dealer picks trump before anyone bids.
turn = if (trumpCard != null && isWizardCard(trumpCard)) {
dealer
} else {
(dealer + 1) % options.playerCount
},
trick = emptyList(),
completedTrick = emptyList(),
leader = (dealer + 1) % options.playerCount,
lastTrickWinner = null,
scores = scores,
roundScores = List(options.playerCount) { 0 },
log = log,
)
}
/** Advances from [WizardPhase.ROUND_OVER] into the next deal. */
fun nextRound(state: WizardState): WizardState {
check(state.phase == WizardPhase.ROUND_OVER) { "Round is not over" }
val dealer = (state.dealer + 1) % state.options.playerCount
return deal(
options = state.options,
seed = state.seed,
round = state.round + 1,
dealer = dealer,
scores = state.scores,
log = state.log + "Round ${state.round + 2}.",
)
}
override fun currentSeat(state: WizardState): Int? = when (state.phase) {
WizardPhase.BIDDING, WizardPhase.PLAYING -> state.turn
WizardPhase.ROUND_OVER, WizardPhase.GAME_OVER -> null
}
// -----------------------------------------------------------------------
// Legal moves
// -----------------------------------------------------------------------
override fun legalMoves(state: WizardState, seat: Int): List<WizardMove> {
if (currentSeat(state) != seat) return emptyList()
return when (state.phase) {
WizardPhase.BIDDING -> biddingMoves(state, seat)
WizardPhase.PLAYING -> playableCards(state, seat).map { PlayCard(it) }
WizardPhase.ROUND_OVER, WizardPhase.GAME_OVER -> emptyList()
}
}
private fun biddingMoves(state: WizardState, seat: Int): List<WizardMove> {
if (needsTrumpChoice(state)) return Suit.entries.map { ChooseTrump(it) }
val available = state.cardsThisRound
val bids = (0..available).toMutableList()
// Screw the dealer: the last bid may not make the bids balance, so at
// least one player is always going to be wrong.
if (state.options.screwTheDealer && seat == state.dealer) {
val alreadyBid = state.bids.filterNotNull().sum()
val forbidden = available - alreadyBid
bids.remove(forbidden)
}
return bids.map { MakeBid(it) }
}
/** A wizard on top means the dealer names trump before bidding starts. */
private fun needsTrumpChoice(state: WizardState): Boolean =
state.trumpCard != null &&
isWizardCard(state.trumpCard) &&
state.trump == null &&
state.bids.all { it == null }
/**
* Follow the led suit if you hold it. Wizards and jesters are always legal
* — playing one is how you get out of following.
*/
fun playableCards(state: WizardState, seat: Int): List<Card> {
val hand = state.hands[seat]
val led = ledSuitOf(state.trick) ?: return hand
val following = hand.filter { it.suit == led && !isWizardCard(it) && !isJester(it) }
if (following.isEmpty()) return hand
// Keep the hand's own order so the UI does not reshuffle under the player.
return hand.filter { it.suit == led || isWizardCard(it) || isJester(it) }
}
// -----------------------------------------------------------------------
// Move application
// -----------------------------------------------------------------------
override fun applyMove(state: WizardState, seat: Int, move: WizardMove): WizardState {
require(currentSeat(state) == seat) { "Not seat $seat's turn" }
require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" }
return when (move) {
is ChooseTrump -> state.copy(
trump = move.suit,
turn = (state.dealer + 1) % state.options.playerCount,
log = state.log + "Dealer names ${move.suit.symbol} trump.",
)
is MakeBid -> applyBid(state, seat, move.tricks)
is PlayCard -> applyPlay(state, seat, move.card)
}.normalised()
}
private fun WizardState.normalised(): WizardState = copy(handCounts = hands.map { it.size })
private fun applyBid(state: WizardState, seat: Int, tricks: Int): WizardState {
val bids = state.bids.toMutableList()
bids[seat] = tricks
val next = state.copy(
bids = bids,
log = state.log + "Seat $seat bids $tricks.",
)
if (bids.all { it != null }) {
val leader = (state.dealer + 1) % state.options.playerCount
return next.copy(phase = WizardPhase.PLAYING, turn = leader, leader = leader)
}
return next.copy(turn = (seat + 1) % state.options.playerCount)
}
private fun applyPlay(state: WizardState, seat: Int, card: Card): WizardState {
val hands = state.hands.toMutableList()
hands[seat] = hands[seat] - card
val trick = state.trick + PlayedCard(seat, card)
val played = state.copy(hands = hands, trick = trick, completedTrick = emptyList())
if (trick.size < state.options.playerCount) {
return played.copy(turn = (seat + 1) % state.options.playerCount)
}
val ledSuit = ledSuitOf(trick)
val winner = trick
.withIndex()
.maxBy { (index, played) -> trickStrength(played.card, index, state.trump, ledSuit) }
.value.seat
val tricksWon = played.tricksWon.toMutableList()
tricksWon[winner] = tricksWon[winner] + 1
val afterTrick = played.copy(
trick = emptyList(),
completedTrick = trick,
tricksWon = tricksWon,
leader = winner,
turn = winner,
lastTrickWinner = winner,
log = state.log + "Seat $winner takes the trick.",
)
return if (afterTrick.hands.all { it.isEmpty() }) scoreRoundEnd(afterTrick) else afterTrick
}
private fun scoreRoundEnd(state: WizardState): WizardState {
val roundScores = state.bids.mapIndexed { seat, bid ->
scoreRound(bid ?: 0, state.tricksWon[seat])
}
val scores = state.scores.mapIndexed { seat, total -> total + roundScores[seat] }
val lastRound = state.round + 1 >= state.options.totalRounds()
return state.copy(
phase = if (lastRound) WizardPhase.GAME_OVER else WizardPhase.ROUND_OVER,
roundScores = roundScores,
scores = scores,
log = state.log + roundScores.mapIndexed { seat, points ->
"Seat $seat bid ${state.bids[seat]}, took ${state.tricksWon[seat]}: $points"
},
)
}
override fun isFinished(state: WizardState): Boolean = state.phase == WizardPhase.GAME_OVER
override fun summary(state: WizardState): String = when (state.phase) {
WizardPhase.GAME_OVER -> {
val best = state.scores.indices.maxByOrNull { state.scores[it] } ?: 0
"Seat $best wins with ${state.scores[best]}"
}
else -> state.scores.mapIndexed { seat, s -> "S$seat:$s" }.joinToString(" ")
}
override fun viewFor(state: WizardState, seat: Int): WizardState = state.copy(
hands = state.hands.mapIndexed { index, hand -> if (index == seat) hand else emptyList() },
handCounts = state.handCounts,
)
override fun encodeState(state: WizardState): String = json.encodeToString(state)
override fun decodeState(json: String): WizardState = this.json.decodeFromString(json)
override fun encodeMove(move: WizardMove): String = json.encodeToString(move)
override fun decodeMove(json: String): WizardMove = this.json.decodeFromString(json)
}
@@ -1,7 +1,9 @@
package org.prolibertate.games.net package org.prolibertate.games.net
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -231,17 +233,24 @@ class MatchController<S : Any, M : Any>(
if (seatKinds[seat] != PlayerKind.AI) return // a person's turn if (seatKinds[seat] != PlayerKind.AI) return // a person's turn
delay(holdBeforeNextMove(current) + aiThinkingMillis()) delay(holdBeforeNextMove(current) + aiThinkingMillis())
val chosen = lock.withLock {
val legal = rules.legalMoves(current, seat)
if (legal.isEmpty()) return
// Off the caller's thread. A card AI answers instantly, but a chess
// search runs for a second or more, and this scope belongs to the
// screen — thinking here would freeze the board it is drawn on.
val move = withContext(Dispatchers.Default) { ai.chooseMove(current, seat, legal) }
val applied = lock.withLock {
val live = authoritative ?: return val live = authoritative ?: return
if (rules.currentSeat(live) != seat) return@withLock null // Nothing else can move while an AI seat is on the clock, but
val legal = rules.legalMoves(live, seat) // the state is re-read rather than assumed.
if (legal.isEmpty()) return@withLock null if (rules.currentSeat(live) != seat) return@withLock false
val move = ai.chooseMove(live, seat, legal)
authoritative = rules.applyMove(live, seat, move) authoritative = rules.applyMove(live, seat, move)
publish() publish()
move true
} }
if (chosen == null) return if (!applied) return
} }
} }
@@ -146,7 +146,7 @@ private fun AppContent(
descriptor = GameCatalog.byId(route.gameId)!!, descriptor = GameCatalog.byId(route.gameId)!!,
playerName = settings.displayName, playerName = settings.displayName,
onBack = { pop() }, onBack = { pop() },
onPlayOffline = { config -> push(Route.Play(route.gameId, config, true, 0)) }, onPlayOffline = { config, seat -> push(Route.Play(route.gameId, config, true, seat)) },
onHostOnline = { optionsJson -> onHostOnline = { optionsJson ->
push(Route.Lobby(route.gameId, optionsJson, hosting = true)) push(Route.Lobby(route.gameId, optionsJson, hosting = true))
}, },
@@ -30,12 +30,20 @@ import org.prolibertate.games.game.GameDescriptor
import org.prolibertate.games.game.engine.PlayerKind import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig import org.prolibertate.games.game.engine.TableConfig
import org.prolibertate.games.game.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.euchre.EuchreOptions
import org.prolibertate.games.game.golf.GolfOptions import org.prolibertate.games.game.golf.GolfOptions
import org.prolibertate.games.game.kaiser.KaiserOptions
import org.prolibertate.games.game.president.PresidentOptions import org.prolibertate.games.game.president.PresidentOptions
import org.prolibertate.games.game.sequence.SequenceOptions import org.prolibertate.games.game.sequence.SequenceOptions
import org.prolibertate.games.game.wizard.WizardOptions
private val setupJson = Json { encodeDefaults = true } private val setupJson = Json {
encodeDefaults = true
ignoreUnknownKeys = true
}
/** /**
* Choose who is playing and which house rules apply. * Choose who is playing and which house rules apply.
@@ -48,28 +56,33 @@ fun GameSetupScreen(
descriptor: GameDescriptor, descriptor: GameDescriptor,
playerName: String, playerName: String,
onBack: () -> Unit, onBack: () -> Unit,
onPlayOffline: (TableConfig) -> Unit, onPlayOffline: (TableConfig, Int) -> Unit,
onHostOnline: (String) -> Unit, onHostOnline: (String) -> Unit,
) { ) {
var euchre by remember { mutableStateOf(EuchreOptions()) } var euchre by remember { mutableStateOf(EuchreOptions()) }
var sequence by remember { mutableStateOf(SequenceOptions()) } var sequence by remember { mutableStateOf(SequenceOptions()) }
var president by remember { mutableStateOf(PresidentOptions()) } var president by remember { mutableStateOf(PresidentOptions()) }
var golf by remember { mutableStateOf(GolfOptions()) } var golf by remember { mutableStateOf(GolfOptions()) }
var kaiser by remember { mutableStateOf(KaiserOptions()) }
var crazyEights by remember { mutableStateOf(CrazyEightsOptions()) }
var wizard by remember { mutableStateOf(WizardOptions()) }
var chess by remember { mutableStateOf(ChessOptions()) }
// 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) }
val optionsJson = when (descriptor.id) { val optionsJson = when (descriptor.id) {
GameCatalog.EUCHRE -> setupJson.encodeToString(euchre) GameCatalog.EUCHRE -> setupJson.encodeToString(euchre)
GameCatalog.SEQUENCE -> setupJson.encodeToString(sequence) GameCatalog.SEQUENCE -> setupJson.encodeToString(sequence)
GameCatalog.PRESIDENT -> setupJson.encodeToString(president) GameCatalog.PRESIDENT -> setupJson.encodeToString(president)
GameCatalog.GOLF -> setupJson.encodeToString(golf) GameCatalog.GOLF -> setupJson.encodeToString(golf)
GameCatalog.KAISER -> setupJson.encodeToString(kaiser)
GameCatalog.CRAZY_EIGHTS -> setupJson.encodeToString(crazyEights)
GameCatalog.WIZARD -> setupJson.encodeToString(wizard)
GameCatalog.CHESS -> setupJson.encodeToString(chess)
else -> "{}" else -> "{}"
} }
val seatCount = when (descriptor.id) { val seatCount = seatCountFor(descriptor.id, optionsJson)
GameCatalog.EUCHRE -> 4
GameCatalog.SEQUENCE -> sequence.playerCount
GameCatalog.PRESIDENT -> president.playerCount
GameCatalog.GOLF -> golf.playerCount
else -> descriptor.minPlayers
}
ScreenScaffold(title = descriptor.title, onBack = onBack) { modifier -> ScreenScaffold(title = descriptor.title, onBack = onBack) { modifier ->
Column( Column(
@@ -86,6 +99,19 @@ fun GameSetupScreen(
GameCatalog.SEQUENCE -> SequenceOptionsEditor(sequence) { sequence = it } GameCatalog.SEQUENCE -> SequenceOptionsEditor(sequence) { sequence = it }
GameCatalog.PRESIDENT -> PresidentOptionsEditor(president) { president = it } GameCatalog.PRESIDENT -> PresidentOptionsEditor(president) { president = it }
GameCatalog.GOLF -> GolfOptionsEditor(golf) { golf = it } GameCatalog.GOLF -> GolfOptionsEditor(golf) { golf = it }
GameCatalog.KAISER -> KaiserOptionsEditor(kaiser) { kaiser = it }
GameCatalog.CRAZY_EIGHTS -> CrazyEightsOptionsEditor(crazyEights) {
crazyEights = it
}
GameCatalog.WIZARD -> WizardOptionsEditor(wizard) { wizard = it }
GameCatalog.CHESS -> ChessOptionsEditor(
options = chess,
playWhite = playWhite,
onChange = { chess = it },
onColour = { playWhite = it },
)
else -> Text("No options yet for this game.") else -> Text("No options yet for this game.")
} }
@@ -98,9 +124,13 @@ fun GameSetupScreen(
style = MaterialTheme.typography.bodySmall, 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
PrimaryAction(text = "Play against the computer") { PrimaryAction(text = "Play against the computer") {
onPlayOffline( onPlayOffline(
offlineConfig(descriptor.id, optionsJson, seatCount, playerName) offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat),
localSeat,
) )
} }
PrimaryAction(text = "Host a game for others to join") { PrimaryAction(text = "Host a game for others to join") {
@@ -110,19 +140,20 @@ fun GameSetupScreen(
} }
} }
/** Seat 0 is the local player; the rest start as AI. */ /** [localSeat] is the person at this device; every other seat starts as AI. */
private fun offlineConfig( private fun offlineConfig(
gameId: String, gameId: String,
optionsJson: String, optionsJson: String,
seatCount: Int, seatCount: Int,
playerName: String, playerName: String,
localSeat: Int = 0,
): TableConfig = TableConfig( ): TableConfig = TableConfig(
gameId = gameId, gameId = gameId,
seats = (0 until seatCount).map { seat -> seats = (0 until seatCount).map { seat ->
PlayerSlot( PlayerSlot(
seat = seat, seat = seat,
name = if (seat == 0) playerName else "Computer $seat", name = if (seat == localSeat) playerName else "Computer $seat",
kind = if (seat == 0) PlayerKind.HUMAN_LOCAL else PlayerKind.AI, kind = if (seat == localSeat) PlayerKind.HUMAN_LOCAL else PlayerKind.AI,
team = teamForSeat(gameId, seat), team = teamForSeat(gameId, seat),
) )
}, },
@@ -131,9 +162,42 @@ private fun offlineConfig(
seed = System.currentTimeMillis(), seed = System.currentTimeMillis(),
) )
/**
* How many seats a table needs, read back out of the options the host chose.
*
* The lobby only carries the options as JSON, so this is the one place that
* knows a six-handed Wizard game needs six seats rather than the catalogue's
* minimum — seat the wrong number and the rules engine rejects the table.
*/
fun seatCountFor(gameId: String, optionsJson: String): Int {
val fallback = GameCatalog.byId(gameId)?.minPlayers ?: 2
return runCatching {
when (gameId) {
GameCatalog.EUCHRE, GameCatalog.KAISER -> 4
GameCatalog.SEQUENCE ->
setupJson.decodeFromString<SequenceOptions>(optionsJson).playerCount
GameCatalog.PRESIDENT ->
setupJson.decodeFromString<PresidentOptions>(optionsJson).playerCount
GameCatalog.GOLF ->
setupJson.decodeFromString<GolfOptions>(optionsJson).playerCount
GameCatalog.CRAZY_EIGHTS ->
setupJson.decodeFromString<CrazyEightsOptions>(optionsJson).playerCount
GameCatalog.WIZARD ->
setupJson.decodeFromString<WizardOptions>(optionsJson).playerCount
GameCatalog.CHESS -> 2
else -> fallback
}
}.getOrDefault(fallback)
}
fun teamForSeat(gameId: String, seat: Int): Int = when (gameId) { fun teamForSeat(gameId: String, seat: Int): Int = when (gameId) {
// Partners sit opposite each other. // Partners sit opposite each other.
GameCatalog.EUCHRE, GameCatalog.SEQUENCE -> seat % 2 GameCatalog.EUCHRE, GameCatalog.KAISER, GameCatalog.SEQUENCE -> seat % 2
// Everyone else plays for themselves. // Everyone else plays for themselves.
else -> seat else -> seat
} }
@@ -305,6 +369,155 @@ private fun GolfOptionsEditor(options: GolfOptions, onChange: (GolfOptions) -> U
} }
} }
@Composable
private fun KaiserOptionsEditor(options: KaiserOptions, onChange: (KaiserOptions) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ChipRow(
label = "Lowest opening bid",
values = listOf(5, 6, 7, 8),
selected = options.minimumBid,
display = { "$it" },
onSelect = { onChange(options.copy(minimumBid = it)) },
)
ChipRow(
label = "Game to",
values = listOf(32, 52, 62),
selected = options.pointsToWin,
display = { "$it points" },
onSelect = { target ->
// The floor moves with the target: a long game needs a long
// rope before a team that keeps going down is written off.
onChange(options.copy(pointsToWin = target, losingScore = -target))
},
)
ToggleRow(
title = "No-trump bids",
subtitle = "Pays double, and costs double when it goes down",
checked = options.allowNoTrump,
onChange = { onChange(options.copy(allowNoTrump = it)) },
)
Text(
text = "Four players in two partnerships. The 5♥ is worth five and the 3♠ " +
"costs three, so a hand is worth ten points in all.",
style = MaterialTheme.typography.bodySmall,
)
}
}
@Composable
private fun CrazyEightsOptionsEditor(
options: CrazyEightsOptions,
onChange: (CrazyEightsOptions) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ChipRow(
label = "Players",
values = listOf(2, 3, 4, 5, 6),
selected = options.playerCount,
display = { "$it" },
onSelect = { onChange(options.copy(playerCount = it)) },
)
ChipRow(
label = "Cards each",
values = listOf(0, 5, 7, 9),
selected = options.startingHand,
display = { if (it == 0) "standard" else "$it" },
onSelect = { onChange(options.copy(startingHand = it)) },
)
ChipRow(
label = "Rounds",
values = listOf(1, 3, 5),
selected = options.roundsToPlay,
display = { "$it" },
onSelect = { onChange(options.copy(roundsToPlay = it)) },
)
ToggleRow(
title = "Draw until you can play",
subtitle = "Otherwise you draw a single card and the turn passes on",
checked = options.drawUntilPlayable,
onChange = { onChange(options.copy(drawUntilPlayable = it)) },
)
Text(
text = "Standard is seven cards heads-up and five otherwise. Eights are wild " +
"and cost fifty if you are caught holding one.",
style = MaterialTheme.typography.bodySmall,
)
}
}
@Composable
private fun WizardOptionsEditor(options: WizardOptions, onChange: (WizardOptions) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ChipRow(
label = "Players",
values = listOf(3, 4, 5, 6),
selected = options.playerCount,
display = { "$it" },
onSelect = { onChange(options.copy(playerCount = it)) },
)
ChipRow(
label = "Rounds",
values = listOf(0, 5, 10),
selected = options.rounds,
display = { if (it == 0) "full game" else "$it" },
onSelect = { onChange(options.copy(rounds = it)) },
)
ToggleRow(
title = "Screw the dealer",
subtitle = "The dealer may not make the bids add up, so somebody always misses",
checked = options.screwTheDealer,
onChange = { onChange(options.copy(screwTheDealer = it)) },
)
Text(
text = "A full game deals the whole sixty-card pack out: " +
"${options.totalRounds()} rounds at this table.",
style = MaterialTheme.typography.bodySmall,
)
}
}
@Composable
private fun ChessOptionsEditor(
options: ChessOptions,
playWhite: Boolean,
onChange: (ChessOptions) -> Unit,
onColour: (Boolean) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ChipRow(
label = "You play",
values = listOf(true, false),
selected = playWhite,
display = { if (it) "White" else "Black" },
onSelect = onColour,
)
ChipRow(
label = "Opponent",
values = ChessLevel.entries.toList(),
selected = options.level,
display = { it.label },
onSelect = { onChange(options.copy(level = it)) },
)
ToggleRow(
title = "Fifty-move rule",
subtitle = "Fifty moves each with no capture and no pawn move is a draw",
checked = options.fiftyMoveRule,
onChange = { onChange(options.copy(fiftyMoveRule = 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 = "Full rules: castling, en passant, promotion, stalemate, and a draw " +
"when neither side has enough material to mate.",
style = MaterialTheme.typography.bodySmall,
)
}
}
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
private fun <T> ChipRow( private fun <T> ChipRow(
@@ -21,7 +21,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.engine.PlayerKind import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.TableConfig import org.prolibertate.games.game.engine.TableConfig
import org.prolibertate.games.net.DiscoveredHost import org.prolibertate.games.net.DiscoveredHost
@@ -46,10 +45,9 @@ fun LobbyScreen(
LaunchedEffect(route) { LaunchedEffect(route) {
env.requestBluetoothPermissions() env.requestBluetoothPermissions()
if (route.hosting) { if (route.hosting) {
val descriptor = GameCatalog.byId(route.gameId) // Seat the table the host actually set up, not the catalogue
val seatCount = descriptor?.let { // minimum: the rules engine rejects a table of the wrong size.
if (it.id == GameCatalog.EUCHRE) 4 else it.minPlayers val seatCount = seatCountFor(route.gameId, route.optionsJson)
} ?: 4
env.lobby.startHosting( env.lobby.startHosting(
gameId = route.gameId, gameId = route.gameId,
optionsJson = route.optionsJson, optionsJson = route.optionsJson,
@@ -7,6 +7,15 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import org.prolibertate.games.game.GameCatalog import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.chess.ChessAi
import org.prolibertate.games.game.chess.ChessMove
import org.prolibertate.games.game.chess.ChessRules
import org.prolibertate.games.game.chess.ChessState
import org.prolibertate.games.game.crazyeights.CrazyEightsAi
import org.prolibertate.games.game.crazyeights.CrazyEightsMove
import org.prolibertate.games.game.crazyeights.CrazyEightsPhase
import org.prolibertate.games.game.crazyeights.CrazyEightsRules
import org.prolibertate.games.game.crazyeights.CrazyEightsState
import org.prolibertate.games.game.euchre.EuchreAi import org.prolibertate.games.game.euchre.EuchreAi
import org.prolibertate.games.game.euchre.EuchreMove import org.prolibertate.games.game.euchre.EuchreMove
import org.prolibertate.games.game.euchre.EuchrePhase import org.prolibertate.games.game.euchre.EuchrePhase
@@ -17,6 +26,11 @@ import org.prolibertate.games.game.golf.GolfMove
import org.prolibertate.games.game.golf.GolfPhase import org.prolibertate.games.game.golf.GolfPhase
import org.prolibertate.games.game.golf.GolfRules import org.prolibertate.games.game.golf.GolfRules
import org.prolibertate.games.game.golf.GolfState import org.prolibertate.games.game.golf.GolfState
import org.prolibertate.games.game.kaiser.KaiserAi
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.president.PresidentAi import org.prolibertate.games.game.president.PresidentAi
import org.prolibertate.games.game.president.PresidentMove import org.prolibertate.games.game.president.PresidentMove
import org.prolibertate.games.game.president.PresidentPhase import org.prolibertate.games.game.president.PresidentPhase
@@ -26,13 +40,22 @@ import org.prolibertate.games.game.sequence.SequenceAi
import org.prolibertate.games.game.sequence.SequenceMove import org.prolibertate.games.game.sequence.SequenceMove
import org.prolibertate.games.game.sequence.SequenceRules import org.prolibertate.games.game.sequence.SequenceRules
import org.prolibertate.games.game.sequence.SequenceState import org.prolibertate.games.game.sequence.SequenceState
import org.prolibertate.games.game.wizard.WizardAi
import org.prolibertate.games.game.wizard.WizardMove
import org.prolibertate.games.game.wizard.WizardPhase
import org.prolibertate.games.game.wizard.WizardRules
import org.prolibertate.games.game.wizard.WizardState
import org.prolibertate.games.net.MatchController import org.prolibertate.games.net.MatchController
import org.prolibertate.games.settings.Settings import org.prolibertate.games.settings.Settings
import org.prolibertate.games.ui.game.ChessScreen
import org.prolibertate.games.ui.game.CrazyEightsScreen
import org.prolibertate.games.ui.game.EuchreScreen import org.prolibertate.games.ui.game.EuchreScreen
import org.prolibertate.games.ui.game.GolfScreen import org.prolibertate.games.ui.game.GolfScreen
import org.prolibertate.games.ui.game.KaiserScreen
import org.prolibertate.games.ui.game.PresidentScreen import org.prolibertate.games.ui.game.PresidentScreen
import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS
import org.prolibertate.games.ui.game.SequenceScreen import org.prolibertate.games.ui.game.SequenceScreen
import org.prolibertate.games.ui.game.WizardScreen
/** /**
* Builds the match for the chosen game and hands it to that game's screen. * Builds the match for the chosen game and hands it to that game's screen.
@@ -154,6 +177,128 @@ fun PlayScreen(
GolfScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) GolfScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
} }
GameCatalog.KAISER -> {
val controller = remember(route) {
MatchController<KaiserState, KaiserMove>(
rules = KaiserRules,
ai = KaiserAi(),
config = route.config,
scope = scope,
role = role,
localSeats = setOf(route.localSeat),
primarySeat = route.localSeat,
advanceIdle = { state ->
if (state.phase == KaiserPhase.HAND_OVER) {
KaiserRules.nextHand(state)
} else {
null
}
},
aiThinkingMillis = { settings.scaled(700L) },
holdBeforeNextMove = { state ->
if (state.completedTrick.isNotEmpty()) {
settings.scaled(TRICK_HOLD_MILLIS)
} else {
0L
}
},
)
}
StartMatch(env, controller, route)
KaiserScreen(
controller = controller,
localSeat = route.localSeat,
trickHoldMillis = settings.scaled(TRICK_HOLD_MILLIS),
onExit = onExit,
)
}
GameCatalog.WIZARD -> {
val controller = remember(route) {
MatchController<WizardState, WizardMove>(
rules = WizardRules,
ai = WizardAi(),
config = route.config,
scope = scope,
role = role,
localSeats = setOf(route.localSeat),
primarySeat = route.localSeat,
advanceIdle = { state ->
if (state.phase == WizardPhase.ROUND_OVER) {
WizardRules.nextRound(state)
} else {
null
}
},
// Bids against tricks taken are read before the next deal.
awaitsConfirmation = { state -> state.phase == WizardPhase.ROUND_OVER },
aiThinkingMillis = { settings.scaled(700L) },
holdBeforeNextMove = { state ->
if (state.completedTrick.isNotEmpty()) {
settings.scaled(TRICK_HOLD_MILLIS)
} else {
0L
}
},
)
}
StartMatch(env, controller, route)
WizardScreen(
controller = controller,
localSeat = route.localSeat,
trickHoldMillis = settings.scaled(TRICK_HOLD_MILLIS),
onExit = onExit,
)
}
GameCatalog.CRAZY_EIGHTS -> {
val controller = remember(route) {
MatchController<CrazyEightsState, CrazyEightsMove>(
rules = CrazyEightsRules,
ai = CrazyEightsAi(),
config = route.config,
scope = scope,
role = role,
localSeats = setOf(route.localSeat),
primarySeat = route.localSeat,
advanceIdle = { state ->
if (state.phase == CrazyEightsPhase.ROUND_OVER) {
CrazyEightsRules.nextRound(state)
} else {
null
}
},
awaitsConfirmation = { state ->
state.phase == CrazyEightsPhase.ROUND_OVER
},
aiThinkingMillis = { settings.scaled(700L) },
)
}
StartMatch(env, controller, route)
CrazyEightsScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
}
GameCatalog.CHESS -> {
val controller = remember(route) {
MatchController<ChessState, ChessMove>(
rules = ChessRules,
// Strength comes from the table's own options, so the setup
// screen sets it without this having to decode anything.
ai = ChessAi(),
config = route.config,
scope = scope,
role = role,
localSeats = setOf(route.localSeat),
primarySeat = route.localSeat,
// The search is the thinking time; adding more on top of it
// would only make the computer look slower than it is.
aiThinkingMillis = { settings.scaled(250L) },
)
}
StartMatch(env, controller, route)
ChessScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
}
else -> Text("That game isn't playable yet.") else -> Text("That game isn't playable yet.")
} }
} }
@@ -0,0 +1,378 @@
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.layout.width
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.Button
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.chess.ChessMove
import org.prolibertate.games.game.chess.ChessPhase
import org.prolibertate.games.game.chess.ChessRules
import org.prolibertate.games.game.chess.ChessState
import org.prolibertate.games.game.chess.Piece
import org.prolibertate.games.game.chess.PieceKind
import org.prolibertate.games.game.chess.fileOf
import org.prolibertate.games.game.chess.isLightSquare
import org.prolibertate.games.game.chess.rankOf
import org.prolibertate.games.game.chess.seatIsWhite
import org.prolibertate.games.game.chess.squareName
import org.prolibertate.games.net.MatchController
import org.prolibertate.games.ui.ScreenScaffold
/** Board colours, kept off the theme so the board reads the same in either mode. */
private val LightSquare = Color(0xFFEEDAB4)
private val DarkSquare = Color(0xFF9A7248)
private val SelectedSquare = Color(0x8830C030)
private val LastMoveSquare = Color(0x55FFC107)
private val CheckSquare = Color(0x99D32F2F)
private val MoveDot = Color(0x66000000)
/**
* The chess board.
*
* Tap a piece, then tap where it goes. The board is always drawn from the local
* player's side, so a game against the computer as Black is played the way it
* would be over a real board rather than upside down.
*/
@Composable
fun ChessScreen(
controller: MatchController<ChessState, ChessMove>,
localSeat: Int,
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val legal by controller.legalMoves.collectAsState()
var selected by remember { mutableStateOf<Int?>(null) }
// A pawn reaching the last rank is one tap that stands for four moves, so
// the destination is held while the piece is chosen.
var promotionTo by remember { mutableStateOf<Int?>(null) }
val current = state
if (current == null) {
ScreenScaffold(title = "Chess", onBack = onExit) { modifier ->
Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") }
}
return
}
val flipped = !seatIsWhite(localSeat)
val fromSelected = selected?.let { from -> legal.filter { it.from == from } }.orEmpty()
val onSquareTapped: (Int) -> Unit = { square ->
val destinations = fromSelected.filter { it.to == square }
when {
destinations.any { it.promotion != null } -> promotionTo = square
destinations.isNotEmpty() -> {
controller.submit(destinations.first())
selected = null
}
// Tapping another of your own pieces switches the selection rather
// than clearing it, which is what a player expects after a misclick.
legal.any { it.from == square } -> selected = square
else -> selected = null
}
}
ScreenScaffold(title = "Chess", onBack = onExit) { modifier ->
BoxWithConstraints(modifier = modifier) {
val wide = maxWidth > maxHeight
val boardSide = if (wide) {
minOf(maxHeight, maxWidth * 0.62f)
} else {
minOf(maxWidth, maxHeight * 0.72f)
}
val board: @Composable () -> Unit = {
Board(
state = current,
legalFromSelected = fromSelected,
selected = selected,
flipped = flipped,
side = boardSide,
onTap = onSquareTapped,
)
}
val panel: @Composable (Modifier) -> Unit = { panelModifier ->
Column(
modifier = panelModifier,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
StatusLine(current, localSeat)
MoveList(current, modifier = Modifier.weight(1f, fill = false))
}
}
if (wide) {
Row(
modifier = 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))
}
}
promotionTo?.let { destination ->
PromotionDialog(
white = current.whiteToMove,
choices = fromSelected.filter { it.to == destination },
onPick = {
controller.submit(it)
promotionTo = null
selected = null
},
onDismiss = { promotionTo = null },
)
}
if (current.phase == ChessPhase.GAME_OVER) {
ResultDialog(current, onExit)
}
}
}
}
@Composable
private fun Board(
state: ChessState,
legalFromSelected: List<ChessMove>,
selected: Int?,
flipped: Boolean,
side: Dp,
onTap: (Int) -> Unit,
) {
val squareSide = side / 8
val destinations = legalFromSelected.map { it.to }.toSet()
val checkedKing = if (ChessRules.inCheck(state)) state.kingSquare(state.whiteToMove) else null
Column(modifier = Modifier.size(side).border(2.dp, DarkSquare)) {
for (row in 0 until 8) {
Row(modifier = Modifier.weight(1f)) {
for (column in 0 until 8) {
val display = row * 8 + column
// Turning the board round is one subtraction, so the rest of
// the screen never has to think about which side is which.
val square = if (flipped) 63 - display else display
Square(
state = state,
square = square,
squareSide = squareSide,
selected = square == selected,
isDestination = square in destinations,
inCheck = square == checkedKing,
modifier = Modifier.weight(1f).fillMaxSize(),
onTap = { onTap(square) },
)
}
}
}
}
}
@Composable
private fun Square(
state: ChessState,
square: Int,
squareSide: Dp,
selected: Boolean,
isDestination: Boolean,
inCheck: Boolean,
modifier: Modifier,
onTap: () -> Unit,
) {
val piece = state.board[square]
val touchedByLastMove =
state.lastMove?.let { square == it.from || square == it.to } == true
Box(
modifier = modifier
.background(if (isLightSquare(square)) LightSquare else DarkSquare)
.then(if (touchedByLastMove) Modifier.background(LastMoveSquare) else Modifier)
.then(if (inCheck) Modifier.background(CheckSquare) else Modifier)
.then(if (selected) Modifier.background(SelectedSquare) else Modifier)
.clickable { onTap() },
contentAlignment = Alignment.Center,
) {
if (piece != null) {
Text(
text = piece.glyph,
fontSize = (squareSide.value * 0.78f).sp,
// The glyphs carry their own colour, but an outline colour keeps
// white pieces visible on the light squares.
color = if (piece.white) Color(0xFFFFFFFF) else Color(0xFF101010),
modifier = Modifier.padding(bottom = squareSide * 0.08f),
)
}
if (isDestination) {
// A dot for an empty square, a ring for something to take.
if (piece == null) {
Box(
modifier = Modifier
.size(squareSide * 0.28f)
.clip(CircleShape)
.background(MoveDot)
)
} else {
Box(
modifier = Modifier
.fillMaxSize()
.padding(squareSide * 0.04f)
.border(squareSide * 0.08f, MoveDot, CircleShape)
)
}
}
// Coordinates on the outer files and ranks, as a printed board has.
if (fileOf(square) == 0) {
Text(
text = "${rankOf(square) + 1}",
fontSize = (squareSide.value * 0.2f).sp,
color = if (isLightSquare(square)) DarkSquare else LightSquare,
modifier = Modifier.align(Alignment.TopStart).padding(1.dp),
)
}
if (rankOf(square) == 0) {
Text(
text = "${'a' + fileOf(square)}",
fontSize = (squareSide.value * 0.2f).sp,
color = if (isLightSquare(square)) DarkSquare else LightSquare,
modifier = Modifier.align(Alignment.BottomEnd).padding(1.dp),
)
}
}
}
@Composable
private fun StatusLine(state: ChessState, localSeat: Int) {
val yourMove = state.turnSeat == localSeat && state.phase == ChessPhase.PLAYING
Column {
Text(
text = state.outcome?.label ?: when {
yourMove && ChessRules.inCheck(state) -> "You are in check"
yourMove -> "Your move"
ChessRules.inCheck(state) -> "Check"
else -> "Thinking…"
},
fontWeight = FontWeight.Bold,
)
Text(
text = "You play ${if (seatIsWhite(localSeat)) "White" else "Black"} · " +
"move ${state.fullmoveNumber}",
style = MaterialTheme.typography.bodySmall,
)
}
}
/** The game score, two half-moves to a line, most recent at the bottom. */
@Composable
private fun MoveList(state: ChessState, 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 PromotionDialog(
white: Boolean,
choices: List<ChessMove>,
onPick: (ChessMove) -> Unit,
onDismiss: () -> Unit,
) {
val order = listOf(PieceKind.QUEEN, PieceKind.ROOK, PieceKind.BISHOP, PieceKind.KNIGHT)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Promote to") },
text = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
order.forEach { kind ->
val move = choices.firstOrNull { it.promotion == kind } ?: return@forEach
Button(onClick = { onPick(move) }, modifier = Modifier.width(64.dp)) {
Text(
text = Piece(kind, white).glyph,
fontSize = 24.sp,
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
@Composable
private fun ResultDialog(state: ChessState, 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.lastMove?.let {
Text(
text = "Last move ${state.moveLog.lastOrNull() ?: squareName(it.to)}",
style = MaterialTheme.typography.bodySmall,
)
}
Text(
text = "${state.moveLog.size} half-moves played.",
style = MaterialTheme.typography.bodySmall,
)
}
},
confirmButton = { TextButton(onClick = onExit) { Text("Back to the menu") } },
dismissButton = { TextButton(onClick = { dismissed = true }) { Text("Look at the board") } },
)
}
@@ -0,0 +1,397 @@
package org.prolibertate.games.ui.game
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
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.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.rememberScrollState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.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.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.crazyeights.CrazyEightsMove
import org.prolibertate.games.game.crazyeights.CrazyEightsPhase
import org.prolibertate.games.game.crazyeights.CrazyEightsState
import org.prolibertate.games.game.crazyeights.DrawCard
import org.prolibertate.games.game.crazyeights.PassTurn
import org.prolibertate.games.game.crazyeights.PlayCard
import org.prolibertate.games.game.crazyeights.crazyEightsPenalty
import org.prolibertate.games.game.crazyeights.isWild
import org.prolibertate.games.net.MatchController
import org.prolibertate.games.ui.ScreenScaffold
import org.prolibertate.games.ui.common.CardBackView
import org.prolibertate.games.ui.common.PlayingCardView
import org.prolibertate.games.ui.theme.FeltGreenDark
/**
* The Crazy 8s table.
*
* The one thing this screen has to get right is that the suit being followed is
* not always the suit on top of the pile — an eight changes it — so the suit in
* force is stated in words rather than left to be read off the card.
*/
@Composable
fun CrazyEightsScreen(
controller: MatchController<CrazyEightsState, CrazyEightsMove>,
localSeat: Int,
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val legal by controller.legalMoves.collectAsState()
// Playing an eight is two decisions: the card, then the suit it turns the
// game to. The card is held here while the suit is chosen.
var pendingWild by remember { mutableStateOf<Card?>(null) }
val current = state
if (current == null) {
ScreenScaffold(title = "Crazy 8s", onBack = onExit) { modifier ->
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
}
ScreenScaffold(title = "Crazy 8s", onBack = onExit) { modifier ->
BoxWithConstraints(modifier = modifier) {
val cardWidth = (minOf(maxWidth, maxHeight) * 0.16f).coerceIn(44.dp, 92.dp)
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ScoreLine(current, localSeat)
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(FeltGreenDark),
contentAlignment = Alignment.Center,
) {
PileArea(current, cardWidth)
}
OpponentCounts(current, localSeat)
if (current.phase == CrazyEightsPhase.PLAYING) {
TurnControls(
state = current,
localSeat = localSeat,
legal = legal,
pendingWild = pendingWild,
onCancelWild = { pendingWild = null },
onMove = { pendingWild = null; controller.submit(it) },
)
}
HandRow(
state = current,
localSeat = localSeat,
legal = legal,
cardWidth = cardWidth,
pendingWild = pendingWild,
onTap = { card ->
if (isWild(card)) {
pendingWild = if (pendingWild == card) null else card
} else {
pendingWild = null
controller.submit(PlayCard(card))
}
},
)
}
if (current.phase != CrazyEightsPhase.PLAYING) {
RoundScoreDialog(
state = current,
localSeat = localSeat,
onContinue = { controller.confirmAdvance() },
onExit = onExit,
)
}
}
}
}
@Composable
private fun ScoreLine(state: CrazyEightsState, localSeat: Int) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column {
Text(
text = "Round ${state.roundNumber + 1} of ${state.options.roundsToPlay}",
fontWeight = FontWeight.Bold,
)
Text(
text = "Your penalty so far: ${state.scores[localSeat]}",
style = MaterialTheme.typography.bodySmall,
)
}
Column(horizontalAlignment = Alignment.End) {
Text("Play ${state.suitInForce.symbol}", fontWeight = FontWeight.Bold)
Text(
text = "${state.stock.size} left in the stock",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
@Composable
private fun PileArea(state: CrazyEightsState, cardWidth: Dp) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "Stock",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary,
)
if (state.stock.isEmpty()) {
Text(
text = "empty",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary,
)
} else {
CardBackView(width = cardWidth)
}
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "Pile",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary,
)
state.topCard?.let { PlayingCardView(card = it, width = cardWidth) }
}
}
// An eight on top says nothing about what may be played next, so the
// suit in force is spelled out under the pile.
Text(
text = "Following ${state.suitInForce.symbol} ${state.suitInForce.name.lowercase()}",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onPrimary,
)
}
}
@Composable
private fun OpponentCounts(state: CrazyEightsState, localSeat: Int) {
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
(0 until state.options.playerCount)
.filter { it != localSeat }
.forEach { seat ->
val count = state.handCounts.getOrElse(seat) { 0 }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "Seat $seat",
style = MaterialTheme.typography.labelSmall,
fontWeight = if (seat == state.turn) FontWeight.Bold else FontWeight.Normal,
)
Row(horizontalArrangement = Arrangement.spacedBy(1.dp)) {
repeat(count.coerceAtMost(8)) { CardBackView(width = 12.dp) }
}
Text("$count", style = MaterialTheme.typography.labelSmall)
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun TurnControls(
state: CrazyEightsState,
localSeat: Int,
legal: List<CrazyEightsMove>,
pendingWild: Card?,
onCancelWild: () -> Unit,
onMove: (CrazyEightsMove) -> Unit,
) {
if (legal.isEmpty()) {
Text("Seat ${state.turn} to play…", style = MaterialTheme.typography.bodySmall)
return
}
if (pendingWild != null) {
val suits = legal.filterIsInstance<PlayCard>()
.filter { it.card == pendingWild }
.mapNotNull { move -> move.nominatedSuit?.let { it to move } }
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Playing ${pendingWild.label} — name the suit", fontWeight = FontWeight.Bold)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
suits.forEach { (suit, move) ->
Button(onClick = { onMove(move) }) {
Text("${suit.symbol} ${suit.name.lowercase()}")
}
}
}
OutlinedButton(onClick = onCancelWild, modifier = Modifier.fillMaxWidth()) {
Text("Pick a different card")
}
}
return
}
val canDraw = legal.contains(DrawCard)
val canPass = legal.contains(PassTurn)
val playable = legal.filterIsInstance<PlayCard>().isNotEmpty()
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = when {
playable -> "Your turn — tap a card"
canDraw -> "Nothing to play"
else -> "Nothing to play and nothing to draw"
},
fontWeight = FontWeight.Bold,
)
if (canDraw) {
Button(onClick = { onMove(DrawCard) }, modifier = Modifier.fillMaxWidth()) {
Text(
if (state.options.drawUntilPlayable) {
"Draw until something fits"
} else {
"Draw a card"
}
)
}
}
if (canPass) {
OutlinedButton(onClick = { onMove(PassTurn) }, modifier = Modifier.fillMaxWidth()) {
Text("Pass")
}
}
}
}
@Composable
private fun HandRow(
state: CrazyEightsState,
localSeat: Int,
legal: List<CrazyEightsMove>,
cardWidth: Dp,
pendingWild: Card?,
onTap: (Card) -> Unit,
) {
val hand = state.hands.getOrNull(localSeat).orEmpty()
val playable = legal.filterIsInstance<PlayCard>().map { it.card }.toSet()
Column {
Text(
text = "Your hand — ${hand.sumOf { crazyEightsPenalty(it) }} in penalties",
style = MaterialTheme.typography.labelLarge,
)
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (hand.isEmpty()) Text("—", style = MaterialTheme.typography.bodySmall)
hand.forEach { card ->
val canPlay = card in playable
PlayingCardView(
card = card,
width = cardWidth,
enabled = canPlay,
selected = card == pendingWild,
caption = if (isWild(card)) "WILD" else null,
onClick = { onTap(card) },
)
}
}
}
}
/** The card at the end of a round: what everyone was caught holding. */
@Composable
private fun RoundScoreDialog(
state: CrazyEightsState,
localSeat: Int,
onContinue: () -> Unit,
onExit: () -> Unit,
) {
var dismissed by remember(state.roundNumber, state.phase) { mutableStateOf(false) }
if (dismissed) return
val finalRound = state.phase == CrazyEightsPhase.GAME_OVER
val standings = (0 until state.options.playerCount).sortedBy { state.scores[it] }
AlertDialog(
onDismissRequest = { },
title = {
Text(
if (finalRound) {
"Final score"
} else {
"Round ${state.roundNumber + 1} of ${state.options.roundsToPlay}"
}
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = state.roundWinner?.let {
if (it == localSeat) "You went out." else "Seat $it went out."
} ?: "The round was blocked — nobody went out.",
style = MaterialTheme.typography.bodyMedium,
)
standings.forEach { seat ->
val you = seat == localSeat
Row(modifier = Modifier.fillMaxWidth()) {
Text(
text = if (you) "You" else "Seat $seat",
modifier = Modifier.weight(1f),
fontWeight = if (you) FontWeight.Bold else FontWeight.Normal,
)
Text("${state.scores[seat]}")
}
}
Text("Lowest penalty wins.", style = MaterialTheme.typography.bodySmall)
}
},
confirmButton = {
TextButton(onClick = {
dismissed = true
if (finalRound) onExit() else onContinue()
}) {
Text(if (finalRound) "Back to the menu" else "Deal the next round")
}
},
)
}
@@ -0,0 +1,414 @@
package org.prolibertate.games.ui.game
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
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.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.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
import kotlinx.coroutines.delay
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.kaiser.FIVE_OF_HEARTS
import org.prolibertate.games.game.kaiser.KaiserMove
import org.prolibertate.games.game.kaiser.KaiserPhase
import org.prolibertate.games.game.kaiser.KaiserState
import org.prolibertate.games.game.kaiser.MakeBid
import org.prolibertate.games.game.kaiser.NameTrump
import org.prolibertate.games.game.kaiser.PassBid
import org.prolibertate.games.game.kaiser.PlayCard
import org.prolibertate.games.game.kaiser.THREE_OF_SPADES
import org.prolibertate.games.game.kaiser.teamOf
import org.prolibertate.games.net.MatchController
import org.prolibertate.games.ui.PrimaryAction
import org.prolibertate.games.ui.ScreenScaffold
import org.prolibertate.games.ui.common.PlayingCardView
import org.prolibertate.games.ui.theme.FeltGreenDark
/**
* The Kaiser table.
*
* Laid out like the Euchre table — partners opposite, each card on the side of
* whoever played it — with the two counters called out wherever they appear,
* since the whole hand is really played for those two cards.
*/
@Composable
fun KaiserScreen(
controller: MatchController<KaiserState, KaiserMove>,
localSeat: Int,
trickHoldMillis: Long,
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val legal by controller.legalMoves.collectAsState()
val notice by controller.notice.collectAsState()
val current = state
if (current == null) {
ScreenScaffold(title = "Kaiser", onBack = onExit) { modifier ->
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
}
ScreenScaffold(title = "Kaiser", onBack = onExit) { modifier ->
BoxWithConstraints(modifier = modifier) {
val compact = maxWidth < 600.dp
val cardWidth = (minOf(maxWidth, maxHeight) * if (compact) 0.15f else 0.11f)
.coerceIn(44.dp, 96.dp)
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ScoreBar(current, localSeat)
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(FeltGreenDark),
contentAlignment = Alignment.Center,
) {
TrickArea(current, localSeat, cardWidth, trickHoldMillis)
}
notice?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
when (current.phase) {
KaiserPhase.BIDDING ->
BiddingControls(current, localSeat, legal) { controller.submit(it) }
KaiserPhase.HAND_OVER -> Text(
text = current.log.lastOrNull().orEmpty(),
style = MaterialTheme.typography.titleSmall,
)
KaiserPhase.GAME_OVER -> {
Text("Game over.", fontWeight = FontWeight.Bold)
PrimaryAction(text = "Back to the menu") { onExit() }
}
else -> Unit
}
HandRow(current, localSeat, legal, cardWidth) { controller.submit(it) }
}
}
}
}
@Composable
private fun ScoreBar(state: KaiserState, localSeat: Int) {
val myTeam = teamOf(localSeat)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(
text = "Us ${state.scores[myTeam]} — Them ${state.scores[1 - myTeam]}",
fontWeight = FontWeight.Bold,
)
Text(
text = "This hand: ${state.handPoints[myTeam]} to us, " +
"${state.handPoints[1 - myTeam]} to them",
style = MaterialTheme.typography.bodySmall,
)
}
Column(horizontalAlignment = Alignment.End) {
val bid = state.highBid
Text(
text = when {
bid == null -> "Bidding"
state.trump == null && state.phase != KaiserPhase.BIDDING -> "No trump"
state.trump != null -> "Trump ${state.trump!!.symbol}"
else -> "Bid ${bid.points}"
},
fontWeight = FontWeight.Bold,
)
if (bid != null) {
val bidder = state.highBidder
Text(
text = "Contract ${bid.points}${if (bid.noTrump) " no trump" else ""}" +
(bidder?.let { " by ${seatLabel(relativePosition(it, localSeat))}" } ?: ""),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
/** Where a seat sits relative to you. Partners sit opposite, so 2 is yours. */
private fun relativePosition(seat: Int, localSeat: Int): Int = ((seat - localSeat) % 4 + 4) % 4
private fun seatLabel(relative: Int): String = when (relative) {
0 -> "You"
1 -> "Left"
2 -> "Partner"
else -> "Right"
}
/** Duration of the sweep towards the winner. */
private const val SWEEP_MILLIS = 450
@Composable
private fun TrickArea(
state: KaiserState,
localSeat: Int,
cardWidth: Dp,
trickHoldMillis: Long,
) {
val sweeping = state.trick.isEmpty() && state.completedTrick.isNotEmpty()
val cardsOnTable = if (sweeping) state.completedTrick else state.trick
var sweepStarted by remember(state.completedTrick) { mutableStateOf(false) }
LaunchedEffect(state.completedTrick) {
if (state.completedTrick.isNotEmpty()) {
delay(trickHoldMillis)
sweepStarted = true
}
}
val sweep by animateFloatAsState(
targetValue = if (sweeping && sweepStarted) 1f else 0f,
animationSpec = tween(durationMillis = SWEEP_MILLIS),
label = "kaiserSweep",
)
val winnerRelative = state.lastTrickWinner?.let { relativePosition(it, localSeat) } ?: 0
val (dirX, dirY) = when (winnerRelative) {
0 -> 0f to 1f
1 -> -1f to 0f
2 -> 0f to -1f
else -> 1f to 0f
}
val travelPx = with(LocalDensity.current) { (cardWidth * 2.5f).toPx() }
Box(modifier = Modifier.fillMaxSize().padding(4.dp)) {
if (cardsOnTable.isEmpty()) {
Text(
text = when {
state.phase == KaiserPhase.BIDDING -> "Bidding"
state.turn == localSeat -> "Lead a card"
else -> "Waiting…"
},
color = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.align(Alignment.Center),
)
}
cardsOnTable.forEach { played ->
val relative = relativePosition(played.seat, localSeat)
val alignment = when (relative) {
0 -> Alignment.BottomCenter
1 -> Alignment.CenterStart
2 -> Alignment.TopCenter
else -> Alignment.CenterEnd
}
val rotation = when (relative) {
1 -> 90f
2 -> 180f
3 -> -90f
else -> 0f
}
Column(
modifier = Modifier
.align(alignment)
.offset {
IntOffset(
x = (dirX * travelPx * sweep).roundToInt(),
y = (dirY * travelPx * sweep).roundToInt(),
)
}
.alpha(1f - sweep),
horizontalAlignment = Alignment.CenterHorizontally,
) {
PlayingCardView(
card = played.card,
width = cardWidth,
caption = counterCaption(played.card),
modifier = Modifier.rotate(rotation),
)
Text(
text = seatLabel(relative),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary,
)
}
}
if (sweeping && state.lastTrickWinner != null) {
Text(
text = "${seatLabel(winnerRelative)} takes it",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.align(Alignment.Center).alpha(1f - sweep),
)
}
if (state.phase == KaiserPhase.PLAYING && state.trick.size < 4) {
val waitingOn = relativePosition(state.turn, localSeat)
if (waitingOn != 0 && state.trick.none { it.seat == state.turn }) {
Text(
text = "${seatLabel(waitingOn)} to play",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.align(Alignment.BottomStart),
)
}
}
}
}
/** The two cards that decide the hand, named on their faces. */
private fun counterCaption(card: Card): String? = when (card) {
FIVE_OF_HEARTS -> "+5"
THREE_OF_SPADES -> "−3"
else -> null
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun BiddingControls(
state: KaiserState,
localSeat: Int,
legal: List<KaiserMove>,
onMove: (KaiserMove) -> Unit,
) {
if (legal.isEmpty()) {
val waitingOn = relativePosition(state.turn, localSeat)
Text("Waiting for ${seatLabel(waitingOn).lowercase()} to bid…")
return
}
val naming = legal.filterIsInstance<NameTrump>()
if (naming.isNotEmpty()) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("You won the bidding — name trump", fontWeight = FontWeight.Bold)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
naming.forEach { move ->
Button(onClick = { onMove(move) }) {
Text(move.suit?.let { "${it.symbol} ${it.name.lowercase()}" } ?: "No trump")
}
}
}
}
return
}
val bids = legal.filterIsInstance<MakeBid>()
val canPass = legal.contains(PassBid)
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = state.highBid?.let { high ->
"Bidding stands at ${high.points}${if (high.noTrump) " no trump" else ""}"
} ?: "Open the bidding",
fontWeight = FontWeight.Bold,
)
if (!canPass) {
Text(
text = "Everyone passed, so as dealer you have to take it.",
style = MaterialTheme.typography.bodySmall,
)
}
// Wraps rather than scrolls, so no bid can be pushed off the edge.
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
bids.forEach { move ->
Button(onClick = { onMove(move) }) {
Text("${move.bid.points}${if (move.bid.noTrump) " NT" else ""}")
}
}
}
Text(
text = "A no-trump contract pays double, and costs double if it goes down.",
style = MaterialTheme.typography.bodySmall,
)
if (canPass) {
OutlinedButton(onClick = { onMove(PassBid) }, modifier = Modifier.fillMaxWidth()) {
Text("Pass")
}
}
}
}
@Composable
private fun HandRow(
state: KaiserState,
seat: Int,
legal: List<KaiserMove>,
cardWidth: Dp,
onPlay: (KaiserMove) -> Unit,
) {
val hand = state.hands.getOrNull(seat).orEmpty()
val playable = legal.filterIsInstance<PlayCard>().map { it.card }.toSet()
Column {
Text(
text = if (playable.isNotEmpty()) "Your turn" else "Your hand",
style = MaterialTheme.typography.labelLarge,
)
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
if (hand.isEmpty()) {
Box(modifier = Modifier.size(cardWidth)) {
Text("—", style = MaterialTheme.typography.bodySmall)
}
}
hand.forEach { card ->
val canPlay = card in playable
PlayingCardView(
card = card,
width = cardWidth,
enabled = canPlay,
caption = counterCaption(card),
onClick = { if (canPlay) onPlay(PlayCard(card)) },
)
}
}
}
}
@@ -0,0 +1,473 @@
package org.prolibertate.games.ui.game
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
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.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.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.roundToInt
import kotlin.math.sin
import kotlinx.coroutines.delay
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.wizard.ChooseTrump
import org.prolibertate.games.game.wizard.MakeBid
import org.prolibertate.games.game.wizard.PlayCard
import org.prolibertate.games.game.wizard.WizardMove
import org.prolibertate.games.game.wizard.WizardPhase
import org.prolibertate.games.game.wizard.WizardState
import org.prolibertate.games.game.wizard.isJester
import org.prolibertate.games.game.wizard.isWizardCard
import org.prolibertate.games.net.MatchController
import org.prolibertate.games.ui.ScreenScaffold
import org.prolibertate.games.ui.common.PlayingCardView
import org.prolibertate.games.ui.theme.FeltGreenDark
/**
* The Wizard table.
*
* Three to six can play, so seats are placed round a circle rather than at four
* fixed edges: you are always at the bottom and everyone else is spaced out from
* there, which keeps the same layout code working at every table size.
*/
@Composable
fun WizardScreen(
controller: MatchController<WizardState, WizardMove>,
localSeat: Int,
trickHoldMillis: Long,
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val legal by controller.legalMoves.collectAsState()
val notice by controller.notice.collectAsState()
val current = state
if (current == null) {
ScreenScaffold(title = "Wizard", onBack = onExit) { modifier ->
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
}
ScreenScaffold(title = "Wizard", onBack = onExit) { modifier ->
BoxWithConstraints(modifier = modifier) {
val cardWidth = (minOf(maxWidth, maxHeight) * 0.14f).coerceIn(40.dp, 88.dp)
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
RoundBar(current, localSeat)
BidTally(current, localSeat)
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(FeltGreenDark),
) {
TrickArea(current, localSeat, cardWidth, trickHoldMillis)
}
notice?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
if (current.phase == WizardPhase.BIDDING) {
BiddingControls(current, localSeat, legal) { controller.submit(it) }
}
HandRow(current, localSeat, legal, cardWidth) { controller.submit(it) }
}
if (current.phase == WizardPhase.ROUND_OVER || current.phase == WizardPhase.GAME_OVER) {
RoundScoreDialog(
state = current,
localSeat = localSeat,
onContinue = { controller.confirmAdvance() },
onExit = onExit,
)
}
}
}
}
@Composable
private fun RoundBar(state: WizardState, localSeat: Int) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(
text = "Round ${state.round + 1} of ${state.options.totalRounds()}" +
" — ${state.cardsThisRound} card(s)",
fontWeight = FontWeight.Bold,
)
Text(
text = "Your score: ${state.scores[localSeat]}",
style = MaterialTheme.typography.bodySmall,
)
}
Column(horizontalAlignment = Alignment.End) {
Text(
text = state.trump?.let { "Trump ${it.symbol}" } ?: "No trump",
fontWeight = FontWeight.Bold,
)
state.trumpCard?.let {
Text("Turned ${it.label}", style = MaterialTheme.typography.bodySmall)
}
}
}
}
/** Everyone's bid against what they have actually taken — the whole game in a row. */
@Composable
private fun BidTally(state: WizardState, localSeat: Int) {
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
(0 until state.options.playerCount).forEach { seat ->
val bid = state.bids[seat]
val won = state.tricksWon[seat]
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = if (seat == localSeat) "You" else seatLabel(seat, localSeat),
style = MaterialTheme.typography.labelSmall,
fontWeight = if (seat == state.turn) FontWeight.Bold else FontWeight.Normal,
)
Text(
text = bid?.let { "$won / $it" } ?: "—",
style = MaterialTheme.typography.labelLarge,
// Green while the bid is still on, red once it cannot be made.
color = when {
bid == null -> MaterialTheme.colorScheme.onSurface
won == bid -> MaterialTheme.colorScheme.primary
won > bid -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.onSurface
},
)
}
}
}
}
/** Seats are named by how far round the table they sit from you. */
private fun seatLabel(seat: Int, localSeat: Int): String =
if (seat == localSeat) "You" else "Seat $seat"
/** Duration of the sweep towards the winner. */
private const val SWEEP_MILLIS = 450
@Composable
private fun TrickArea(
state: WizardState,
localSeat: Int,
cardWidth: Dp,
trickHoldMillis: Long,
) {
val players = state.options.playerCount
val sweeping = state.trick.isEmpty() && state.completedTrick.isNotEmpty()
val cardsOnTable = if (sweeping) state.completedTrick else state.trick
var sweepStarted by remember(state.completedTrick) { mutableStateOf(false) }
LaunchedEffect(state.completedTrick) {
if (state.completedTrick.isNotEmpty()) {
delay(trickHoldMillis)
sweepStarted = true
}
}
val sweep by animateFloatAsState(
targetValue = if (sweeping && sweepStarted) 1f else 0f,
animationSpec = tween(durationMillis = SWEEP_MILLIS),
label = "wizardSweep",
)
val winnerRelative = state.lastTrickWinner
?.let { ((it - localSeat) % players + players) % players }
?: 0
val winnerAngle = 2.0 * PI * winnerRelative / players
BoxWithConstraints(modifier = Modifier.fillMaxSize().padding(4.dp)) {
// Cards sit on a circle that fits inside whatever space the table has.
val radius = (minOf(maxWidth, maxHeight) / 2 - cardWidth * 0.7f).coerceAtLeast(0.dp)
val radiusPx = with(LocalDensity.current) { radius.toPx() }
val travelPx = with(LocalDensity.current) { (cardWidth * 2f).toPx() }
if (cardsOnTable.isEmpty()) {
Text(
text = when {
state.phase == WizardPhase.BIDDING -> "Bidding"
state.turn == localSeat -> "Lead a card"
else -> "Waiting…"
},
color = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.align(Alignment.Center),
)
}
cardsOnTable.forEach { played ->
val relative = ((played.seat - localSeat) % players + players) % players
val angle = 2.0 * PI * relative / players
// Relative seat 0 is you, at the bottom; the rest run clockwise.
val x = -sin(angle) * radiusPx
val y = cos(angle) * radiusPx
// Turn each card to face whoever played it.
val rotation = (relative * 360f / players)
Column(
modifier = Modifier
.align(Alignment.Center)
.offset {
IntOffset(
x = (x + -sin(winnerAngle) * travelPx * sweep).roundToInt(),
y = (y + cos(winnerAngle) * travelPx * sweep).roundToInt(),
)
}
.alpha(1f - sweep),
horizontalAlignment = Alignment.CenterHorizontally,
) {
PlayingCardView(
card = played.card,
width = cardWidth,
caption = specialCaption(played.card),
modifier = Modifier.rotate(rotation),
)
Text(
text = seatLabel(played.seat, localSeat),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary,
)
}
}
if (sweeping && state.lastTrickWinner != null) {
Text(
text = "${seatLabel(state.lastTrickWinner!!, localSeat)} takes it",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.align(Alignment.Center).alpha(1f - sweep),
)
}
}
}
/**
* Wizards and jesters carry no rank or suit anyone can read off the face, so
* they say what they do instead.
*/
private fun specialCaption(card: Card): String? = when {
isWizardCard(card) -> "WIZARD"
isJester(card) -> "JESTER"
else -> null
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun BiddingControls(
state: WizardState,
localSeat: Int,
legal: List<WizardMove>,
onMove: (WizardMove) -> Unit,
) {
if (legal.isEmpty()) {
Text(
text = "Waiting for ${seatLabel(state.turn, localSeat).lowercase()} to bid…",
style = MaterialTheme.typography.bodySmall,
)
return
}
val trumpChoices = legal.filterIsInstance<ChooseTrump>()
if (trumpChoices.isNotEmpty()) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("A wizard was turned — you name trump", fontWeight = FontWeight.Bold)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
trumpChoices.forEach { move ->
Button(onClick = { onMove(move) }) {
Text("${move.suit.symbol} ${move.suit.name.lowercase()}")
}
}
}
}
return
}
val bids = legal.filterIsInstance<MakeBid>()
val bidSoFar = state.bids.filterNotNull().sum()
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = "How many of the ${state.cardsThisRound} tricks will you take?",
fontWeight = FontWeight.Bold,
)
Text(
text = "Bid so far: $bidSoFar. Hit your bid exactly for 20 plus 10 a trick; " +
"miss it and it costs 10 for every trick either way.",
style = MaterialTheme.typography.bodySmall,
)
if (state.options.screwTheDealer && state.turn == state.dealer) {
Text(
text = "Screw the dealer: you may not make the bids add up.",
style = MaterialTheme.typography.bodySmall,
)
}
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
bids.forEach { move ->
Button(onClick = { onMove(move) }) { Text("${move.tricks}") }
}
}
}
}
@Composable
private fun HandRow(
state: WizardState,
seat: Int,
legal: List<WizardMove>,
cardWidth: Dp,
onPlay: (WizardMove) -> Unit,
) {
val hand = state.hands.getOrNull(seat).orEmpty()
val playable = legal.filterIsInstance<PlayCard>().map { it.card }.toSet()
Column {
Text(
text = if (playable.isNotEmpty()) "Your turn" else "Your hand",
style = MaterialTheme.typography.labelLarge,
)
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
if (hand.isEmpty()) {
Box(modifier = Modifier.size(cardWidth)) {
Text("—", style = MaterialTheme.typography.bodySmall)
}
}
hand.forEach { card ->
val canPlay = card in playable
PlayingCardView(
card = card,
width = cardWidth,
enabled = canPlay,
caption = specialCaption(card),
onClick = { if (canPlay) onPlay(PlayCard(card)) },
)
}
}
}
}
/** Bids against tricks taken, and what that was worth, before the next deal. */
@Composable
private fun RoundScoreDialog(
state: WizardState,
localSeat: Int,
onContinue: () -> Unit,
onExit: () -> Unit,
) {
var dismissed by remember(state.round, state.phase) { mutableStateOf(false) }
if (dismissed) return
val finalRound = state.phase == WizardPhase.GAME_OVER
val standings = (0 until state.options.playerCount).sortedByDescending { state.scores[it] }
AlertDialog(
onDismissRequest = { },
title = {
Text(
if (finalRound) {
"Final score"
} else {
"Round ${state.round + 1} of ${state.options.totalRounds()}"
}
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(modifier = Modifier.fillMaxWidth()) {
Text("Player", modifier = Modifier.weight(1f), fontWeight = FontWeight.Bold)
Text("Bid", fontWeight = FontWeight.Bold)
Text(" Took", fontWeight = FontWeight.Bold)
Text(" Round", fontWeight = FontWeight.Bold)
Text(" Total", fontWeight = FontWeight.Bold)
}
standings.forEach { seat ->
val you = seat == localSeat
val weight = if (you) FontWeight.Bold else FontWeight.Normal
Row(modifier = Modifier.fillMaxWidth()) {
Text(
text = if (you) "You" else "Seat $seat",
modifier = Modifier.weight(1f),
fontWeight = weight,
)
Text("${state.bids[seat] ?: 0}")
Text(" ${state.tricksWon[seat]}")
Text(" ${state.roundScores[seat]}")
Text(" ${state.scores[seat]}", fontWeight = weight)
}
}
Text(
text = if (finalRound) {
"Highest score wins — seat ${standings.first()} takes it."
} else {
"Highest score wins."
},
style = MaterialTheme.typography.bodySmall,
)
}
},
confirmButton = {
TextButton(onClick = {
dismissed = true
if (finalRound) onExit() else onContinue()
}) {
Text(if (finalRound) "Back to the menu" else "Deal the next round")
}
},
)
}
@@ -55,6 +55,10 @@ class CatalogTest {
GameCatalog.SEQUENCE, GameCatalog.SEQUENCE,
GameCatalog.PRESIDENT, GameCatalog.PRESIDENT,
GameCatalog.GOLF, GameCatalog.GOLF,
GameCatalog.KAISER,
GameCatalog.CRAZY_EIGHTS,
GameCatalog.WIZARD,
GameCatalog.CHESS,
), ),
GameCatalog.playable.map { it.id }.toSet(), GameCatalog.playable.map { it.id }.toSet(),
) )
@@ -0,0 +1,131 @@
package org.prolibertate.games.game.chess
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ChessAiTest {
private fun position(fen: String, level: ChessLevel = ChessLevel.CLUB) =
ChessRules.fromPosition(ChessOptions(level = level), fen)
private fun ChessAi.moveIn(state: ChessState): ChessMove =
chooseMove(state, state.turnSeat, ChessRules.legalMoves(state))
@Test
fun `it only ever returns a legal move`() {
val ai = ChessAi()
var state = ChessRules.fromPosition(ChessOptions(), START_FEN)
repeat(30) {
if (ChessRules.isFinished(state)) return
val legal = ChessRules.legalMoves(state)
val move = ai.moveIn(state)
assertTrue("$move is not legal in ${state.toFen()}", legal.contains(move))
state = ChessRules.applyMove(state, state.turnSeat, move)
}
}
@Test
fun `it takes a piece that is hanging`() {
// The black queen on d5 is undefended and the rook on d1 sees it.
val state = position("4k3/8/8/3q4/8/8/8/3RK3 w - - 0 1")
assertEquals("d1d5", ChessAi(ChessLevel.STRONG).moveIn(state).toString())
}
@Test
fun `it does not give a piece away for nothing`() {
// Rb1 and Rb7 are both available; Rb7 hangs the rook to the king.
val state = position("8/1k6/8/8/8/8/8/1R2K3 w - - 0 1")
val move = ChessAi(ChessLevel.STRONG).moveIn(state)
assertTrue("played $move into the king", move.toString() != "b1b7")
}
@Test
fun `it finds mate in one`() {
val state = position("6k1/5ppp/8/8/8/8/8/R5K1 w - - 0 1")
assertEquals("a1a8", ChessAi(ChessLevel.STRONG).moveIn(state).toString())
}
@Test
fun `it gets out of check rather than ignoring it`() {
val state = position("4k3/8/8/8/8/8/4r3/4K3 w - - 0 1")
val move = ChessAi().moveIn(state)
val after = ChessRules.applyMove(state, state.turnSeat, move)
assertTrue("still in check after $move", !ChessRules.inCheck(after.board, white = true))
}
@Test
fun `it promotes to a queen when there is nothing to fear`() {
val state = position("4k3/P7/8/8/8/8/8/4K3 w - - 0 1")
val move = ChessAi(ChessLevel.STRONG).moveIn(state)
assertEquals(PieceKind.QUEEN, move.promotion)
}
/** The same position with the colours swapped and the board turned round. */
private fun mirrored(state: ChessState): ChessState {
val board = arrayOfNulls<Piece>(64)
for (square in 0 until 64) {
state.board[square]?.let { board[square xor 56] = it.copy(white = !it.white) }
}
return state.copy(
board = board.toList(),
whiteToMove = !state.whiteToMove,
castling = CastlingRights(
whiteKingSide = state.castling.blackKingSide,
whiteQueenSide = state.castling.blackQueenSide,
blackKingSide = state.castling.whiteKingSide,
blackQueenSide = state.castling.whiteQueenSide,
),
enPassant = state.enPassant?.let { it xor 56 },
)
}
@Test
fun `the evaluation is symmetric`() {
// A position and its mirror image must score identically from the side
// to move, or the search believes one colour is better simply for being
// that colour — and it will then play for it.
listOf(
"r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 4 4",
"r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1",
"8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1",
"4k3/8/8/8/8/8/8/3RK3 w - - 0 1",
).forEach { fen ->
val state = position(fen)
assertEquals(fen, ChessAi().evaluate(state), ChessAi().evaluate(mirrored(state)))
}
}
@Test
fun `two engines play a whole game without an illegal move`() {
val ai = ChessAi(ChessLevel.CASUAL)
var state = ChessRules.fromPosition(ChessOptions(level = ChessLevel.CASUAL), START_FEN)
var plies = 0
while (!ChessRules.isFinished(state) && plies++ < 220) {
val legal = ChessRules.legalMoves(state)
val move = ai.moveIn(state)
assertTrue("illegal $move", legal.contains(move))
state = ChessRules.applyMove(state, state.turnSeat, move)
}
// Either somebody was mated or the position ran into one of the draw
// rules; both are results. What matters is that it never got stuck.
assertTrue(
"no result and no legal continuation",
ChessRules.isFinished(state) || ChessRules.legalMoves(state).isNotEmpty(),
)
}
@Test
fun `a move takes a reasonable amount of time at every level`() {
// Rough guard rather than a benchmark: a phone should not sit thinking
// for a quarter of a minute, and the node budget is what enforces it.
val opening = "r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 4 4"
ChessLevel.entries.forEach { level ->
val state = position(opening, level)
val started = System.nanoTime()
ChessAi(level).moveIn(state)
val millis = (System.nanoTime() - started) / 1_000_000
assertTrue("$level took ${millis}ms", millis < 15_000)
}
}
}
@@ -0,0 +1,417 @@
package org.prolibertate.games.game.chess
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
import kotlin.random.Random
class ChessRulesTest {
private val json = Json { encodeDefaults = true }
private fun config(options: ChessOptions = ChessOptions()) = TableConfig(
gameId = "chess",
seats = (0 until 2).map {
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
},
optionsJson = json.encodeToString(options),
seed = 1L,
)
private fun position(fen: String, options: ChessOptions = ChessOptions()) =
ChessRules.fromPosition(options, fen)
private fun ChessState.moveTo(notation: String): ChessState {
val move = ChessRules.legalMoves(this).firstOrNull { it.toString() == notation }
assertNotNull("$notation is not legal in ${toFen()}", move)
return ChessRules.applyMove(this, turnSeat, move!!)
}
// -- Squares and FEN ----------------------------------------------------
@Test
fun `squares are numbered the way a FEN reads`() {
assertEquals(0, squareFromName("a8"))
assertEquals(7, squareFromName("h8"))
assertEquals(56, squareFromName("a1"))
assertEquals(63, squareFromName("h1"))
(0 until 64).forEach { assertEquals(it, squareFromName(squareName(it))) }
}
@Test
fun `a1 is dark and h1 is light`() {
assertFalse(isLightSquare(squareFromName("a1")))
assertTrue(isLightSquare(squareFromName("h1")))
assertTrue(isLightSquare(squareFromName("e4")))
}
@Test
fun `the opening array round trips through FEN`() {
val state = ChessRules.initialState(config())
assertEquals(START_FEN, state.toFen())
assertEquals(32, state.board.count { it != null })
assertEquals("white starts", 0, state.turnSeat)
}
@Test
fun `every published test position parses back to itself`() {
PERFT_POSITIONS.forEach { (fen, _) ->
assertEquals(fen, position(fen).toFen())
}
}
// -- Perft --------------------------------------------------------------
//
// These counts are published and exact. A generator that matches them has
// essentially nowhere left to hide a rule it gets wrong: the positions are
// chosen to exercise castling through check, en passant that would expose
// a king, promotion with capture, and pins.
@Test
fun `perft matches the published counts from the opening array`() {
val state = ChessRules.initialState(config())
assertEquals(20L, ChessRules.perft(state, 1))
assertEquals(400L, ChessRules.perft(state, 2))
assertEquals(8_902L, ChessRules.perft(state, 3))
assertEquals(197_281L, ChessRules.perft(state, 4))
}
@Test
fun `perft matches the published counts for the standard test positions`() {
PERFT_POSITIONS.forEach { (fen, counts) ->
val state = position(fen)
counts.forEachIndexed { index, expected ->
assertEquals(
"perft(${index + 1}) for $fen",
expected,
ChessRules.perft(state, index + 1),
)
}
}
}
// -- Individual rules ---------------------------------------------------
@Test
fun `a pawn on its home rank may step one or two`() {
val state = ChessRules.initialState(config())
val fromE2 = ChessRules.legalMoves(state).filter { squareName(it.from) == "e2" }
assertEquals(setOf("e2e3", "e2e4"), fromE2.map { it.toString() }.toSet())
}
@Test
fun `a double step can be answered en passant, and only immediately`() {
var state = position("4k3/8/8/8/4p3/8/3P4/4K3 w - - 0 1")
state = state.moveTo("d2d4")
assertEquals("the skipped square is the target", "d3", squareName(state.enPassant!!))
val enPassant = ChessRules.legalMoves(state).first { it.toString() == "e4d3" }
val after = ChessRules.applyMove(state, state.turnSeat, enPassant)
assertNull("the pawn that stepped two is gone", after.board[squareFromName("d4")])
assertNotNull("and the capturer stands behind it", after.board[squareFromName("d3")])
// Waiting a move loses the right.
var later = position("4k3/8/8/8/4p3/8/3P4/4K3 w - - 0 1").moveTo("d2d4")
later = later.moveTo("e8d8")
later = later.moveTo("e1d1")
assertTrue(ChessRules.legalMoves(later).none { it.toString() == "e4d3" })
}
@Test
fun `en passant is refused when it would expose the king`() {
// White king on e5, black rook on h5: taking en passant clears two
// pawns off the fifth rank at once and hangs the king.
val state = position("8/8/8/K1Pp3r/8/8/8/4k3 w - d6 0 1")
assertTrue(
"c5d6 must not be legal",
ChessRules.legalMoves(state).none { it.toString() == "c5d6" },
)
}
@Test
fun `a pawn reaching the last rank offers all four promotions`() {
val state = position("4k3/P7/8/8/8/8/8/4K3 w - - 0 1")
val promotions = ChessRules.legalMoves(state)
.filter { squareName(it.from) == "a7" }
.mapNotNull { it.promotion }
assertEquals(
setOf(PieceKind.QUEEN, PieceKind.ROOK, PieceKind.BISHOP, PieceKind.KNIGHT),
promotions.toSet(),
)
val queened = state.moveTo("a7a8q")
assertEquals(Piece(PieceKind.QUEEN, white = true), queened.board[squareFromName("a8")])
}
@Test
fun `castling moves the rook and gives up both rights`() {
var state = position("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1")
state = state.moveTo("e1g1")
assertEquals(Piece(PieceKind.KING, true), state.board[squareFromName("g1")])
assertEquals("the rook jumps over", Piece(PieceKind.ROOK, true), state.board[squareFromName("f1")])
assertNull(state.board[squareFromName("h1")])
assertFalse(state.castling.whiteKingSide)
assertFalse(state.castling.whiteQueenSide)
assertTrue("black keeps its own", state.castling.blackKingSide)
val queenSide = position("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1").moveTo("e1c1")
assertEquals(Piece(PieceKind.ROOK, true), queenSide.board[squareFromName("d1")])
}
@Test
fun `you may not castle out of, through, or into check`() {
// Rook on e8 attacks the king's own square.
assertTrue(
"out of check",
ChessRules.legalMoves(position("4r3/8/8/8/8/8/8/R3K2R w KQ - 0 1"))
.none { it.toString() == "e1g1" || it.toString() == "e1c1" },
)
// Rook on f8 attacks the square the king crosses.
assertTrue(
"through check",
ChessRules.legalMoves(position("5r2/8/8/8/8/8/8/R3K2R w KQ - 0 1"))
.none { it.toString() == "e1g1" },
)
// Rook on g8 attacks the square the king lands on.
assertTrue(
"into check",
ChessRules.legalMoves(position("6r1/8/8/8/8/8/8/R3K2R w KQ - 0 1"))
.none { it.toString() == "e1g1" },
)
// The b-file square may be attacked; the king does not cross it.
assertTrue(
"b1 attacked is still fine",
ChessRules.legalMoves(position("1r6/8/8/8/8/8/8/R3K2R w KQ - 0 1"))
.any { it.toString() == "e1c1" },
)
}
@Test
fun `castling rights go when a rook moves or is taken on its corner`() {
val moved = position("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1").moveTo("a1b1")
assertFalse(moved.castling.whiteQueenSide)
assertTrue(moved.castling.whiteKingSide)
// White's rook takes on a8, so Black loses the queen's side.
val taken = position("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1")
.moveTo("a1a8")
assertFalse("captured rook loses its right", taken.castling.blackQueenSide)
assertTrue(taken.castling.blackKingSide)
}
@Test
fun `a pinned piece may not step off the line`() {
// The knight on e2 is pinned against the king by the rook on e8.
val state = position("4r3/8/8/8/8/8/4N3/4K3 w - - 0 1")
assertTrue(
ChessRules.legalMoves(state).none { squareName(it.from) == "e2" },
)
}
@Test
fun `in check you must answer the check`() {
val state = position("4k3/8/8/8/8/8/4r3/4K3 w - - 0 1")
assertTrue(ChessRules.inCheck(state))
val moves = ChessRules.legalMoves(state).map { it.toString() }.toSet()
// Take the rook, or step off the file. Nothing else.
assertEquals(setOf("e1e2", "e1d1", "e1f1"), moves)
}
// -- Endings ------------------------------------------------------------
@Test
fun `the back rank mate ends the game`() {
val state = position("6k1/5ppp/8/8/8/8/8/R5K1 w - - 0 1").moveTo("a1a8")
assertTrue(ChessRules.isFinished(state))
assertEquals(ChessOutcome.WHITE_WINS, state.outcome)
assertEquals("recorded as mate", "Ra8#", state.moveLog.last())
assertNull("nobody is on the clock", ChessRules.currentSeat(state))
}
@Test
fun `fools mate is mate`() {
var state = ChessRules.initialState(config())
state = state.moveTo("f2f3")
state = state.moveTo("e7e5")
state = state.moveTo("g2g4")
state = state.moveTo("d8h4")
assertEquals(ChessOutcome.BLACK_WINS, state.outcome)
assertEquals("Qh4#", state.moveLog.last())
}
@Test
fun `a king with no move and no check is stalemate`() {
// Qf7 takes g7, g8 and h7 away without touching h8.
val state = position("7k/8/6Q1/8/8/8/8/K7 w - - 0 1").moveTo("g6f7")
assertEquals(ChessOutcome.STALEMATE, state.outcome)
assertTrue(ChessRules.isFinished(state))
}
@Test
fun `bare kings cannot mate and the game is drawn`() {
val state = position("4k3/8/8/8/8/8/4r3/4K3 w - - 0 1").moveTo("e1e2")
assertEquals(ChessOutcome.INSUFFICIENT_MATERIAL, state.outcome)
}
@Test
fun `what counts as too little material`() {
fun material(fen: String) = ChessRules.insufficientMaterial(parseFen(fen).board)
assertTrue("bare kings", material("4k3/8/8/8/8/8/8/4K3 w - - 0 1"))
assertTrue("one knight", material("4k3/8/8/8/8/8/8/3NK3 w - - 0 1"))
assertTrue("one bishop", material("4k3/8/8/8/8/8/8/3BK3 w - - 0 1"))
// Both bishops on dark squares: nothing can ever be forced.
assertTrue("same-colour bishops", material("2b1k3/8/8/8/8/8/8/3BK3 w - - 0 1"))
assertFalse("opposite-colour bishops", material("3bk3/8/8/8/8/8/8/3BK3 w - - 0 1"))
assertFalse("a rook is enough", material("4k3/8/8/8/8/8/8/3RK3 w - - 0 1"))
assertFalse("a pawn can promote", material("4k3/8/8/8/8/8/3P4/4K3 w - - 0 1"))
assertFalse("two knights are not automatic", material("4k3/8/8/8/8/8/8/2NNK3 w - - 0 1"))
}
@Test
fun `the fifty-move rule ends it, and can be switched off`() {
val fen = "4k3/8/8/8/8/8/8/R3K3 w - - 99 60"
val drawn = position(fen).moveTo("a1a2")
assertEquals(ChessOutcome.FIFTY_MOVE, drawn.outcome)
val played = position(fen, ChessOptions(fiftyMoveRule = false)).moveTo("a1a2")
assertNull("the toggle keeps the game alive", played.outcome)
}
@Test
fun `a capture or a pawn move resets the clock`() {
val state = position("4k3/8/8/8/8/8/3P4/4K3 w - - 40 60").moveTo("d2d4")
assertEquals(0, state.halfmoveClock)
}
@Test
fun `the same position three times is a draw`() {
// Both kings walk back and forth; the third repetition ends it.
var state = position("4k3/8/8/8/8/8/8/R3K3 w - - 0 1")
var guard = 0
while (!ChessRules.isFinished(state) && guard++ < 40) {
val square = if (guard % 4 == 1 || guard % 4 == 2) {
if (state.whiteToMove) "a1b1" else "e8d8"
} else {
if (state.whiteToMove) "b1a1" else "d8e8"
}
state = state.moveTo(square)
}
assertEquals(ChessOutcome.REPETITION, state.outcome)
}
// -- Notation -----------------------------------------------------------
@Test
fun `the move log reads as algebraic notation`() {
var state = ChessRules.initialState(config())
state = state.moveTo("e2e4")
state = state.moveTo("e7e5")
state = state.moveTo("g1f3")
state = state.moveTo("b8c6")
state = state.moveTo("f1b5")
assertEquals(listOf("e4", "e5", "Nf3", "Nc6", "Bb5"), state.moveLog)
}
@Test
fun `captures, castling and promotion are all written out`() {
val taken = position("4k3/8/8/3p4/4P3/8/8/4K3 w - - 0 1").moveTo("e4d5")
assertEquals("exd5", taken.moveLog.last())
val castled = position("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1").moveTo("e1g1")
assertEquals("O-O", castled.moveLog.last())
val long = position("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1").moveTo("e1c1")
assertEquals("O-O-O", long.moveLog.last())
val promoted = position("4k3/P7/8/8/8/8/8/4K3 w - - 0 1").moveTo("a7a8r")
assertEquals("a8=R+", promoted.moveLog.last())
}
@Test
fun `two pieces reaching the same square are told apart`() {
// Knights on c3 and g1 both reach e2: the file separates them.
val byFile = position("4k3/8/8/8/8/2N5/8/4K1N1 w - - 0 1").moveTo("c3e2")
assertEquals("Nce2", byFile.moveLog.last())
// Rooks on a1 and a5 both reach a3, sharing a file: the rank separates them.
val byRank = position("4k3/8/8/R7/8/8/8/R3K3 w - - 0 1").moveTo("a1a3")
assertEquals("R1a3", byRank.moveLog.last())
}
// -- Playing it out -----------------------------------------------------
@Test
fun `random legal play always reaches a result`() {
repeat(12) { iteration ->
val random = Random(iteration.toLong())
var state = ChessRules.initialState(config())
var guard = 0
while (!ChessRules.isFinished(state) && guard++ < 1_000) {
val seat = ChessRules.currentSeat(state)!!
val legal = ChessRules.legalMoves(state, seat)
assertTrue("no legal move but the game is not over", legal.isNotEmpty())
state = ChessRules.applyMove(state, seat, legal[random.nextInt(legal.size)])
}
assertTrue("game $iteration never ended", ChessRules.isFinished(state))
assertNotNull(state.outcome)
}
}
@Test
fun `the wrong seat cannot move`() {
val state = ChessRules.initialState(config())
assertTrue(ChessRules.legalMoves(state, BLACK_SEAT).isEmpty())
val move = ChessRules.legalMoves(state, WHITE_SEAT).first()
runCatching { ChessRules.applyMove(state, BLACK_SEAT, move) }
.onSuccess { throw AssertionError("Black was allowed to play White's move") }
}
// -- Wire format --------------------------------------------------------
@Test
fun `both sides see the same board`() {
val state = ChessRules.initialState(config())
assertEquals(state, ChessRules.viewFor(state, WHITE_SEAT))
assertEquals(state, ChessRules.viewFor(state, BLACK_SEAT))
}
@Test
fun `state and moves survive a round trip`() {
val state = ChessRules.initialState(config()).moveTo("e2e4")
assertEquals(state, ChessRules.decodeState(ChessRules.encodeState(state)))
listOf(
ChessMove(squareFromName("e2"), squareFromName("e4")),
ChessMove(squareFromName("a7"), squareFromName("a8"), PieceKind.QUEEN),
).forEach { assertEquals(it, ChessRules.decodeMove(ChessRules.encodeMove(it))) }
}
private companion object {
/**
* The standard perft suite. Depths are kept where they run in a second
* or two apiece — deep enough that castling, en passant, promotion and
* pin handling are all exercised many thousands of times over.
*/
val PERFT_POSITIONS: List<Pair<String, List<Long>>> = listOf(
// Kiwipete: castling both ways, pins, and a board full of tactics.
"r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1" to
listOf(48L, 2_039L, 97_862L),
// A rook-and-pawn endgame that catches en passant errors.
"8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1" to
listOf(14L, 191L, 2_812L, 43_238L),
// Promotion with capture, from both sides.
"r3k2r/Pppp1ppp/1b3nbN/nP6/BBP1P3/q4N2/Pp1P2PP/R2Q1RK1 w kq - 0 1" to
listOf(6L, 264L, 9_467L),
// A cramped position where most moves are illegal.
"rnbq1k1r/pp1Pbppp/2p5/8/2B5/8/PPP1NnPP/RNBQK2R w KQ - 1 8" to
listOf(44L, 1_486L, 62_379L),
)
}
}
@@ -0,0 +1,337 @@
package org.prolibertate.games.game.crazyeights
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
import kotlin.random.Random
class CrazyEightsRulesTest {
private val json = Json { encodeDefaults = true }
private fun config(
options: CrazyEightsOptions = CrazyEightsOptions(),
seed: Long = 21L,
) = TableConfig(
gameId = "crazy8s",
seats = (0 until options.playerCount).map {
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
},
optionsJson = json.encodeToString(options),
seed = seed,
)
private fun c(rank: Rank, suit: Suit) = Card(rank, suit)
private fun playingState(
hands: List<List<Card>>,
top: Card,
suitInForce: Suit = top.suit,
stock: List<Card> = emptyList(),
turn: Int = 0,
options: CrazyEightsOptions = CrazyEightsOptions(playerCount = hands.size),
drawnThisTurn: Int = 0,
) = CrazyEightsState(
options = options,
seed = 1L,
roundNumber = 0,
hands = hands,
handCounts = hands.map { it.size },
stock = stock,
discard = listOf(top),
suitInForce = suitInForce,
turn = turn,
drawnThisTurn = drawnThisTurn,
scores = List(hands.size) { 0 },
roundWinner = null,
phase = CrazyEightsPhase.PLAYING,
log = emptyList(),
)
// -- Matching -----------------------------------------------------------
@Test
fun `a card matches on suit or on rank`() {
val top = c(Rank.SEVEN, Suit.HEARTS)
assertTrue("same suit", canPlay(c(Rank.KING, Suit.HEARTS), top, Suit.HEARTS))
assertTrue("same rank", canPlay(c(Rank.SEVEN, Suit.CLUBS), top, Suit.HEARTS))
assertFalse("neither", canPlay(c(Rank.KING, Suit.CLUBS), top, Suit.HEARTS))
}
@Test
fun `an eight always plays`() {
val top = c(Rank.SEVEN, Suit.HEARTS)
Suit.entries.forEach { suit ->
assertTrue(canPlay(c(Rank.EIGHT, suit), top, Suit.HEARTS))
}
}
@Test
fun `the suit in force overrides the top card`() {
// An eight was played on a heart and clubs were called.
val top = c(Rank.EIGHT, Suit.HEARTS)
assertTrue(canPlay(c(Rank.TWO, Suit.CLUBS), top, Suit.CLUBS))
assertFalse("hearts no longer run", canPlay(c(Rank.TWO, Suit.HEARTS), top, Suit.CLUBS))
}
// -- Legal moves --------------------------------------------------------
@Test
fun `playing an eight offers a choice of suit`() {
val hand = listOf(c(Rank.EIGHT, Suit.SPADES))
val state = playingState(listOf(hand, emptyList()), top = c(Rank.THREE, Suit.HEARTS))
val moves = CrazyEightsRules.legalMoves(state, 0).filterIsInstance<PlayCard>()
assertEquals("one move per suit", 4, moves.size)
assertEquals(Suit.entries.toSet(), moves.mapNotNull { it.nominatedSuit }.toSet())
}
@Test
fun `you must play when you can`() {
val hand = listOf(c(Rank.KING, Suit.HEARTS), c(Rank.TWO, Suit.CLUBS))
val state = playingState(
hands = listOf(hand, emptyList()),
top = c(Rank.THREE, Suit.HEARTS),
stock = listOf(c(Rank.NINE, Suit.SPADES)),
)
val moves = CrazyEightsRules.legalMoves(state, 0)
assertTrue("no drawing your way out of a playable hand", moves.none { it is DrawCard })
assertEquals(1, moves.size)
}
@Test
fun `with nothing playable you draw`() {
val hand = listOf(c(Rank.KING, Suit.CLUBS))
val state = playingState(
hands = listOf(hand, emptyList()),
top = c(Rank.THREE, Suit.HEARTS),
stock = listOf(c(Rank.NINE, Suit.SPADES)),
)
assertEquals(listOf(DrawCard), CrazyEightsRules.legalMoves(state, 0))
}
@Test
fun `with an empty stock and nothing playable you pass`() {
val hand = listOf(c(Rank.KING, Suit.CLUBS))
val state = playingState(
hands = listOf(hand, emptyList()),
top = c(Rank.THREE, Suit.HEARTS),
stock = emptyList(),
)
assertEquals(listOf(PassTurn), CrazyEightsRules.legalMoves(state, 0))
}
@Test
fun `draw one and pass stops after a single card`() {
val options = CrazyEightsOptions(playerCount = 2, drawUntilPlayable = false)
val hand = listOf(c(Rank.KING, Suit.CLUBS))
val state = playingState(
hands = listOf(hand, emptyList()),
top = c(Rank.THREE, Suit.HEARTS),
stock = listOf(c(Rank.NINE, Suit.SPADES), c(Rank.TEN, Suit.SPADES)),
options = options,
drawnThisTurn = 1,
)
assertEquals("already drawn, so pass", listOf(PassTurn), CrazyEightsRules.legalMoves(state, 0))
}
// -- Play ---------------------------------------------------------------
@Test
fun `an eight sets the suit in force`() {
val hand = listOf(c(Rank.EIGHT, Suit.SPADES), c(Rank.TWO, Suit.CLUBS))
var state = playingState(listOf(hand, listOf(c(Rank.FIVE, Suit.CLUBS))),
top = c(Rank.THREE, Suit.HEARTS))
state = CrazyEightsRules.applyMove(
state, 0, PlayCard(c(Rank.EIGHT, Suit.SPADES), Suit.DIAMONDS)
)
assertEquals(Suit.DIAMONDS, state.suitInForce)
assertEquals(c(Rank.EIGHT, Suit.SPADES), state.topCard)
assertEquals(1, state.turn)
}
@Test
fun `drawing keeps the turn`() {
val state = playingState(
hands = listOf(listOf(c(Rank.KING, Suit.CLUBS)), emptyList()),
top = c(Rank.THREE, Suit.HEARTS),
stock = listOf(c(Rank.NINE, Suit.SPADES)),
)
val after = CrazyEightsRules.applyMove(state, 0, DrawCard)
assertEquals("still your turn", 0, after.turn)
assertEquals(2, after.hands[0].size)
assertEquals(1, after.drawnThisTurn)
}
@Test
fun `an exhausted stock is refilled from the discards`() {
val state = playingState(
hands = listOf(listOf(c(Rank.KING, Suit.CLUBS)), emptyList()),
top = c(Rank.THREE, Suit.HEARTS),
stock = emptyList(),
).copy(discard = listOf(c(Rank.FOUR, Suit.SPADES), c(Rank.THREE, Suit.HEARTS)))
val after = CrazyEightsRules.applyMove(state, 0, DrawCard)
assertEquals("the card in play stays put", c(Rank.THREE, Suit.HEARTS), after.topCard)
assertEquals("and the rest became stock", 2, after.hands[0].size)
}
// -- Scoring ------------------------------------------------------------
@Test
fun `penalties charge eights the most`() {
assertEquals(50, crazyEightsPenalty(c(Rank.EIGHT, Suit.CLUBS)))
assertEquals(10, crazyEightsPenalty(c(Rank.KING, Suit.CLUBS)))
assertEquals(10, crazyEightsPenalty(c(Rank.TEN, Suit.CLUBS)))
assertEquals(1, crazyEightsPenalty(c(Rank.ACE, Suit.CLUBS)))
assertEquals(5, crazyEightsPenalty(c(Rank.FIVE, Suit.CLUBS)))
}
@Test
fun `going out charges everyone else for what they hold`() {
val options = CrazyEightsOptions(playerCount = 3, roundsToPlay = 2)
var state = playingState(
hands = listOf(
listOf(c(Rank.THREE, Suit.HEARTS)),
listOf(c(Rank.EIGHT, Suit.CLUBS), c(Rank.ACE, Suit.SPADES)),
listOf(c(Rank.KING, Suit.SPADES)),
),
top = c(Rank.FOUR, Suit.HEARTS),
options = options,
)
state = CrazyEightsRules.applyMove(state, 0, PlayCard(c(Rank.THREE, Suit.HEARTS)))
assertEquals(CrazyEightsPhase.ROUND_OVER, state.phase)
assertEquals(0, state.roundWinner)
assertEquals("the winner is charged nothing", 0, state.scores[0])
assertEquals("an eight and an ace", 51, state.scores[1])
assertEquals(10, state.scores[2])
}
@Test
fun `the last round ends the game`() {
val options = CrazyEightsOptions(playerCount = 2, roundsToPlay = 1)
var state = playingState(
hands = listOf(listOf(c(Rank.THREE, Suit.HEARTS)), listOf(c(Rank.KING, Suit.SPADES))),
top = c(Rank.FOUR, Suit.HEARTS),
options = options,
)
state = CrazyEightsRules.applyMove(state, 0, PlayCard(c(Rank.THREE, Suit.HEARTS)))
assertTrue(CrazyEightsRules.isFinished(state))
}
// -- Dealing ------------------------------------------------------------
@Test
fun `two players get seven cards and everyone else five`() {
assertEquals(7, CrazyEightsOptions(playerCount = 2).handSize())
assertEquals(5, CrazyEightsOptions(playerCount = 4).handSize())
assertEquals(9, CrazyEightsOptions(playerCount = 4, startingHand = 9).handSize())
}
@Test
fun `the opening card is never an eight`() {
// An eight on top would leave the suit in force unnamed.
repeat(30) { seed ->
val state = CrazyEightsRules.initialState(config(seed = seed.toLong()))
assertFalse("seed $seed dealt a wild upcard", isWild(state.topCard!!))
assertEquals(state.topCard!!.suit, state.suitInForce)
}
}
// -- Full games ---------------------------------------------------------
@Test
fun `random legal play always finishes`() {
repeat(20) { iteration ->
val random = Random(iteration.toLong())
var state = CrazyEightsRules.initialState(
config(CrazyEightsOptions(playerCount = 4, roundsToPlay = 2), iteration.toLong())
)
var guard = 0
while (!CrazyEightsRules.isFinished(state) && guard++ < 8000) {
val seat = CrazyEightsRules.currentSeat(state)
if (seat == null) {
state = CrazyEightsRules.nextRound(state)
continue
}
val legal = CrazyEightsRules.legalMoves(state, seat)
assertTrue("seat $seat had no legal move", legal.isNotEmpty())
state = CrazyEightsRules.applyMove(state, seat, legal[random.nextInt(legal.size)])
}
assertTrue("game $iteration did not finish", CrazyEightsRules.isFinished(state))
}
}
@Test
fun `ai play always finishes and beats random play`() {
val ai = CrazyEightsAi()
var aiTotal = 0
var randomTotal = 0
repeat(12) { iteration ->
val random = Random(3000L + iteration)
var state = CrazyEightsRules.initialState(
config(CrazyEightsOptions(playerCount = 2, roundsToPlay = 3), 800L + iteration)
)
var guard = 0
while (!CrazyEightsRules.isFinished(state) && guard++ < 8000) {
val seat = CrazyEightsRules.currentSeat(state)
if (seat == null) {
state = CrazyEightsRules.nextRound(state)
continue
}
val legal = CrazyEightsRules.legalMoves(state, seat)
val move = if (seat == 0) {
ai.chooseMove(state, seat, legal)
} else {
legal[random.nextInt(legal.size)]
}
assertTrue("ai returned an illegal move $move", legal.contains(move))
state = CrazyEightsRules.applyMove(state, seat, move)
}
assertTrue(CrazyEightsRules.isFinished(state))
aiTotal += state.scores[0]
randomTotal += state.scores[1]
}
// Penalty points, so lower is better.
assertTrue(
"ai took $aiTotal against random's $randomTotal — no better than flailing",
aiTotal < randomTotal,
)
}
// -- Redaction and wire format -----------------------------------------
@Test
fun `a seat view hides other hands and the stock`() {
val state = CrazyEightsRules.initialState(config())
val view = CrazyEightsRules.viewFor(state, seat = 2)
assertEquals(state.hands[2], view.hands[2])
assertTrue(view.hands.filterIndexed { i, _ -> i != 2 }.all { it.isEmpty() })
assertTrue(view.stock.isEmpty())
assertEquals(state.handCounts, view.handCounts)
}
@Test
fun `state and moves survive a round trip`() {
val state = CrazyEightsRules.initialState(config())
assertEquals(state, CrazyEightsRules.decodeState(CrazyEightsRules.encodeState(state)))
listOf(
DrawCard,
PassTurn,
PlayCard(c(Rank.KING, Suit.HEARTS)),
PlayCard(c(Rank.EIGHT, Suit.CLUBS), Suit.DIAMONDS),
).forEach {
assertEquals(it, CrazyEightsRules.decodeMove(CrazyEightsRules.encodeMove(it)))
}
}
}
@@ -0,0 +1,369 @@
package org.prolibertate.games.game.kaiser
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Decks
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
import kotlin.random.Random
class KaiserRulesTest {
private val json = Json { encodeDefaults = true }
private fun config(options: KaiserOptions = KaiserOptions(), seed: Long = 5L) = TableConfig(
gameId = "kaiser",
seats = (0 until 4).map {
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it % 2)
},
optionsJson = json.encodeToString(options),
seed = seed,
)
// -- The deck -----------------------------------------------------------
@Test
fun `the deck is thirty-two cards with the two counters in it`() {
val deck = Decks.kaiser()
assertEquals(32, deck.size)
assertEquals("no duplicates", 32, deck.distinct().size)
assertTrue("five of hearts", deck.contains(FIVE_OF_HEARTS))
assertTrue("three of spades", deck.contains(THREE_OF_SPADES))
assertTrue("seven of clubs stays", deck.contains(Card(Rank.SEVEN, Suit.CLUBS)))
assertTrue("seven of diamonds stays", deck.contains(Card(Rank.SEVEN, Suit.DIAMONDS)))
assertFalse("but not the seven of hearts", deck.contains(Card(Rank.SEVEN, Suit.HEARTS)))
assertFalse("nor the seven of spades", deck.contains(Card(Rank.SEVEN, Suit.SPADES)))
assertTrue("nothing below the counters", deck.none {
it.rank.order < Rank.SEVEN.order && it != FIVE_OF_HEARTS && it != THREE_OF_SPADES
})
}
@Test
fun `a hand is worth ten points in total`() {
// Eight tricks at one each, plus five, less three.
val deck = Decks.kaiser()
val allTricks = deck.chunked(4)
assertEquals(8, allTricks.size)
assertEquals(10, allTricks.sumOf { trickValue(it) })
}
@Test
fun `the counters move a tricks value`() {
val plain = listOf(Card(Rank.ACE, Suit.CLUBS), Card(Rank.KING, Suit.CLUBS))
assertEquals(1, trickValue(plain))
assertEquals(6, trickValue(plain + FIVE_OF_HEARTS))
assertEquals(-2, trickValue(plain + THREE_OF_SPADES))
assertEquals(3, trickValue(plain + FIVE_OF_HEARTS + THREE_OF_SPADES))
}
// -- Bidding ------------------------------------------------------------
@Test
fun `everyone gets eight cards and bidding opens left of the dealer`() {
val state = KaiserRules.initialState(config())
assertEquals(KaiserPhase.BIDDING, state.phase)
state.hands.forEach { assertEquals(8, it.size) }
assertEquals((state.dealer + 1) % 4, state.turn)
}
@Test
fun `a bid must beat the one before it`() {
var state = KaiserRules.initialState(config(KaiserOptions(minimumBid = 6)))
val opener = state.turn
state = KaiserRules.applyMove(state, opener, MakeBid(Bid(7, noTrump = false)))
val moves = KaiserRules.legalMoves(state, state.turn).filterIsInstance<MakeBid>()
assertTrue("nothing at or below seven in suit",
moves.none { !it.bid.noTrump && it.bid.points <= 7 })
assertTrue("but seven no trump beats seven",
moves.any { it.bid.noTrump && it.bid.points == 7 })
}
@Test
fun `no trump outranks the same number`() {
assertTrue(Bid(7, noTrump = true).beats(Bid(7, noTrump = false)))
assertFalse(Bid(7, noTrump = false).beats(Bid(7, noTrump = true)))
assertTrue(Bid(8, noTrump = false).beats(Bid(7, noTrump = true)))
}
@Test
fun `the bidding floor is respected`() {
val state = KaiserRules.initialState(config(KaiserOptions(minimumBid = 8)))
val moves = KaiserRules.legalMoves(state, state.turn).filterIsInstance<MakeBid>()
assertTrue(moves.all { it.bid.points >= 8 })
}
@Test
fun `the dealer is stuck when everyone passes`() {
var state = KaiserRules.initialState(config())
repeat(3) { state = KaiserRules.applyMove(state, state.turn, PassBid) }
assertEquals(state.dealer, state.turn)
val moves = KaiserRules.legalMoves(state, state.dealer)
assertFalse("the dealer has to bid", moves.contains(PassBid))
assertTrue(moves.all { it is MakeBid })
}
@Test
fun `the winning bidder names trump and leads`() {
var state = KaiserRules.initialState(config())
val bidder = state.turn
state = KaiserRules.applyMove(state, bidder, MakeBid(Bid(6, noTrump = false)))
repeat(3) { state = KaiserRules.applyMove(state, state.turn, PassBid) }
assertEquals("back to the bidder", bidder, state.turn)
val naming = KaiserRules.legalMoves(state, bidder).filterIsInstance<NameTrump>()
assertEquals("a choice of four suits", 4, naming.size)
state = KaiserRules.applyMove(state, bidder, NameTrump(Suit.SPADES))
assertEquals(KaiserPhase.PLAYING, state.phase)
assertEquals(Suit.SPADES, state.trump)
assertEquals("and leads", bidder, state.turn)
}
@Test
fun `a no trump bid commits to no trump`() {
var state = KaiserRules.initialState(config())
val bidder = state.turn
state = KaiserRules.applyMove(state, bidder, MakeBid(Bid(6, noTrump = true)))
repeat(3) { state = KaiserRules.applyMove(state, state.turn, PassBid) }
val naming = KaiserRules.legalMoves(state, bidder).filterIsInstance<NameTrump>()
assertEquals(listOf(NameTrump(null)), naming)
state = KaiserRules.applyMove(state, bidder, NameTrump(null))
assertEquals(null, state.trump)
assertEquals(KaiserPhase.PLAYING, state.phase)
}
// -- Play ---------------------------------------------------------------
private fun playingState(
hands: List<List<Card>>,
trump: Suit?,
bid: Bid = Bid(6, noTrump = false),
bidder: Int = 0,
turn: Int = 0,
) = KaiserState(
options = KaiserOptions(),
seed = 1L,
handNumber = 0,
dealer = 3,
phase = KaiserPhase.PLAYING,
hands = hands,
handCounts = hands.map { it.size },
turn = turn,
passes = 3,
highBid = bid,
highBidder = bidder,
trump = trump,
trick = emptyList(),
completedTrick = emptyList(),
leader = turn,
lastTrickWinner = null,
handPoints = listOf(0, 0),
scores = listOf(0, 0),
log = emptyList(),
)
@Test
fun `you must follow suit`() {
val hand = listOf(Card(Rank.NINE, Suit.HEARTS), Card(Rank.ACE, Suit.CLUBS))
val state = playingState(
hands = listOf(emptyList(), hand, emptyList(), emptyList()),
trump = Suit.SPADES,
turn = 1,
).copy(trick = listOf(PlayedCard(0, Card(Rank.KING, Suit.HEARTS))))
assertEquals(listOf(Card(Rank.NINE, Suit.HEARTS)), KaiserRules.playableCards(state, 1))
}
@Test
fun `trump beats the led suit and there are no bowers`() {
val trump = Suit.SPADES
assertTrue(
trickStrength(Card(Rank.EIGHT, trump), trump, Suit.HEARTS) >
trickStrength(Card(Rank.ACE, Suit.HEARTS), trump, Suit.HEARTS)
)
// The jack is just a jack here.
assertTrue(
trickStrength(Card(Rank.ACE, trump), trump, trump) >
trickStrength(Card(Rank.JACK, trump), trump, trump)
)
}
@Test
fun `taking the five of hearts is worth five`() {
val hands = listOf(
listOf(Card(Rank.ACE, Suit.CLUBS)),
listOf(FIVE_OF_HEARTS),
listOf(Card(Rank.EIGHT, Suit.CLUBS)),
listOf(Card(Rank.NINE, Suit.CLUBS)),
)
var state = playingState(hands, trump = Suit.SPADES)
state = KaiserRules.applyMove(state, 0, PlayCard(Card(Rank.ACE, Suit.CLUBS)))
state = KaiserRules.applyMove(state, 1, PlayCard(FIVE_OF_HEARTS))
state = KaiserRules.applyMove(state, 2, PlayCard(Card(Rank.EIGHT, Suit.CLUBS)))
state = KaiserRules.applyMove(state, 3, PlayCard(Card(Rank.NINE, Suit.CLUBS)))
assertEquals("seat 0 took it", 0, state.lastTrickWinner)
assertEquals("one for the trick plus five", 6, state.handPoints[0])
}
@Test
fun `taking the three of spades costs three`() {
val hands = listOf(
listOf(Card(Rank.ACE, Suit.CLUBS)),
listOf(THREE_OF_SPADES),
listOf(Card(Rank.EIGHT, Suit.CLUBS)),
listOf(Card(Rank.NINE, Suit.CLUBS)),
)
var state = playingState(hands, trump = Suit.HEARTS)
state = KaiserRules.applyMove(state, 0, PlayCard(Card(Rank.ACE, Suit.CLUBS)))
state = KaiserRules.applyMove(state, 1, PlayCard(THREE_OF_SPADES))
state = KaiserRules.applyMove(state, 2, PlayCard(Card(Rank.EIGHT, Suit.CLUBS)))
state = KaiserRules.applyMove(state, 3, PlayCard(Card(Rank.NINE, Suit.CLUBS)))
assertEquals(0, state.lastTrickWinner)
assertEquals("one for the trick less three", -2, state.handPoints[0])
}
@Test
fun `a finished trick is held on the table`() {
val hands = listOf(
listOf(Card(Rank.ACE, Suit.CLUBS), Card(Rank.KING, Suit.CLUBS)),
listOf(Card(Rank.EIGHT, Suit.CLUBS), Card(Rank.NINE, Suit.CLUBS)),
listOf(Card(Rank.TEN, Suit.CLUBS), Card(Rank.QUEEN, Suit.CLUBS)),
listOf(Card(Rank.JACK, Suit.CLUBS), Card(Rank.EIGHT, Suit.HEARTS)),
)
var state = playingState(hands, trump = Suit.SPADES)
repeat(4) {
val seat = KaiserRules.currentSeat(state)!!
state = KaiserRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
}
assertTrue(state.trick.isEmpty())
assertEquals("all four held for display", 4, state.completedTrick.size)
}
// -- Scoring ------------------------------------------------------------
@Test
fun `making the bid scores what was taken and going down costs the bid`() {
val made = scoreOneHand(bid = Bid(6, noTrump = false), bidderTakesAll = true)
assertTrue("made contract scores", made.scores[0] > 0)
val down = scoreOneHand(bid = Bid(10, noTrump = false), bidderTakesAll = false)
assertEquals("going down costs the bid", -10, down.scores[0])
}
/** Plays a whole hand where one team either wins every trick or none. */
private fun scoreOneHand(bid: Bid, bidderTakesAll: Boolean): KaiserState {
val strong = Suit.SPADES
// Team 0 holds every spade; team 1 holds none.
val hands = listOf(
(0 until 8).map { Card(Rank.entries.filter { r -> r.order >= Rank.EIGHT.order }[it % 7], strong) },
List(8) { Card(Rank.EIGHT, Suit.HEARTS) },
List(8) { Card(Rank.NINE, Suit.HEARTS) },
List(8) { Card(Rank.TEN, Suit.HEARTS) },
)
var state = playingState(hands, trump = strong, bid = bid, bidder = if (bidderTakesAll) 0 else 0)
if (!bidderTakesAll) {
// Give the spades to the other side instead.
state = state.copy(
hands = listOf(hands[1], hands[0], hands[2], hands[3]),
handCounts = hands.map { it.size },
)
}
var guard = 0
while (state.phase == KaiserPhase.PLAYING && guard++ < 100) {
val seat = KaiserRules.currentSeat(state)!!
val legal = KaiserRules.legalMoves(state, seat)
state = KaiserRules.applyMove(state, seat, legal.first())
}
return state
}
@Test
fun `defenders keep what they take`() {
var state = KaiserRules.initialState(config())
var guard = 0
while (state.phase != KaiserPhase.HAND_OVER && !KaiserRules.isFinished(state) && guard++ < 500) {
val seat = KaiserRules.currentSeat(state) ?: break
val legal = KaiserRules.legalMoves(state, seat)
state = KaiserRules.applyMove(state, seat, legal.first())
}
assertTrue("a hand was scored", state.scores.any { it != 0 } || state.handPoints.any { it != 0 })
}
// -- Full games ---------------------------------------------------------
@Test
fun `random legal play always reaches a winner`() {
repeat(15) { iteration ->
val random = Random(iteration.toLong())
var state = KaiserRules.initialState(config(seed = iteration.toLong()))
var guard = 0
while (!KaiserRules.isFinished(state) && guard++ < 8000) {
val seat = KaiserRules.currentSeat(state)
if (seat == null) {
state = KaiserRules.nextHand(state)
continue
}
val legal = KaiserRules.legalMoves(state, seat)
assertTrue("seat $seat had no legal move in ${state.phase}", legal.isNotEmpty())
state = KaiserRules.applyMove(state, seat, legal[random.nextInt(legal.size)])
}
assertTrue("game $iteration did not finish", KaiserRules.isFinished(state))
}
}
@Test
fun `ai play always reaches a winner`() {
val ai = KaiserAi()
repeat(8) { iteration ->
var state = KaiserRules.initialState(config(seed = 600L + iteration))
var guard = 0
while (!KaiserRules.isFinished(state) && guard++ < 8000) {
val seat = KaiserRules.currentSeat(state)
if (seat == null) {
state = KaiserRules.nextHand(state)
continue
}
val legal = KaiserRules.legalMoves(state, seat)
val move = ai.chooseMove(state, seat, legal)
assertTrue("ai returned an illegal move $move", legal.contains(move))
state = KaiserRules.applyMove(state, seat, move)
}
assertTrue("ai game did not finish", KaiserRules.isFinished(state))
}
}
// -- Redaction and wire format -----------------------------------------
@Test
fun `a seat view hides every other hand`() {
val state = KaiserRules.initialState(config())
val view = KaiserRules.viewFor(state, seat = 3)
assertEquals(state.hands[3], view.hands[3])
assertTrue(view.hands.filterIndexed { i, _ -> i != 3 }.all { it.isEmpty() })
assertEquals(listOf(8, 8, 8, 8), view.handCounts)
}
@Test
fun `state and moves survive a round trip`() {
val state = KaiserRules.initialState(config())
assertEquals(state, KaiserRules.decodeState(KaiserRules.encodeState(state)))
listOf(
PassBid,
MakeBid(Bid(7, noTrump = true)),
NameTrump(Suit.HEARTS),
NameTrump(null),
PlayCard(FIVE_OF_HEARTS),
).forEach { assertEquals(it, KaiserRules.decodeMove(KaiserRules.encodeMove(it))) }
}
}
@@ -64,7 +64,7 @@ class SequenceRulesTest {
@Test @Test
fun `every non-jack card maps to exactly two squares`() { fun `every non-jack card maps to exactly two squares`() {
val nonJacks = Suit.entries.flatMap { suit -> val nonJacks = Suit.entries.flatMap { suit ->
Rank.entries.filter { it != Rank.JACK }.map { Card(it, suit) } Rank.standard.filter { it != Rank.JACK }.map { Card(it, suit) }
} }
nonJacks.forEach { card -> nonJacks.forEach { card ->
assertEquals("$card", 2, SequenceBoard.squaresByCard[card]?.size) assertEquals("$card", 2, SequenceBoard.squaresByCard[card]?.size)
@@ -0,0 +1,471 @@
package org.prolibertate.games.game.wizard
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Decks
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
import kotlin.random.Random
class WizardRulesTest {
private val json = Json { encodeDefaults = true }
private fun config(options: WizardOptions = WizardOptions(), seed: Long = 11L) = TableConfig(
gameId = "wizard",
seats = (0 until options.playerCount).map {
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
},
optionsJson = json.encodeToString(options),
seed = seed,
)
private fun wizardOf(suit: Suit) = Card(Rank.WIZARD, suit)
private fun jesterOf(suit: Suit) = Card(Rank.JESTER, suit)
// -- The deck -----------------------------------------------------------
@Test
fun `the deck is sixty cards with four wizards and four jesters`() {
val deck = Decks.wizard()
assertEquals(60, deck.size)
assertEquals("no duplicates", 60, deck.distinct().size)
assertEquals(4, deck.count { isWizardCard(it) })
assertEquals(4, deck.count { isJester(it) })
assertEquals(52, deck.count { it.rank.standard })
}
@Test
fun `the whole deck is dealt out over the rounds`() {
assertEquals(20, WizardOptions(playerCount = 3).totalRounds())
assertEquals(15, WizardOptions(playerCount = 4).totalRounds())
assertEquals(12, WizardOptions(playerCount = 5).totalRounds())
assertEquals(10, WizardOptions(playerCount = 6).totalRounds())
// A short game stops early rather than inventing extra rounds.
assertEquals(5, WizardOptions(playerCount = 4, rounds = 5).totalRounds())
assertEquals(15, WizardOptions(playerCount = 4, rounds = 99).totalRounds())
}
// -- Dealing and trump --------------------------------------------------
@Test
fun `round one deals a single card each and turns one for trump`() {
val state = WizardRules.initialState(config())
assertEquals(WizardPhase.BIDDING, state.phase)
state.hands.forEach { assertEquals(1, it.size) }
assertEquals(1, state.cardsThisRound)
assertEquals("something was turned", true, state.trumpCard != null)
}
@Test
fun `the last round turns nothing and is played without trump`() {
// Four players, fifteen rounds: the last one uses every card in the deck.
var state = WizardRules.initialState(config())
var guard = 0
while (state.round < 14 && guard++ < 40) {
state = playRoundOut(state)
if (state.phase == WizardPhase.ROUND_OVER) state = WizardRules.nextRound(state)
}
assertEquals(14, state.round)
assertEquals(15, state.hands[0].size)
assertNull("nothing left to turn", state.trumpCard)
assertNull("so there is no trump", state.trump)
}
@Test
fun `a turned jester means no trump`() {
// Search seeds until one turns a jester rather than asserting on luck.
val seed = (1L..400L).firstOrNull { s ->
val state = WizardRules.initialState(config(seed = s))
state.trumpCard?.let { isJester(it) } == true
}
requireNotNull(seed) { "no seed in range turned a jester" }
val state = WizardRules.initialState(config(seed = seed))
assertNull(state.trump)
assertTrue("bidding starts left of the dealer", state.turn == (state.dealer + 1) % 4)
}
@Test
fun `a turned wizard hands the dealer the choice of trump`() {
val seed = (1L..400L).firstOrNull { s ->
val state = WizardRules.initialState(config(seed = s))
state.trumpCard?.let { isWizardCard(it) } == true
}
requireNotNull(seed) { "no seed in range turned a wizard" }
var state = WizardRules.initialState(config(seed = seed))
assertNull("trump is not set yet", state.trump)
assertEquals("the dealer is on turn", state.dealer, state.turn)
val choices = WizardRules.legalMoves(state, state.dealer)
assertEquals(Suit.entries.map { ChooseTrump(it) }, choices)
state = WizardRules.applyMove(state, state.dealer, ChooseTrump(Suit.HEARTS))
assertEquals(Suit.HEARTS, state.trump)
assertEquals("then bidding opens on the left", (state.dealer + 1) % 4, state.turn)
assertTrue(WizardRules.legalMoves(state, state.turn).all { it is MakeBid })
}
// -- Bidding ------------------------------------------------------------
@Test
fun `you may bid anything from zero up to the tricks available`() {
val state = WizardRules.initialState(config())
val bids = WizardRules.legalMoves(state, state.turn).filterIsInstance<MakeBid>()
assertEquals(listOf(0, 1), bids.map { it.tricks })
}
@Test
fun `screw the dealer forbids the bid that would balance the book`() {
var state = WizardRules.initialState(
config(WizardOptions(screwTheDealer = true, rounds = 4), seed = 3L)
)
// Get to a round with room to bid, then walk round to the dealer.
while (state.cardsThisRound < 3) {
state = playRoundOut(state)
state = WizardRules.nextRound(state)
}
if (state.trumpCard?.let { isWizardCard(it) } == true) {
state = WizardRules.applyMove(state, state.dealer, ChooseTrump(Suit.SPADES))
}
val available = state.cardsThisRound
var bidSoFar = 0
while (state.turn != state.dealer) {
val seat = state.turn
state = WizardRules.applyMove(state, seat, MakeBid(1))
bidSoFar += 1
}
val dealerBids = WizardRules.legalMoves(state, state.dealer)
.filterIsInstance<MakeBid>().map { it.tricks }
assertFalse("the balancing bid is off the table", dealerBids.contains(available - bidSoFar))
assertEquals(available, dealerBids.size) // one fewer than the 0..available range
}
@Test
fun `play starts once everyone has bid`() {
var state = WizardRules.initialState(config())
if (state.trumpCard?.let { isWizardCard(it) } == true) {
state = WizardRules.applyMove(state, state.dealer, ChooseTrump(Suit.SPADES))
}
repeat(4) { state = WizardRules.applyMove(state, state.turn, MakeBid(0)) }
assertEquals(WizardPhase.PLAYING, state.phase)
assertEquals("the player left of the dealer leads", (state.dealer + 1) % 4, state.turn)
}
// -- Trick ranking ------------------------------------------------------
@Test
fun `a wizard beats everything including trump`() {
val trump = Suit.SPADES
assertTrue(
trickStrength(wizardOf(Suit.CLUBS), 1, trump, Suit.HEARTS) >
trickStrength(Card(Rank.ACE, trump), 0, trump, Suit.HEARTS)
)
}
@Test
fun `the first wizard down takes the trick`() {
assertTrue(
trickStrength(wizardOf(Suit.CLUBS), 0, Suit.SPADES, null) >
trickStrength(wizardOf(Suit.HEARTS), 3, Suit.SPADES, null)
)
}
@Test
fun `a jester loses to everything, even an off-suit discard`() {
val trump = Suit.SPADES
val jester = trickStrength(jesterOf(Suit.CLUBS), 1, trump, Suit.HEARTS)
val offSuit = trickStrength(Card(Rank.TWO, Suit.DIAMONDS), 2, trump, Suit.HEARTS)
assertTrue(offSuit > jester)
assertTrue(trickStrength(Card(Rank.TWO, Suit.HEARTS), 2, trump, Suit.HEARTS) > offSuit)
}
@Test
fun `a trick of nothing but jesters goes to the first one played`() {
val hands = List(4) { seat -> listOf(jesterOf(Suit.entries[seat])) }
var state = playingState(hands, trump = Suit.SPADES, bids = listOf(0, 0, 0, 0))
repeat(4) {
val seat = WizardRules.currentSeat(state)!!
state = WizardRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
}
assertEquals(0, state.lastTrickWinner)
}
@Test
fun `a led jester leaves the suit to the next ordinary card`() {
val trick = listOf(
PlayedCard(0, jesterOf(Suit.CLUBS)),
PlayedCard(1, Card(Rank.NINE, Suit.HEARTS)),
)
assertEquals(Suit.HEARTS, ledSuitOf(trick))
}
@Test
fun `a wizard before any suit is established means anything may be played`() {
assertNull(ledSuitOf(listOf(PlayedCard(0, wizardOf(Suit.CLUBS)))))
assertNull(
ledSuitOf(
listOf(
PlayedCard(0, jesterOf(Suit.CLUBS)),
PlayedCard(1, wizardOf(Suit.HEARTS)),
PlayedCard(2, Card(Rank.NINE, Suit.SPADES)),
)
)
)
}
// -- Following suit -----------------------------------------------------
@Test
fun `you must follow suit but may always throw a wizard or a jester`() {
val hand = listOf(
Card(Rank.NINE, Suit.HEARTS),
Card(Rank.ACE, Suit.CLUBS),
wizardOf(Suit.SPADES),
jesterOf(Suit.DIAMONDS),
)
val state = playingState(
hands = listOf(emptyList(), hand, emptyList(), emptyList()),
trump = Suit.SPADES,
bids = listOf(0, 1, 0, 0),
turn = 1,
).copy(trick = listOf(PlayedCard(0, Card(Rank.KING, Suit.HEARTS))))
val playable = WizardRules.playableCards(state, 1)
assertTrue(playable.contains(Card(Rank.NINE, Suit.HEARTS)))
assertTrue(playable.contains(wizardOf(Suit.SPADES)))
assertTrue(playable.contains(jesterOf(Suit.DIAMONDS)))
assertFalse("the ace of clubs is not a heart", playable.contains(Card(Rank.ACE, Suit.CLUBS)))
}
@Test
fun `with none of the led suit you may play anything`() {
val hand = listOf(Card(Rank.ACE, Suit.CLUBS), Card(Rank.TWO, Suit.SPADES))
val state = playingState(
hands = listOf(emptyList(), hand, emptyList(), emptyList()),
trump = Suit.SPADES,
bids = listOf(0, 1, 0, 0),
turn = 1,
).copy(trick = listOf(PlayedCard(0, Card(Rank.KING, Suit.HEARTS))))
assertEquals(hand, WizardRules.playableCards(state, 1))
}
@Test
fun `a finished trick is held on the table`() {
val hands = listOf(
listOf(Card(Rank.ACE, Suit.CLUBS), Card(Rank.KING, Suit.CLUBS)),
listOf(Card(Rank.EIGHT, Suit.CLUBS), Card(Rank.NINE, Suit.CLUBS)),
listOf(Card(Rank.TEN, Suit.CLUBS), Card(Rank.QUEEN, Suit.CLUBS)),
listOf(Card(Rank.JACK, Suit.CLUBS), Card(Rank.TWO, Suit.HEARTS)),
)
var state = playingState(hands, trump = Suit.SPADES, bids = listOf(1, 0, 0, 1))
repeat(4) {
val seat = WizardRules.currentSeat(state)!!
state = WizardRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
}
assertTrue(state.trick.isEmpty())
assertEquals("all four held for display", 4, state.completedTrick.size)
assertEquals("seat 0 took it with the ace", 0, state.lastTrickWinner)
}
// -- Scoring ------------------------------------------------------------
@Test
fun `an exact bid pays twenty plus ten a trick`() {
assertEquals(20, scoreRound(bid = 0, won = 0))
assertEquals(30, scoreRound(bid = 1, won = 1))
assertEquals(50, scoreRound(bid = 3, won = 3))
}
@Test
fun `missing costs ten for every trick out either way`() {
assertEquals(-10, scoreRound(bid = 0, won = 1))
assertEquals(-30, scoreRound(bid = 3, won = 0))
assertEquals(-20, scoreRound(bid = 1, won = 3))
}
@Test
fun `the round is scored when the last trick is taken`() {
val hands = listOf(
listOf(Card(Rank.ACE, Suit.CLUBS)),
listOf(Card(Rank.EIGHT, Suit.CLUBS)),
listOf(Card(Rank.NINE, Suit.CLUBS)),
listOf(Card(Rank.TEN, Suit.CLUBS)),
)
var state = playingState(hands, trump = Suit.SPADES, bids = listOf(1, 0, 0, 1))
repeat(4) {
val seat = WizardRules.currentSeat(state)!!
state = WizardRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
}
assertEquals(WizardPhase.ROUND_OVER, state.phase)
assertEquals("bid one and took it", 30, state.roundScores[0])
assertEquals("bid none and took none", 20, state.roundScores[1])
assertEquals("bid one and took none", -10, state.roundScores[3])
assertEquals(state.roundScores, state.scores)
assertNull("no seat is on turn between rounds", WizardRules.currentSeat(state))
}
@Test
fun `the next round deals one more card and moves the deal on`() {
var state = WizardRules.initialState(config())
val dealer = state.dealer
state = playRoundOut(state)
assertEquals(WizardPhase.ROUND_OVER, state.phase)
val next = WizardRules.nextRound(state)
assertEquals(1, next.round)
assertEquals(2, next.cardsThisRound)
next.hands.forEach { assertEquals(2, it.size) }
assertEquals((dealer + 1) % 4, next.dealer)
assertEquals("scores carry over", state.scores, next.scores)
}
// -- Full games ---------------------------------------------------------
@Test
fun `random legal play always reaches the end of the game`() {
for (players in 3..6) {
repeat(3) { iteration ->
val random = Random(iteration.toLong())
val options = WizardOptions(playerCount = players)
var state = WizardRules.initialState(
config(options, seed = 100L * players + iteration)
)
var guard = 0
while (!WizardRules.isFinished(state) && guard++ < 20_000) {
val seat = WizardRules.currentSeat(state)
if (seat == null) {
state = WizardRules.nextRound(state)
continue
}
val legal = WizardRules.legalMoves(state, seat)
assertTrue("seat $seat had no move in ${state.phase}", legal.isNotEmpty())
state = WizardRules.applyMove(state, seat, legal[random.nextInt(legal.size)])
}
assertTrue("$players-handed game $iteration did not finish",
WizardRules.isFinished(state))
assertEquals(options.totalRounds() - 1, state.round)
}
}
}
@Test
fun `ai play always reaches the end of the game`() {
val ai = WizardAi()
repeat(4) { iteration ->
var state = WizardRules.initialState(config(seed = 700L + iteration))
var guard = 0
while (!WizardRules.isFinished(state) && guard++ < 20_000) {
val seat = WizardRules.currentSeat(state)
if (seat == null) {
state = WizardRules.nextRound(state)
continue
}
val legal = WizardRules.legalMoves(state, seat)
val move = ai.chooseMove(state, seat, legal)
assertTrue("ai returned an illegal move $move", legal.contains(move))
state = WizardRules.applyMove(state, seat, move)
}
assertTrue("ai game did not finish", WizardRules.isFinished(state))
}
}
@Test
fun `the ai hits its bid more often than chance would`() {
val ai = WizardAi()
var exact = 0
var rounds = 0
repeat(6) { iteration ->
var state = WizardRules.initialState(config(seed = 900L + iteration))
var guard = 0
while (!WizardRules.isFinished(state) && guard++ < 20_000) {
val seat = WizardRules.currentSeat(state)
if (seat == null) {
exact += state.roundScores.count { it > 0 }
rounds += state.roundScores.size
state = WizardRules.nextRound(state)
continue
}
val legal = WizardRules.legalMoves(state, seat)
state = WizardRules.applyMove(state, seat, ai.chooseMove(state, seat, legal))
}
}
// Blind guessing over a fifteen-round game lands well under a fifth of
// the time; anything paying attention should clear a quarter.
assertTrue("only $exact of $rounds bids were exact", exact * 4 >= rounds)
}
// -- Redaction and wire format -----------------------------------------
@Test
fun `a seat view hides every other hand`() {
val state = WizardRules.initialState(config(WizardOptions(playerCount = 4)))
val view = WizardRules.viewFor(state, seat = 2)
assertEquals(state.hands[2], view.hands[2])
assertTrue(view.hands.filterIndexed { i, _ -> i != 2 }.all { it.isEmpty() })
assertEquals(listOf(1, 1, 1, 1), view.handCounts)
}
@Test
fun `state and moves survive a round trip`() {
val state = WizardRules.initialState(config())
assertEquals(state, WizardRules.decodeState(WizardRules.encodeState(state)))
listOf(
MakeBid(0),
MakeBid(3),
ChooseTrump(Suit.DIAMONDS),
PlayCard(wizardOf(Suit.SPADES)),
PlayCard(jesterOf(Suit.HEARTS)),
).forEach { assertEquals(it, WizardRules.decodeMove(WizardRules.encodeMove(it))) }
}
// -- Helpers ------------------------------------------------------------
private fun playingState(
hands: List<List<Card>>,
trump: Suit?,
bids: List<Int?>,
turn: Int = 0,
) = WizardState(
options = WizardOptions(playerCount = hands.size),
seed = 1L,
round = hands.first().size - 1,
dealer = hands.size - 1,
phase = WizardPhase.PLAYING,
hands = hands,
handCounts = hands.map { it.size },
trumpCard = null,
trump = trump,
bids = bids,
tricksWon = List(hands.size) { 0 },
turn = turn,
trick = emptyList(),
completedTrick = emptyList(),
leader = turn,
lastTrickWinner = null,
scores = List(hands.size) { 0 },
roundScores = List(hands.size) { 0 },
log = emptyList(),
)
/** Bids zero all round and plays the first legal card until the round ends. */
private fun playRoundOut(start: WizardState): WizardState {
var state = start
var guard = 0
while (state.phase == WizardPhase.BIDDING || state.phase == WizardPhase.PLAYING) {
if (guard++ > 5000) break
val seat = WizardRules.currentSeat(state)!!
val legal = WizardRules.legalMoves(state, seat)
state = WizardRules.applyMove(state, seat, legal.first())
}
return state
}
}
+84
View File
@@ -0,0 +1,84 @@
# Chess — implemented rules
The full game, not a subset. This is the ruleset the engine enforces. Anything
marked **toggle** is in the game setup screen.
## Table
Two players. Seat 0 is always White; choosing to play Black in setup puts you in
seat 1 and turns the board round so your pieces are still at the near edge.
## Movement
All of it, including the parts that are usually left out:
- **Castling**, both sides, with every condition: the king and the rook must not
have moved, the squares between them must be empty, and the king may not
castle out of check, through an attacked square, or into check. The b-file
square may be attacked on the queen's side — the king does not cross it.
- **En passant**, on the move immediately after a pawn steps two squares and
only then. It is refused when taking would expose your own king, which is the
one case a naive implementation gets wrong.
- **Promotion** to queen, rook, bishop or knight, chosen when the pawn arrives.
- **Pins** — a piece that shields its own king may not step off the line.
Rights are given up correctly: a rook that moves loses its castling right, and
so does a rook captured on its own corner.
## How a game ends
| Ending | When |
| --- | --- |
| Checkmate | No legal move, and in check |
| Stalemate | No legal move, and not in check |
| Insufficient material | Neither side has enough to mate, whatever either plays |
| Fifty-move rule | A hundred half-moves with no capture and no pawn move — **toggle** |
| Threefold repetition | The same position, side to move and rights, three times — **toggle** |
Insufficient material covers bare kings, a king and one minor piece, and two
bishops on the same colour square. King and two knights is *not* included: it
cannot be forced, but it can happen with cooperation, so it is not a dead
position.
Both draw rules apply automatically rather than needing a claim. Switch either
off in setup for a game that runs until somebody is mated.
## Notation
The move list is ordinary algebraic: `e4`, `Nf3`, `exd5`, `O-O`, `a8=Q+`,
`Ra8#`. Two pieces that can reach the same square are told apart by file, or by
rank when they share a file, or by the whole square when they share both.
## Positions
The engine reads and writes FEN, so a table can start from a set position rather
than the opening array — a puzzle, or an adjourned game.
## What the computer opponent does
A negamax search with alpha-beta pruning, iterative deepening, and a
capture-only quiescence tail. The quiescence tail is what stops it giving pieces
away: a search that simply stops after a fixed number of moves scores a position
in the middle of an exchange as though the recapture never comes.
Evaluation is material plus piece placement, with a term in bare endgames that
drives the losing king towards the edge — without it a won endgame just shuffles.
**Toggle** for three strengths:
| Level | Search | Plays |
| --- | --- | --- |
| Casual | 2 ply | Takes any move within about two thirds of a pawn of the best one |
| Club | 3 ply | Nearly always the best move it can see |
| Strong | 4 ply | The best move it can see |
Every level is capped by a node budget, so no single move can hang the app on a
sharp position — it plays the best move from the last depth it finished.
## Testing
Move generation is checked against the published **perft** counts: the exact
number of positions reachable at a given depth. The opening array is verified to
depth four (197,281 positions), along with four standard test positions chosen
because they break generators — castling through check, en passant that would
expose a king, promotion with capture, and heavily pinned pieces.
+59
View File
@@ -0,0 +1,59 @@
# Crazy 8s — implemented rules
This is the ruleset the engine enforces. Anything marked **toggle** is in the
game setup screen.
## Table
- 2 to 6 players — **toggle**. Everyone plays for themselves.
- One 52-card deck.
- Seven cards each heads-up, five otherwise — **toggle** to fix it at 5, 7 or 9
instead.
- The rest becomes the stock; one card is turned up to start the discard pile.
If that card is an eight, the suit it shows is the one in force.
## Play
On your turn, play one card that matches either
- the **suit in force**, or
- the **rank** of the card on top of the pile.
**Eights are wild.** An eight may be played on anything, and the player names
the suit that is then in force. This is why the suit in force is stated in words
on the table rather than left to be read off the pile — after an eight, the top
card tells you nothing.
If you cannot play:
- **Draw until you can — *toggle*, on by default.** Keep drawing until a
playable card turns up, or until there is nothing left to draw.
- With the toggle off, draw a single card; if it still does not play, the turn
passes.
- When the stock is empty it is rebuilt from the discards, leaving the top card
in place. If there is genuinely nothing to draw and nothing to play, you pass.
If every player passes in turn, the round is blocked and ends with nobody out.
## Scoring
The round ends when a player sheds their last card. Everyone else scores penalty
points for what they were caught holding:
| Card | Penalty |
| --- | --- |
| Eight | 50 |
| Ten, jack, queen, king | 10 |
| Ace | 1 |
| Everything else | Face value |
A blocked round is scored the same way — everyone still holding cards pays for
them.
Lowest total after the last round wins — **toggle** for 1, 3 or 5 rounds.
## What the computer opponent does
It plays the most expensive card it can get rid of, saves its eights until the
endgame or until it has nothing else, and when it does play one it names the
suit it holds most of.
+78
View File
@@ -0,0 +1,78 @@
# Kaiser — implemented rules
Kaiser is the prairie game, played hard in Saskatchewan and not much anywhere
else. This is the ruleset the engine enforces. Anything marked **toggle** is in
the game setup screen.
## Table
Four players in two partnerships, sitting opposite. Seats 0 and 2 are one team,
seats 1 and 3 the other.
## The deck — 32 cards
Eight through ace in every suit, plus:
- the **seven of clubs** and the **seven of diamonds**, which are ordinary cards
making up the count, and
- the **five of hearts** and the **three of spades**, which replace the other
two sevens and are what the whole game is played for.
Eight cards each, and the deck is exhausted.
## What a hand is worth
Ten points, always:
| Source | Points |
| --- | --- |
| Each of the eight tricks | 1 |
| Taking the five of hearts | +5 |
| Taking the three of spades | −3 |
So a trick containing the five of hearts is worth six, and one containing the
three of spades is worth minus two. Both in the same trick is worth three.
## Bidding
Bidding opens to the dealer's left and goes round. Each bid is a number of
points the bidder's side undertakes to take.
- The floor is **5, 6, 7 or 8** — **toggle**, six by default.
- The ceiling is ten, because ten is the most a hand can be worth. Bidding
above what exists is only a way to guarantee going down, so it is not offered.
- **No trump** — **toggle**, on by default. A no-trump bid outranks the same
number bid with a suit; it pays double and costs double.
- Each bid must beat the one before it.
- If the first three players pass, the dealer must bid — there is no throwing
the hand in from the dealer's seat. If all four pass (only possible when the
dealer's forced bid is somehow not reached), the hand is redealt.
The winning bidder then names trump, or is committed to no trump if that is what
they bid.
## Play
The bidder leads. Follow suit if you can; otherwise play anything.
There are no bowers — Kaiser ranks straight down from the ace, so the jack is
just a jack. Trump beats every other suit; otherwise only the led suit can win.
## Scoring
- **Bidding side made it** (took at least the bid): they score every point they
took, doubled if the contract was no trump.
- **Bidding side went down**: they lose the amount of the bid, doubled if it was
no trump.
- **Defenders always keep what they took**, made or not.
First side to the target wins — **toggle** for 32, 52 or 62 points. A side that
falls to the matching negative score loses outright; without that floor a table
that keeps bidding and keeps going down never reaches a result at all.
## What the computer opponent does
It rates each suit by trump length and top cards, adds for holding the five of
hearts and subtracts for holding the three of spades, and bids the floor rather
than reaching. In play it chases the five of hearts, ducks when the three of
spades is on the table, and lets its partner have a trick it is already winning.
+81
View File
@@ -0,0 +1,81 @@
# Wizard — implemented rules
This is the ruleset the engine enforces. Anything marked **toggle** is in the
game setup screen.
## Table
- 3 to 6 players — **toggle**. Everyone plays for themselves.
## The deck — 60 cards
An ordinary 52-card pack plus **four wizards** and **four jesters**.
## Rounds
Round 1 deals one card each, round 2 deals two, and so on until the pack runs
out. That is 20 rounds with three players, 15 with four, 12 with five and 10
with six. **Toggle** to stop after 5 or 10 rounds instead of playing the lot.
After the deal, the next card off the pack is turned for trump:
- an ordinary card — its suit is trump;
- a **jester** — the round is played with no trump;
- a **wizard** — the dealer names trump before anyone bids.
In the last round the whole pack has been dealt, so there is no card to turn and
no trump.
## Bidding
Starting to the dealer's left, each player says exactly how many tricks they
will take, from none up to the number of cards in hand. Every bid is heard, so
the later you bid the more you know.
**Screw the dealer — *toggle*, off by default.** The dealer may not name the
number that would make the bids add up to the tricks available, so at least one
player at the table is always going to be wrong.
## Play
The player to the dealer's left leads. Follow the led suit if you hold it —
except that a **wizard or a jester may always be played**, whatever you hold.
Playing one is how you get out of following.
**What suit is led:**
- an ordinary card leads its suit;
- a **jester** leads nothing — the suit is set by the next ordinary card;
- a **wizard** reaching the front means no suit is led at all, and everyone may
play whatever they like.
**Who takes the trick:**
1. The **first wizard** played takes it, whatever else is down.
2. Otherwise the highest trump.
3. Otherwise the highest card of the led suit.
4. A **jester** never wins — except a trick of nothing but jesters, which goes
to the first one played.
## Scoring
| Result | Score |
| --- | --- |
| Bid made exactly | 20, plus 10 for each trick taken |
| Bid missed | −10 for every trick over or under |
So bidding none and taking none is worth 20; bidding three and taking three is
worth 50; bidding three and taking one costs 20.
Highest total after the last round wins.
## What the computer opponent does
It counts its expected tricks — a wizard is one, a jester is none, and
everything else is a fraction depending on how high it is and whether it is
trump — and bids the nearest number still on offer. In play it chases tricks
while it is short of its bid, taking them as cheaply as it can, and ducks once
it has them, leading a jester when it has one to spare.
Over long runs of self-play it lands its bid exactly around half the time,
against about one in six for random legal play.