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:
@@ -5,12 +5,12 @@ or against other people over Wi-Fi (LAN) or Bluetooth.
|
||||
|
||||
## 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 |
|
||||
| **Listed, not yet implemented** | Kaiser, Wizard, Crazy 8s, Mastermind, Backgammon, Chess, Checkers |
|
||||
| **Playable** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s, Sequence, Chess |
|
||||
| **Listed, not yet implemented** | Mastermind, Backgammon, Checkers |
|
||||
|
||||
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,
|
||||
@@ -148,8 +148,12 @@ game:
|
||||
|
||||
- [Euchre](docs/RULES-euchre.md)
|
||||
- [Sequence](docs/RULES-sequence.md) — **note the caveat about the board layout**
|
||||
- [Kaiser](docs/RULES-kaiser.md)
|
||||
- [President](docs/RULES-president.md)
|
||||
- [Golf](docs/RULES-golf.md)
|
||||
- [Wizard](docs/RULES-wizard.md)
|
||||
- [Crazy 8s](docs/RULES-crazy8s.md)
|
||||
- [Chess](docs/RULES-chess.md)
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -158,9 +162,13 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested
|
||||
cards/ deck, suits, ranks
|
||||
engine/ GameRules / GameAi contracts, seats, table config
|
||||
euchre/ Euchre model, rules, AI
|
||||
kaiser/ Kaiser model, bidding, rules, AI
|
||||
sequence/ Sequence model, board, rules, AI
|
||||
president/ President model, 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
|
||||
settings/ DataStore-backed preferences
|
||||
update/ GitHub Releases OTA updater
|
||||
|
||||
@@ -58,7 +58,7 @@ object GameCatalog {
|
||||
maxPlayers = 4,
|
||||
teamBased = true,
|
||||
blurb = "Prairie trick-taking with bidding. The 5 of hearts and 3 of spades decide it.",
|
||||
available = false,
|
||||
available = true,
|
||||
),
|
||||
GameDescriptor(
|
||||
id = PRESIDENT,
|
||||
@@ -88,7 +88,7 @@ object GameCatalog {
|
||||
maxPlayers = 6,
|
||||
teamBased = false,
|
||||
blurb = "Bid your tricks exactly. Wizards always win, jesters always lose.",
|
||||
available = false,
|
||||
available = true,
|
||||
),
|
||||
GameDescriptor(
|
||||
id = CRAZY_EIGHTS,
|
||||
@@ -98,7 +98,7 @@ object GameCatalog {
|
||||
maxPlayers = 6,
|
||||
teamBased = false,
|
||||
blurb = "Match rank or suit, and eights change everything.",
|
||||
available = false,
|
||||
available = true,
|
||||
),
|
||||
GameDescriptor(
|
||||
id = SEQUENCE,
|
||||
@@ -138,7 +138,7 @@ object GameCatalog {
|
||||
maxPlayers = 2,
|
||||
teamBased = false,
|
||||
blurb = "The old game. Full legal moves, castling, en passant, promotion.",
|
||||
available = false,
|
||||
available = true,
|
||||
),
|
||||
GameDescriptor(
|
||||
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
|
||||
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),
|
||||
THREE("3", 3),
|
||||
FOUR("4", 4),
|
||||
@@ -34,6 +40,18 @@ enum class Rank(val short: String, val order: Int) {
|
||||
QUEEN("Q", 12),
|
||||
KING("K", 13),
|
||||
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
|
||||
@@ -61,7 +79,7 @@ object Decks {
|
||||
|
||||
/** Standard 52-card deck. */
|
||||
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
|
||||
@@ -74,12 +92,38 @@ object Decks {
|
||||
else -> throw IllegalArgumentException("Unsupported Euchre deck size: $size")
|
||||
}
|
||||
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. */
|
||||
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
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
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
|
||||
|
||||
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
|
||||
if (rules.currentSeat(live) != seat) return@withLock null
|
||||
val legal = rules.legalMoves(live, seat)
|
||||
if (legal.isEmpty()) return@withLock null
|
||||
val move = ai.chooseMove(live, seat, legal)
|
||||
// Nothing else can move while an AI seat is on the clock, but
|
||||
// the state is re-read rather than assumed.
|
||||
if (rules.currentSeat(live) != seat) return@withLock false
|
||||
authoritative = rules.applyMove(live, seat, move)
|
||||
publish()
|
||||
move
|
||||
true
|
||||
}
|
||||
if (chosen == null) return
|
||||
if (!applied) return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@ private fun AppContent(
|
||||
descriptor = GameCatalog.byId(route.gameId)!!,
|
||||
playerName = settings.displayName,
|
||||
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 ->
|
||||
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.PlayerSlot
|
||||
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.golf.GolfOptions
|
||||
import org.prolibertate.games.game.kaiser.KaiserOptions
|
||||
import org.prolibertate.games.game.president.PresidentOptions
|
||||
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.
|
||||
@@ -48,28 +56,33 @@ fun GameSetupScreen(
|
||||
descriptor: GameDescriptor,
|
||||
playerName: String,
|
||||
onBack: () -> Unit,
|
||||
onPlayOffline: (TableConfig) -> Unit,
|
||||
onPlayOffline: (TableConfig, Int) -> Unit,
|
||||
onHostOnline: (String) -> Unit,
|
||||
) {
|
||||
var euchre by remember { mutableStateOf(EuchreOptions()) }
|
||||
var sequence by remember { mutableStateOf(SequenceOptions()) }
|
||||
var president by remember { mutableStateOf(PresidentOptions()) }
|
||||
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) {
|
||||
GameCatalog.EUCHRE -> setupJson.encodeToString(euchre)
|
||||
GameCatalog.SEQUENCE -> setupJson.encodeToString(sequence)
|
||||
GameCatalog.PRESIDENT -> setupJson.encodeToString(president)
|
||||
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 -> "{}"
|
||||
}
|
||||
val seatCount = when (descriptor.id) {
|
||||
GameCatalog.EUCHRE -> 4
|
||||
GameCatalog.SEQUENCE -> sequence.playerCount
|
||||
GameCatalog.PRESIDENT -> president.playerCount
|
||||
GameCatalog.GOLF -> golf.playerCount
|
||||
else -> descriptor.minPlayers
|
||||
}
|
||||
val seatCount = seatCountFor(descriptor.id, optionsJson)
|
||||
|
||||
ScreenScaffold(title = descriptor.title, onBack = onBack) { modifier ->
|
||||
Column(
|
||||
@@ -86,6 +99,19 @@ fun GameSetupScreen(
|
||||
GameCatalog.SEQUENCE -> SequenceOptionsEditor(sequence) { sequence = it }
|
||||
GameCatalog.PRESIDENT -> PresidentOptionsEditor(president) { president = 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.")
|
||||
}
|
||||
|
||||
@@ -98,9 +124,13 @@ fun GameSetupScreen(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
// Chess is the only game so far where the local player picks a
|
||||
// seat rather than always taking the first one.
|
||||
val localSeat = if (descriptor.id == GameCatalog.CHESS && !playWhite) 1 else 0
|
||||
PrimaryAction(text = "Play against the computer") {
|
||||
onPlayOffline(
|
||||
offlineConfig(descriptor.id, optionsJson, seatCount, playerName)
|
||||
offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat),
|
||||
localSeat,
|
||||
)
|
||||
}
|
||||
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(
|
||||
gameId: String,
|
||||
optionsJson: String,
|
||||
seatCount: Int,
|
||||
playerName: String,
|
||||
localSeat: Int = 0,
|
||||
): TableConfig = TableConfig(
|
||||
gameId = gameId,
|
||||
seats = (0 until seatCount).map { seat ->
|
||||
PlayerSlot(
|
||||
seat = seat,
|
||||
name = if (seat == 0) playerName else "Computer $seat",
|
||||
kind = if (seat == 0) PlayerKind.HUMAN_LOCAL else PlayerKind.AI,
|
||||
name = if (seat == localSeat) playerName else "Computer $seat",
|
||||
kind = if (seat == localSeat) PlayerKind.HUMAN_LOCAL else PlayerKind.AI,
|
||||
team = teamForSeat(gameId, seat),
|
||||
)
|
||||
},
|
||||
@@ -131,9 +162,42 @@ private fun offlineConfig(
|
||||
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) {
|
||||
// Partners sit opposite each other.
|
||||
GameCatalog.EUCHRE, GameCatalog.SEQUENCE -> seat % 2
|
||||
GameCatalog.EUCHRE, GameCatalog.KAISER, GameCatalog.SEQUENCE -> seat % 2
|
||||
// Everyone else plays for themselves.
|
||||
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)
|
||||
@Composable
|
||||
private fun <T> ChipRow(
|
||||
|
||||
@@ -21,7 +21,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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.TableConfig
|
||||
import org.prolibertate.games.net.DiscoveredHost
|
||||
@@ -46,10 +45,9 @@ fun LobbyScreen(
|
||||
LaunchedEffect(route) {
|
||||
env.requestBluetoothPermissions()
|
||||
if (route.hosting) {
|
||||
val descriptor = GameCatalog.byId(route.gameId)
|
||||
val seatCount = descriptor?.let {
|
||||
if (it.id == GameCatalog.EUCHRE) 4 else it.minPlayers
|
||||
} ?: 4
|
||||
// Seat the table the host actually set up, not the catalogue
|
||||
// minimum: the rules engine rejects a table of the wrong size.
|
||||
val seatCount = seatCountFor(route.gameId, route.optionsJson)
|
||||
env.lobby.startHosting(
|
||||
gameId = route.gameId,
|
||||
optionsJson = route.optionsJson,
|
||||
|
||||
@@ -7,6 +7,15 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.EuchreMove
|
||||
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.GolfRules
|
||||
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.PresidentMove
|
||||
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.SequenceRules
|
||||
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.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.GolfScreen
|
||||
import org.prolibertate.games.ui.game.KaiserScreen
|
||||
import org.prolibertate.games.ui.game.PresidentScreen
|
||||
import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS
|
||||
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.
|
||||
@@ -154,6 +177,128 @@ fun PlayScreen(
|
||||
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.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.PRESIDENT,
|
||||
GameCatalog.GOLF,
|
||||
GameCatalog.KAISER,
|
||||
GameCatalog.CRAZY_EIGHTS,
|
||||
GameCatalog.WIZARD,
|
||||
GameCatalog.CHESS,
|
||||
),
|
||||
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
|
||||
fun `every non-jack card maps to exactly two squares`() {
|
||||
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 ->
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user