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
|
## What works today
|
||||||
|
|
||||||
The app shell is complete and four games are playable end to end:
|
The app shell is complete and eight games are playable end to end:
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **Playable** | Euchre, Sequence, President, Golf |
|
| **Playable** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s, Sequence, Chess |
|
||||||
| **Listed, not yet implemented** | Kaiser, Wizard, Crazy 8s, Mastermind, Backgammon, Chess, Checkers |
|
| **Listed, not yet implemented** | Mastermind, Backgammon, Checkers |
|
||||||
|
|
||||||
Unimplemented games appear in the menu marked *Coming soon* **on dev builds
|
Unimplemented games appear in the menu marked *Coming soon* **on dev builds
|
||||||
only** — a production release lists just the games that can actually be played,
|
only** — a production release lists just the games that can actually be played,
|
||||||
@@ -148,8 +148,12 @@ game:
|
|||||||
|
|
||||||
- [Euchre](docs/RULES-euchre.md)
|
- [Euchre](docs/RULES-euchre.md)
|
||||||
- [Sequence](docs/RULES-sequence.md) — **note the caveat about the board layout**
|
- [Sequence](docs/RULES-sequence.md) — **note the caveat about the board layout**
|
||||||
|
- [Kaiser](docs/RULES-kaiser.md)
|
||||||
- [President](docs/RULES-president.md)
|
- [President](docs/RULES-president.md)
|
||||||
- [Golf](docs/RULES-golf.md)
|
- [Golf](docs/RULES-golf.md)
|
||||||
|
- [Wizard](docs/RULES-wizard.md)
|
||||||
|
- [Crazy 8s](docs/RULES-crazy8s.md)
|
||||||
|
- [Chess](docs/RULES-chess.md)
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
@@ -158,9 +162,13 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested
|
|||||||
cards/ deck, suits, ranks
|
cards/ deck, suits, ranks
|
||||||
engine/ GameRules / GameAi contracts, seats, table config
|
engine/ GameRules / GameAi contracts, seats, table config
|
||||||
euchre/ Euchre model, rules, AI
|
euchre/ Euchre model, rules, AI
|
||||||
|
kaiser/ Kaiser model, bidding, rules, AI
|
||||||
sequence/ Sequence model, board, rules, AI
|
sequence/ Sequence model, board, rules, AI
|
||||||
president/ President model, rules, AI
|
president/ President model, rules, AI
|
||||||
golf/ Golf model, scoring, rules, AI
|
golf/ Golf model, scoring, rules, AI
|
||||||
|
wizard/ Wizard model, rules, AI
|
||||||
|
crazyeights/ Crazy 8s model, rules, AI
|
||||||
|
chess/ Chess model, FEN, move generation, search
|
||||||
net/ wire protocol, LAN and Bluetooth transports, lobby, match driver
|
net/ wire protocol, LAN and Bluetooth transports, lobby, match driver
|
||||||
settings/ DataStore-backed preferences
|
settings/ DataStore-backed preferences
|
||||||
update/ GitHub Releases OTA updater
|
update/ GitHub Releases OTA updater
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ object GameCatalog {
|
|||||||
maxPlayers = 4,
|
maxPlayers = 4,
|
||||||
teamBased = true,
|
teamBased = true,
|
||||||
blurb = "Prairie trick-taking with bidding. The 5 of hearts and 3 of spades decide it.",
|
blurb = "Prairie trick-taking with bidding. The 5 of hearts and 3 of spades decide it.",
|
||||||
available = false,
|
available = true,
|
||||||
),
|
),
|
||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = PRESIDENT,
|
id = PRESIDENT,
|
||||||
@@ -88,7 +88,7 @@ object GameCatalog {
|
|||||||
maxPlayers = 6,
|
maxPlayers = 6,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
blurb = "Bid your tricks exactly. Wizards always win, jesters always lose.",
|
blurb = "Bid your tricks exactly. Wizards always win, jesters always lose.",
|
||||||
available = false,
|
available = true,
|
||||||
),
|
),
|
||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = CRAZY_EIGHTS,
|
id = CRAZY_EIGHTS,
|
||||||
@@ -98,7 +98,7 @@ object GameCatalog {
|
|||||||
maxPlayers = 6,
|
maxPlayers = 6,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
blurb = "Match rank or suit, and eights change everything.",
|
blurb = "Match rank or suit, and eights change everything.",
|
||||||
available = false,
|
available = true,
|
||||||
),
|
),
|
||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = SEQUENCE,
|
id = SEQUENCE,
|
||||||
@@ -138,7 +138,7 @@ object GameCatalog {
|
|||||||
maxPlayers = 2,
|
maxPlayers = 2,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
blurb = "The old game. Full legal moves, castling, en passant, promotion.",
|
blurb = "The old game. Full legal moves, castling, en passant, promotion.",
|
||||||
available = false,
|
available = true,
|
||||||
),
|
),
|
||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = CHECKERS,
|
id = CHECKERS,
|
||||||
|
|||||||
@@ -19,8 +19,14 @@ enum class Suit(val symbol: String, val isRed: Boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [standard] marks the fifty-two cards every ordinary deck holds. The two
|
||||||
|
* extras belong to Wizard alone, so anything building a normal deck must filter
|
||||||
|
* on this rather than walking the whole enum — otherwise every other game
|
||||||
|
* quietly gains eight cards it has no rules for.
|
||||||
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
enum class Rank(val short: String, val order: Int) {
|
enum class Rank(val short: String, val order: Int, val standard: Boolean = true) {
|
||||||
TWO("2", 2),
|
TWO("2", 2),
|
||||||
THREE("3", 3),
|
THREE("3", 3),
|
||||||
FOUR("4", 4),
|
FOUR("4", 4),
|
||||||
@@ -34,6 +40,18 @@ enum class Rank(val short: String, val order: Int) {
|
|||||||
QUEEN("Q", 12),
|
QUEEN("Q", 12),
|
||||||
KING("K", 13),
|
KING("K", 13),
|
||||||
ACE("A", 14),
|
ACE("A", 14),
|
||||||
|
|
||||||
|
/** Wizard only: beats everything, including trump. */
|
||||||
|
WIZARD("Wz", 100, standard = false),
|
||||||
|
|
||||||
|
/** Wizard only: loses to everything, and leads no suit. */
|
||||||
|
JESTER("Je", 0, standard = false),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The thirteen ranks of an ordinary deck. */
|
||||||
|
val standard: List<Rank> get() = entries.filter { it.standard }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -61,7 +79,7 @@ object Decks {
|
|||||||
|
|
||||||
/** Standard 52-card deck. */
|
/** Standard 52-card deck. */
|
||||||
fun standard52(): List<Card> =
|
fun standard52(): List<Card> =
|
||||||
Suit.entries.flatMap { suit -> Rank.entries.map { rank -> Card(rank, suit) } }
|
Suit.entries.flatMap { suit -> Rank.standard.map { rank -> Card(rank, suit) } }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Euchre decks. 24 cards (9 through ace) is the common game; 32 cards
|
* Euchre decks. 24 cards (9 through ace) is the common game; 32 cards
|
||||||
@@ -74,12 +92,38 @@ object Decks {
|
|||||||
else -> throw IllegalArgumentException("Unsupported Euchre deck size: $size")
|
else -> throw IllegalArgumentException("Unsupported Euchre deck size: $size")
|
||||||
}
|
}
|
||||||
return Suit.entries.flatMap { suit ->
|
return Suit.entries.flatMap { suit ->
|
||||||
Rank.entries.filter { it.order >= lowest.order }.map { rank -> Card(rank, suit) }
|
Rank.standard.filter { it.order >= lowest.order }.map { rank -> Card(rank, suit) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 60-card Wizard deck: an ordinary pack plus four wizards and four
|
||||||
|
* jesters, one of each per suit so they fit the same card model.
|
||||||
|
*/
|
||||||
|
fun wizard(): List<Card> = standard52() +
|
||||||
|
Suit.entries.map { Card(Rank.WIZARD, it) } +
|
||||||
|
Suit.entries.map { Card(Rank.JESTER, it) }
|
||||||
|
|
||||||
/** Two full decks shuffled together, as Sequence uses. */
|
/** Two full decks shuffled together, as Sequence uses. */
|
||||||
fun double52(): List<Card> = standard52() + standard52()
|
fun double52(): List<Card> = standard52() + standard52()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 32-card Kaiser deck: eight to ace in every suit, plus the seven of
|
||||||
|
* clubs and the seven of diamonds — and, in place of the other two sevens,
|
||||||
|
* the five of hearts and the three of spades, which are what the whole game
|
||||||
|
* is played for.
|
||||||
|
*/
|
||||||
|
fun kaiser(): List<Card> {
|
||||||
|
val high = Suit.entries.flatMap { suit ->
|
||||||
|
Rank.standard.filter { it.order >= Rank.EIGHT.order }.map { Card(it, suit) }
|
||||||
|
}
|
||||||
|
return high + listOf(
|
||||||
|
Card(Rank.SEVEN, Suit.CLUBS),
|
||||||
|
Card(Rank.SEVEN, Suit.DIAMONDS),
|
||||||
|
Card(Rank.FIVE, Suit.HEARTS),
|
||||||
|
Card(Rank.THREE, Suit.SPADES),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
package org.prolibertate.games.game.chess
|
||||||
|
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.random.Random
|
||||||
|
import org.prolibertate.games.game.engine.GameAi
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A negamax search with alpha-beta pruning, iterative deepening and a capture-
|
||||||
|
* only quiescence tail.
|
||||||
|
*
|
||||||
|
* The quiescence tail is what stops it hanging pieces: a fixed-depth search that
|
||||||
|
* stops in the middle of an exchange scores the position as though the recapture
|
||||||
|
* never happens, and every such search plays like it is being robbed.
|
||||||
|
*
|
||||||
|
* Search depth comes from the table's own options unless a level is passed in,
|
||||||
|
* so the setup screen sets the strength without the caller having to thread it
|
||||||
|
* through.
|
||||||
|
*/
|
||||||
|
class ChessAi(private val level: ChessLevel? = null) : GameAi<ChessState, ChessMove> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ceiling on how much thinking any one move gets. A phone should not stall
|
||||||
|
* on a sharp middlegame, so the search stops where it is and plays the best
|
||||||
|
* move from the last depth it finished.
|
||||||
|
*/
|
||||||
|
private var nodeBudget = 0
|
||||||
|
private var nodesUsed = 0
|
||||||
|
|
||||||
|
override fun chooseMove(state: ChessState, seat: Int, legal: List<ChessMove>): ChessMove {
|
||||||
|
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||||
|
val setting = level ?: state.options.level
|
||||||
|
if (legal.size == 1) return legal.first()
|
||||||
|
|
||||||
|
nodeBudget = NODE_BUDGET
|
||||||
|
nodesUsed = 0
|
||||||
|
|
||||||
|
// Deterministic per position, so a host and a client replaying the same
|
||||||
|
// game reach the same move, but varied enough that two games from the
|
||||||
|
// opening array do not run identically.
|
||||||
|
val random = Random(state.repetitionKeys.size * 31L + state.toFen().hashCode())
|
||||||
|
|
||||||
|
var best = legal.first()
|
||||||
|
var scored = legal.map { it to 0 }
|
||||||
|
|
||||||
|
for (depth in 1..setting.depth) {
|
||||||
|
val results = mutableListOf<Pair<ChessMove, Int>>()
|
||||||
|
// Search the previous iteration's best first, so the sub-searches
|
||||||
|
// below it have a decent bound to prune against.
|
||||||
|
val ordered = orderMoves(state, legal, preferred = best)
|
||||||
|
for (move in ordered) {
|
||||||
|
// Every root move gets a full window rather than being cut off
|
||||||
|
// against the best so far. It costs some pruning, but it is
|
||||||
|
// what makes the scores comparable — the weaker levels pick
|
||||||
|
// from among the near-best moves, and a move that returned only
|
||||||
|
// an upper bound cannot be compared with one that did not.
|
||||||
|
val score = -negamax(
|
||||||
|
state = ChessRules.advanced(state, move),
|
||||||
|
depth = depth - 1,
|
||||||
|
alpha = -INFINITY,
|
||||||
|
beta = INFINITY,
|
||||||
|
ply = 1,
|
||||||
|
)
|
||||||
|
results += move to score
|
||||||
|
if (nodesUsed >= nodeBudget) break
|
||||||
|
}
|
||||||
|
// A half-finished iteration has scored only some of the moves, so
|
||||||
|
// it is thrown away rather than compared against a full one.
|
||||||
|
if (nodesUsed >= nodeBudget && results.size < ordered.size) break
|
||||||
|
scored = results
|
||||||
|
best = results.maxBy { it.second }.first
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weaker levels take any move that is nearly as good, which is what
|
||||||
|
// makes them beatable without making them play nonsense.
|
||||||
|
val bestScore = scored.maxOf { it.second }
|
||||||
|
val acceptable = scored.filter { it.second >= bestScore - setting.slack() }
|
||||||
|
return acceptable[random.nextInt(acceptable.size)].first
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ChessLevel.slack(): Int = when (this) {
|
||||||
|
ChessLevel.CASUAL -> 70
|
||||||
|
ChessLevel.CLUB -> 20
|
||||||
|
ChessLevel.STRONG -> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Search
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun negamax(state: ChessState, depth: Int, alpha: Int, beta: Int, ply: Int): Int {
|
||||||
|
nodesUsed++
|
||||||
|
val moves = ChessRules.legalMoves(state)
|
||||||
|
if (moves.isEmpty()) {
|
||||||
|
// Mate is scored by distance, so the search prefers mating sooner
|
||||||
|
// and, when it is losing, delays as long as it can.
|
||||||
|
return if (ChessRules.inCheck(state)) -(MATE - ply) else 0
|
||||||
|
}
|
||||||
|
if (state.halfmoveClock >= 100) return 0
|
||||||
|
if (depth <= 0) return quiescence(state, alpha, beta, ply)
|
||||||
|
|
||||||
|
var localAlpha = alpha
|
||||||
|
for (move in orderMoves(state, moves, preferred = null)) {
|
||||||
|
val score = -negamax(ChessRules.advanced(state, move), depth - 1, -beta, -localAlpha, ply + 1)
|
||||||
|
if (score >= beta) return beta
|
||||||
|
if (score > localAlpha) localAlpha = score
|
||||||
|
if (nodesUsed >= nodeBudget) break
|
||||||
|
}
|
||||||
|
return localAlpha
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps searching while pieces are still being taken.
|
||||||
|
*
|
||||||
|
* Standing pat is allowed because the side to move is never obliged to
|
||||||
|
* capture — the position as it stands is a lower bound on what it can get.
|
||||||
|
*/
|
||||||
|
private fun quiescence(state: ChessState, alpha: Int, beta: Int, ply: Int): Int {
|
||||||
|
nodesUsed++
|
||||||
|
val standPat = evaluate(state)
|
||||||
|
if (standPat >= beta) return beta
|
||||||
|
var localAlpha = maxOf(alpha, standPat)
|
||||||
|
if (ply >= MAX_QUIESCENCE_PLY) return localAlpha
|
||||||
|
|
||||||
|
val captures = ChessRules.legalMoves(state).filter {
|
||||||
|
ChessRules.isCapture(state, it) || it.promotion != null
|
||||||
|
}
|
||||||
|
for (move in orderMoves(state, captures, preferred = null)) {
|
||||||
|
val score = -quiescence(ChessRules.advanced(state, move), -beta, -localAlpha, ply + 1)
|
||||||
|
if (score >= beta) return beta
|
||||||
|
if (score > localAlpha) localAlpha = score
|
||||||
|
if (nodesUsed >= nodeBudget) break
|
||||||
|
}
|
||||||
|
return localAlpha
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Captures first, biggest victim by smallest attacker — cheap and effective. */
|
||||||
|
private fun orderMoves(
|
||||||
|
state: ChessState,
|
||||||
|
moves: List<ChessMove>,
|
||||||
|
preferred: ChessMove?,
|
||||||
|
): List<ChessMove> = moves.sortedByDescending { move ->
|
||||||
|
var score = 0
|
||||||
|
if (move == preferred) score += 1_000_000
|
||||||
|
val victim = state.board[move.to]
|
||||||
|
if (victim != null) {
|
||||||
|
score += 10_000 + victim.kind.value - (state.board[move.from]?.kind?.value ?: 0) / 10
|
||||||
|
}
|
||||||
|
move.promotion?.let { score += 9_000 + it.value }
|
||||||
|
score
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Evaluation
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Centipawns, from the point of view of whoever is to move. */
|
||||||
|
fun evaluate(state: ChessState): Int {
|
||||||
|
var white = 0
|
||||||
|
var black = 0
|
||||||
|
var whitePieces = 0
|
||||||
|
var blackPieces = 0
|
||||||
|
|
||||||
|
for (square in 0 until 64) {
|
||||||
|
val piece = state.board[square] ?: continue
|
||||||
|
val table = tableFor(piece.kind)
|
||||||
|
// Black reads the same table from the other end of the board.
|
||||||
|
val placement = table[if (piece.white) square else square xor 56]
|
||||||
|
if (piece.white) {
|
||||||
|
white += piece.kind.value + placement
|
||||||
|
if (piece.kind != PieceKind.PAWN && piece.kind != PieceKind.KING) whitePieces++
|
||||||
|
} else {
|
||||||
|
black += piece.kind.value + placement
|
||||||
|
if (piece.kind != PieceKind.PAWN && piece.kind != PieceKind.KING) blackPieces++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In a bare endgame the tables stop meaning much and driving the enemy
|
||||||
|
// king to the edge starts to; without this a won endgame shuffles.
|
||||||
|
if (whitePieces + blackPieces <= 2) {
|
||||||
|
state.kingSquare(white = false)?.let { white += edgeDistance(it) * 8 }
|
||||||
|
state.kingSquare(white = true)?.let { black += edgeDistance(it) * 8 }
|
||||||
|
}
|
||||||
|
|
||||||
|
val score = white - black
|
||||||
|
return if (state.whiteToMove) score else -score
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How far a king has been pushed towards the rim, 0 in the centre. */
|
||||||
|
private fun edgeDistance(square: Int): Int {
|
||||||
|
val file = fileOf(square)
|
||||||
|
val rank = rankOf(square)
|
||||||
|
return maxOf(abs(file * 2 - 7), abs(rank * 2 - 7))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tableFor(kind: PieceKind): IntArray = when (kind) {
|
||||||
|
PieceKind.PAWN -> PAWN_TABLE
|
||||||
|
PieceKind.KNIGHT -> KNIGHT_TABLE
|
||||||
|
PieceKind.BISHOP -> BISHOP_TABLE
|
||||||
|
PieceKind.ROOK -> ROOK_TABLE
|
||||||
|
PieceKind.QUEEN -> QUEEN_TABLE
|
||||||
|
PieceKind.KING -> KING_TABLE
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val INFINITY = 1_000_000
|
||||||
|
const val MATE = 100_000
|
||||||
|
const val NODE_BUDGET = 120_000
|
||||||
|
const val MAX_QUIESCENCE_PLY = 24
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placement bonuses in centipawns, written from White's side with rank
|
||||||
|
* eight on the top row — the same reading order the board uses, so the
|
||||||
|
* table can be laid out to look like a board.
|
||||||
|
*/
|
||||||
|
val PAWN_TABLE = intArrayOf(
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
50, 50, 50, 50, 50, 50, 50, 50,
|
||||||
|
10, 10, 20, 30, 30, 20, 10, 10,
|
||||||
|
5, 5, 10, 25, 25, 10, 5, 5,
|
||||||
|
0, 0, 0, 20, 20, 0, 0, 0,
|
||||||
|
5, -5, -10, 0, 0, -10, -5, 5,
|
||||||
|
5, 10, 10, -20, -20, 10, 10, 5,
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
val KNIGHT_TABLE = intArrayOf(
|
||||||
|
-50, -40, -30, -30, -30, -30, -40, -50,
|
||||||
|
-40, -20, 0, 0, 0, 0, -20, -40,
|
||||||
|
-30, 0, 10, 15, 15, 10, 0, -30,
|
||||||
|
-30, 5, 15, 20, 20, 15, 5, -30,
|
||||||
|
-30, 0, 15, 20, 20, 15, 0, -30,
|
||||||
|
-30, 5, 10, 15, 15, 10, 5, -30,
|
||||||
|
-40, -20, 0, 5, 5, 0, -20, -40,
|
||||||
|
-50, -40, -30, -30, -30, -30, -40, -50,
|
||||||
|
)
|
||||||
|
|
||||||
|
val BISHOP_TABLE = intArrayOf(
|
||||||
|
-20, -10, -10, -10, -10, -10, -10, -20,
|
||||||
|
-10, 0, 0, 0, 0, 0, 0, -10,
|
||||||
|
-10, 0, 5, 10, 10, 5, 0, -10,
|
||||||
|
-10, 5, 5, 10, 10, 5, 5, -10,
|
||||||
|
-10, 0, 10, 10, 10, 10, 0, -10,
|
||||||
|
-10, 10, 10, 10, 10, 10, 10, -10,
|
||||||
|
-10, 5, 0, 0, 0, 0, 5, -10,
|
||||||
|
-20, -10, -10, -10, -10, -10, -10, -20,
|
||||||
|
)
|
||||||
|
|
||||||
|
val ROOK_TABLE = intArrayOf(
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
5, 10, 10, 10, 10, 10, 10, 5,
|
||||||
|
-5, 0, 0, 0, 0, 0, 0, -5,
|
||||||
|
-5, 0, 0, 0, 0, 0, 0, -5,
|
||||||
|
-5, 0, 0, 0, 0, 0, 0, -5,
|
||||||
|
-5, 0, 0, 0, 0, 0, 0, -5,
|
||||||
|
-5, 0, 0, 0, 0, 0, 0, -5,
|
||||||
|
0, 0, 0, 5, 5, 0, 0, 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
val QUEEN_TABLE = intArrayOf(
|
||||||
|
-20, -10, -10, -5, -5, -10, -10, -20,
|
||||||
|
-10, 0, 0, 0, 0, 0, 0, -10,
|
||||||
|
-10, 0, 5, 5, 5, 5, 0, -10,
|
||||||
|
-5, 0, 5, 5, 5, 5, 0, -5,
|
||||||
|
0, 0, 5, 5, 5, 5, 0, -5,
|
||||||
|
-10, 5, 5, 5, 5, 5, 0, -10,
|
||||||
|
-10, 0, 5, 0, 0, 0, 0, -10,
|
||||||
|
-20, -10, -10, -5, -5, -10, -10, -20,
|
||||||
|
)
|
||||||
|
|
||||||
|
val KING_TABLE = intArrayOf(
|
||||||
|
-30, -40, -40, -50, -50, -40, -40, -30,
|
||||||
|
-30, -40, -40, -50, -50, -40, -40, -30,
|
||||||
|
-30, -40, -40, -50, -50, -40, -40, -30,
|
||||||
|
-30, -40, -40, -50, -50, -40, -40, -30,
|
||||||
|
-20, -30, -30, -40, -40, -30, -30, -20,
|
||||||
|
-10, -20, -20, -20, -20, -20, -20, -10,
|
||||||
|
20, 20, 0, 0, 0, 20, 20, 20,
|
||||||
|
20, 30, 10, 0, 0, 10, 30, 20,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
package org.prolibertate.games.game.chess
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Squares are numbered the way a FEN string reads them: 0 is a8, 7 is h8, and
|
||||||
|
* 63 is h1. Keeping the board in reading order means parsing and printing a
|
||||||
|
* position are both straight loops, and the screen only has to flip the index
|
||||||
|
* when it draws from Black's side.
|
||||||
|
*/
|
||||||
|
fun squareOf(file: Int, rank: Int): Int = (7 - rank) * 8 + file
|
||||||
|
|
||||||
|
/** 0 is the a-file. */
|
||||||
|
fun fileOf(square: Int): Int = square % 8
|
||||||
|
|
||||||
|
/** 0 is rank one. */
|
||||||
|
fun rankOf(square: Int): Int = 7 - square / 8
|
||||||
|
|
||||||
|
fun squareName(square: Int): String =
|
||||||
|
"${'a' + fileOf(square)}${rankOf(square) + 1}"
|
||||||
|
|
||||||
|
fun squareFromName(name: String): Int =
|
||||||
|
squareOf(name[0] - 'a', name[1] - '1')
|
||||||
|
|
||||||
|
/** True for the light squares, which is all the screen needs to colour a board. */
|
||||||
|
fun isLightSquare(square: Int): Boolean = (fileOf(square) + rankOf(square)) % 2 == 1
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class PieceKind(val letter: Char, val glyphIndex: Int, val value: Int) {
|
||||||
|
PAWN('P', 5, 100),
|
||||||
|
KNIGHT('N', 4, 320),
|
||||||
|
BISHOP('B', 3, 330),
|
||||||
|
ROOK('R', 2, 500),
|
||||||
|
QUEEN('Q', 1, 900),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The king has no material value: it is never captured, and a search that
|
||||||
|
* could trade it for anything would happily do so.
|
||||||
|
*/
|
||||||
|
KING('K', 0, 0),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromLetter(letter: Char): PieceKind? =
|
||||||
|
entries.firstOrNull { it.letter == letter.uppercaseChar() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Piece(val kind: PieceKind, val white: Boolean) {
|
||||||
|
/** FEN notation: white in upper case, black in lower. */
|
||||||
|
val letter: Char get() = if (white) kind.letter else kind.letter.lowercaseChar()
|
||||||
|
|
||||||
|
/** The Unicode chess figurine, which is how the board is drawn. */
|
||||||
|
val glyph: String get() = (if (white) WHITE_GLYPHS else BLACK_GLYPHS)[kind.glyphIndex].toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val WHITE_GLYPHS = "♔♕♖♗♘♙"
|
||||||
|
private const val BLACK_GLYPHS = "♚♛♜♝♞♟"
|
||||||
|
|
||||||
|
/** Which castles are still available, regardless of whether they are legal now. */
|
||||||
|
@Serializable
|
||||||
|
data class CastlingRights(
|
||||||
|
val whiteKingSide: Boolean = true,
|
||||||
|
val whiteQueenSide: Boolean = true,
|
||||||
|
val blackKingSide: Boolean = true,
|
||||||
|
val blackQueenSide: Boolean = true,
|
||||||
|
) {
|
||||||
|
fun kingSide(white: Boolean): Boolean = if (white) whiteKingSide else blackKingSide
|
||||||
|
fun queenSide(white: Boolean): Boolean = if (white) whiteQueenSide else blackQueenSide
|
||||||
|
|
||||||
|
/** The FEN field, or "-" when nobody may castle. */
|
||||||
|
fun toFen(): String = buildString {
|
||||||
|
if (whiteKingSide) append('K')
|
||||||
|
if (whiteQueenSide) append('Q')
|
||||||
|
if (blackKingSide) append('k')
|
||||||
|
if (blackQueenSide) append('q')
|
||||||
|
if (isEmpty()) append('-')
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromFen(field: String): CastlingRights = CastlingRights(
|
||||||
|
whiteKingSide = field.contains('K'),
|
||||||
|
whiteQueenSide = field.contains('Q'),
|
||||||
|
blackKingSide = field.contains('k'),
|
||||||
|
blackQueenSide = field.contains('q'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A move is only ever from one square to another, plus what a pawn turns into.
|
||||||
|
* Castling is recorded as the king's two-square step and en passant as the
|
||||||
|
* pawn's diagonal — the rules work out the rest from the position.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ChessMove(
|
||||||
|
val from: Int,
|
||||||
|
val to: Int,
|
||||||
|
val promotion: PieceKind? = null,
|
||||||
|
) {
|
||||||
|
/** Long algebraic, as used by every engine protocol: e2e4, e7e8q. */
|
||||||
|
override fun toString(): String =
|
||||||
|
squareName(from) + squareName(to) + (promotion?.letter?.lowercaseChar() ?: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class ChessPhase { PLAYING, GAME_OVER }
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class ChessOutcome(val label: String) {
|
||||||
|
// Named for who wins rather than who was mated: every caller wants the
|
||||||
|
// result, and "White mated" reads either way round.
|
||||||
|
WHITE_WINS("White wins by checkmate"),
|
||||||
|
BLACK_WINS("Black wins by checkmate"),
|
||||||
|
STALEMATE("Draw by stalemate"),
|
||||||
|
FIFTY_MOVE("Draw by the fifty-move rule"),
|
||||||
|
REPETITION("Draw by threefold repetition"),
|
||||||
|
INSUFFICIENT_MATERIAL("Draw — neither side can mate"),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How hard the computer thinks about it. */
|
||||||
|
@Serializable
|
||||||
|
enum class ChessLevel(val label: String, val depth: Int) {
|
||||||
|
CASUAL("Casual", depth = 2),
|
||||||
|
CLUB("Club", depth = 3),
|
||||||
|
STRONG("Strong", depth = 4),
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChessOptions(
|
||||||
|
val level: ChessLevel = ChessLevel.CLUB,
|
||||||
|
/** A hundred half-moves with no capture and no pawn move is a draw. */
|
||||||
|
val fiftyMoveRule: Boolean = true,
|
||||||
|
/** The same position three times over is a draw. */
|
||||||
|
val threefoldRepetition: Boolean = true,
|
||||||
|
/**
|
||||||
|
* Where the game starts, in FEN. Blank means the ordinary opening array;
|
||||||
|
* anything else lets a table set up a puzzle or resume an adjourned game.
|
||||||
|
*/
|
||||||
|
val startingFen: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChessState(
|
||||||
|
val options: ChessOptions,
|
||||||
|
/** Sixty-four squares in FEN reading order; null is an empty square. */
|
||||||
|
val board: List<Piece?>,
|
||||||
|
val whiteToMove: Boolean,
|
||||||
|
val castling: CastlingRights,
|
||||||
|
/** The square a pawn just skipped over, which is where it may be taken. */
|
||||||
|
val enPassant: Int?,
|
||||||
|
val halfmoveClock: Int,
|
||||||
|
val fullmoveNumber: Int,
|
||||||
|
val phase: ChessPhase,
|
||||||
|
val outcome: ChessOutcome?,
|
||||||
|
val lastMove: ChessMove?,
|
||||||
|
/**
|
||||||
|
* One entry per position reached, in the four-field form that decides
|
||||||
|
* repetition — the clocks are deliberately left out, since a position
|
||||||
|
* repeats whether or not the same number of moves has been played.
|
||||||
|
*/
|
||||||
|
val repetitionKeys: List<String>,
|
||||||
|
/** The game score in algebraic notation, one entry per half-move. */
|
||||||
|
val moveLog: List<String>,
|
||||||
|
) {
|
||||||
|
/** Seat 0 is White, seat 1 is Black. */
|
||||||
|
val turnSeat: Int get() = if (whiteToMove) WHITE_SEAT else BLACK_SEAT
|
||||||
|
|
||||||
|
fun kingSquare(white: Boolean): Int? =
|
||||||
|
board.indexOfFirst { it != null && it.white == white && it.kind == PieceKind.KING }
|
||||||
|
.takeIf { it >= 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const val WHITE_SEAT = 0
|
||||||
|
const val BLACK_SEAT = 1
|
||||||
|
|
||||||
|
fun seatIsWhite(seat: Int): Boolean = seat == WHITE_SEAT
|
||||||
|
|
||||||
|
const val START_FEN = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|
||||||
|
|
||||||
|
/** A position with everything needed to resume it: board, side, rights, clocks. */
|
||||||
|
data class FenPosition(
|
||||||
|
val board: List<Piece?>,
|
||||||
|
val whiteToMove: Boolean,
|
||||||
|
val castling: CastlingRights,
|
||||||
|
val enPassant: Int?,
|
||||||
|
val halfmoveClock: Int,
|
||||||
|
val fullmoveNumber: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a FEN string.
|
||||||
|
*
|
||||||
|
* This is what the tests are built on — the standard perft positions are all
|
||||||
|
* published as FEN — and it is also what lets a game start from something other
|
||||||
|
* than the opening array.
|
||||||
|
*/
|
||||||
|
fun parseFen(fen: String): FenPosition {
|
||||||
|
val fields = fen.trim().split(" ")
|
||||||
|
require(fields.size >= 4) { "FEN needs at least four fields: $fen" }
|
||||||
|
|
||||||
|
val board = arrayOfNulls<Piece>(64)
|
||||||
|
var index = 0
|
||||||
|
for (character in fields[0]) {
|
||||||
|
when {
|
||||||
|
character == '/' -> Unit
|
||||||
|
character.isDigit() -> index += character - '0'
|
||||||
|
else -> {
|
||||||
|
val kind = requireNotNull(PieceKind.fromLetter(character)) {
|
||||||
|
"Unknown piece '$character' in $fen"
|
||||||
|
}
|
||||||
|
require(index < 64) { "Too many squares in $fen" }
|
||||||
|
board[index] = Piece(kind, white = character.isUpperCase())
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require(index == 64) { "FEN describes $index squares, not 64: $fen" }
|
||||||
|
|
||||||
|
return FenPosition(
|
||||||
|
board = board.toList(),
|
||||||
|
whiteToMove = fields[1] == "w",
|
||||||
|
castling = CastlingRights.fromFen(fields[2]),
|
||||||
|
enPassant = fields[3].takeIf { it != "-" }?.let { squareFromName(it) },
|
||||||
|
halfmoveClock = fields.getOrNull(4)?.toIntOrNull() ?: 0,
|
||||||
|
fullmoveNumber = fields.getOrNull(5)?.toIntOrNull() ?: 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first four FEN fields — the part that decides whether a position repeats. */
|
||||||
|
fun positionKey(
|
||||||
|
board: List<Piece?>,
|
||||||
|
whiteToMove: Boolean,
|
||||||
|
castling: CastlingRights,
|
||||||
|
enPassant: Int?,
|
||||||
|
): String = buildString {
|
||||||
|
for (rank in 0 until 8) {
|
||||||
|
var empty = 0
|
||||||
|
for (file in 0 until 8) {
|
||||||
|
val piece = board[rank * 8 + file]
|
||||||
|
if (piece == null) {
|
||||||
|
empty++
|
||||||
|
} else {
|
||||||
|
if (empty > 0) { append(empty); empty = 0 }
|
||||||
|
append(piece.letter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (empty > 0) append(empty)
|
||||||
|
if (rank < 7) append('/')
|
||||||
|
}
|
||||||
|
append(' ').append(if (whiteToMove) 'w' else 'b')
|
||||||
|
append(' ').append(castling.toFen())
|
||||||
|
append(' ').append(enPassant?.let { squareName(it) } ?: "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ChessState.toFen(): String =
|
||||||
|
positionKey(board, whiteToMove, castling, enPassant) + " $halfmoveClock $fullmoveNumber"
|
||||||
@@ -0,0 +1,577 @@
|
|||||||
|
package org.prolibertate.games.game.chess
|
||||||
|
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.prolibertate.games.game.GameCatalog
|
||||||
|
import org.prolibertate.games.game.engine.GameRules
|
||||||
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chess as a pure state machine. See RULES-chess.md.
|
||||||
|
*
|
||||||
|
* Move generation is pseudo-legal followed by a king-safety filter: every
|
||||||
|
* candidate is played onto a copy of the board and thrown away if it leaves the
|
||||||
|
* mover in check. That is slower than tracking pins, and it is the reason the
|
||||||
|
* perft counts in the tests match published figures exactly — there is no
|
||||||
|
* special case to get subtly wrong.
|
||||||
|
*/
|
||||||
|
object ChessRules : GameRules<ChessState, ChessMove> {
|
||||||
|
|
||||||
|
override val gameId: String = GameCatalog.CHESS
|
||||||
|
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun initialState(config: TableConfig): ChessState {
|
||||||
|
require(config.seats.size == 2) { "Chess is a two-handed game" }
|
||||||
|
val options = json.decodeFromString<ChessOptions>(config.optionsJson)
|
||||||
|
return fromPosition(options, options.startingFen.ifBlank { START_FEN })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds a state from a FEN string, which is also how a puzzle is loaded. */
|
||||||
|
fun fromPosition(options: ChessOptions, fen: String): ChessState {
|
||||||
|
val position = parseFen(fen)
|
||||||
|
return ChessState(
|
||||||
|
options = options,
|
||||||
|
board = position.board,
|
||||||
|
whiteToMove = position.whiteToMove,
|
||||||
|
castling = position.castling,
|
||||||
|
enPassant = position.enPassant,
|
||||||
|
halfmoveClock = position.halfmoveClock,
|
||||||
|
fullmoveNumber = position.fullmoveNumber,
|
||||||
|
phase = ChessPhase.PLAYING,
|
||||||
|
outcome = null,
|
||||||
|
lastMove = null,
|
||||||
|
repetitionKeys = listOf(
|
||||||
|
positionKey(
|
||||||
|
position.board,
|
||||||
|
position.whiteToMove,
|
||||||
|
position.castling,
|
||||||
|
position.enPassant,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
moveLog = emptyList(),
|
||||||
|
).withTerminalCheck()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun currentSeat(state: ChessState): Int? =
|
||||||
|
if (state.phase == ChessPhase.PLAYING) state.turnSeat else null
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Attacks
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private val KNIGHT_STEPS =
|
||||||
|
listOf(1 to 2, 2 to 1, 2 to -1, 1 to -2, -1 to -2, -2 to -1, -2 to 1, -1 to 2)
|
||||||
|
private val KING_STEPS =
|
||||||
|
listOf(1 to 0, 1 to 1, 0 to 1, -1 to 1, -1 to 0, -1 to -1, 0 to -1, 1 to -1)
|
||||||
|
private val ROOK_DIRS = listOf(1 to 0, -1 to 0, 0 to 1, 0 to -1)
|
||||||
|
private val BISHOP_DIRS = listOf(1 to 1, 1 to -1, -1 to 1, -1 to -1)
|
||||||
|
|
||||||
|
private fun pieceAt(board: List<Piece?>, file: Int, rank: Int): Piece? =
|
||||||
|
if (file in 0..7 && rank in 0..7) board[squareOf(file, rank)] else null
|
||||||
|
|
||||||
|
/** Whether [square] is attacked by the side [byWhite], king included. */
|
||||||
|
fun isAttacked(board: List<Piece?>, square: Int, byWhite: Boolean): Boolean {
|
||||||
|
val file = fileOf(square)
|
||||||
|
val rank = rankOf(square)
|
||||||
|
|
||||||
|
// A pawn attacks forwards, so an attacker stands one rank behind.
|
||||||
|
val pawnRank = rank - if (byWhite) 1 else -1
|
||||||
|
for (side in intArrayOf(-1, 1)) {
|
||||||
|
val found = pieceAt(board, file + side, pawnRank)
|
||||||
|
if (found != null && found.white == byWhite && found.kind == PieceKind.PAWN) return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for ((df, dr) in KNIGHT_STEPS) {
|
||||||
|
val found = pieceAt(board, file + df, rank + dr)
|
||||||
|
if (found != null && found.white == byWhite && found.kind == PieceKind.KNIGHT) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for ((df, dr) in KING_STEPS) {
|
||||||
|
val found = pieceAt(board, file + df, rank + dr)
|
||||||
|
if (found != null && found.white == byWhite && found.kind == PieceKind.KING) return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raysHit(board, file, rank, byWhite, ROOK_DIRS, PieceKind.ROOK)) return true
|
||||||
|
if (raysHit(board, file, rank, byWhite, BISHOP_DIRS, PieceKind.BISHOP)) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Walks each direction until something blocks, looking for [kind] or a queen. */
|
||||||
|
private fun raysHit(
|
||||||
|
board: List<Piece?>,
|
||||||
|
file: Int,
|
||||||
|
rank: Int,
|
||||||
|
byWhite: Boolean,
|
||||||
|
directions: List<Pair<Int, Int>>,
|
||||||
|
kind: PieceKind,
|
||||||
|
): Boolean {
|
||||||
|
for ((df, dr) in directions) {
|
||||||
|
var f = file + df
|
||||||
|
var r = rank + dr
|
||||||
|
while (f in 0..7 && r in 0..7) {
|
||||||
|
val found = board[squareOf(f, r)]
|
||||||
|
if (found != null) {
|
||||||
|
if (found.white == byWhite &&
|
||||||
|
(found.kind == kind || found.kind == PieceKind.QUEEN)
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
f += df
|
||||||
|
r += dr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the side to move is in check. */
|
||||||
|
fun inCheck(state: ChessState): Boolean = inCheck(state.board, state.whiteToMove)
|
||||||
|
|
||||||
|
fun inCheck(board: List<Piece?>, white: Boolean): Boolean {
|
||||||
|
val king = board.indexOfFirst { it != null && it.white == white && it.kind == PieceKind.KING }
|
||||||
|
// A position with no king cannot be in check. Only reachable from a
|
||||||
|
// hand-written FEN, but it must not throw.
|
||||||
|
return king >= 0 && isAttacked(board, king, byWhite = !white)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Move generation
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
override fun legalMoves(state: ChessState, seat: Int): List<ChessMove> {
|
||||||
|
if (currentSeat(state) != seat) return emptyList()
|
||||||
|
return legalMoves(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every move the side to move may actually play. */
|
||||||
|
fun legalMoves(state: ChessState): List<ChessMove> {
|
||||||
|
if (state.phase != ChessPhase.PLAYING) return emptyList()
|
||||||
|
val white = state.whiteToMove
|
||||||
|
return pseudoMoves(state).filter { move ->
|
||||||
|
!inCheck(make(state, move).board, white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moves that obey the way pieces travel, but may leave the king in check. */
|
||||||
|
private fun pseudoMoves(state: ChessState): List<ChessMove> {
|
||||||
|
val moves = mutableListOf<ChessMove>()
|
||||||
|
val white = state.whiteToMove
|
||||||
|
for (square in 0 until 64) {
|
||||||
|
val piece = state.board[square] ?: continue
|
||||||
|
if (piece.white != white) continue
|
||||||
|
when (piece.kind) {
|
||||||
|
PieceKind.PAWN -> pawnMoves(state, square, white, moves)
|
||||||
|
PieceKind.KNIGHT -> stepMoves(state, square, white, KNIGHT_STEPS, moves)
|
||||||
|
PieceKind.KING -> {
|
||||||
|
stepMoves(state, square, white, KING_STEPS, moves)
|
||||||
|
castlingMoves(state, square, white, moves)
|
||||||
|
}
|
||||||
|
|
||||||
|
PieceKind.BISHOP -> slideMoves(state, square, white, BISHOP_DIRS, moves)
|
||||||
|
PieceKind.ROOK -> slideMoves(state, square, white, ROOK_DIRS, moves)
|
||||||
|
PieceKind.QUEEN ->
|
||||||
|
slideMoves(state, square, white, ROOK_DIRS + BISHOP_DIRS, moves)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return moves
|
||||||
|
}
|
||||||
|
|
||||||
|
private val PROMOTIONS =
|
||||||
|
listOf(PieceKind.QUEEN, PieceKind.ROOK, PieceKind.BISHOP, PieceKind.KNIGHT)
|
||||||
|
|
||||||
|
private fun pawnMoves(
|
||||||
|
state: ChessState,
|
||||||
|
from: Int,
|
||||||
|
white: Boolean,
|
||||||
|
moves: MutableList<ChessMove>,
|
||||||
|
) {
|
||||||
|
val board = state.board
|
||||||
|
val file = fileOf(from)
|
||||||
|
val rank = rankOf(from)
|
||||||
|
val forward = if (white) 1 else -1
|
||||||
|
val startRank = if (white) 1 else 6
|
||||||
|
val lastRank = if (white) 7 else 0
|
||||||
|
|
||||||
|
fun addPawnMove(to: Int) {
|
||||||
|
if (rankOf(to) == lastRank) {
|
||||||
|
PROMOTIONS.forEach { moves += ChessMove(from, to, it) }
|
||||||
|
} else {
|
||||||
|
moves += ChessMove(from, to)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val ahead = rank + forward
|
||||||
|
if (ahead in 0..7 && board[squareOf(file, ahead)] == null) {
|
||||||
|
addPawnMove(squareOf(file, ahead))
|
||||||
|
val twoAhead = rank + 2 * forward
|
||||||
|
if (rank == startRank && board[squareOf(file, twoAhead)] == null) {
|
||||||
|
moves += ChessMove(from, squareOf(file, twoAhead))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ahead !in 0..7) return
|
||||||
|
for (side in intArrayOf(-1, 1)) {
|
||||||
|
val captureFile = file + side
|
||||||
|
if (captureFile !in 0..7) continue
|
||||||
|
val to = squareOf(captureFile, ahead)
|
||||||
|
val occupant = board[to]
|
||||||
|
when {
|
||||||
|
occupant != null && occupant.white != white -> addPawnMove(to)
|
||||||
|
// The square behind a pawn that has just stepped two.
|
||||||
|
occupant == null && to == state.enPassant -> moves += ChessMove(from, to)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stepMoves(
|
||||||
|
state: ChessState,
|
||||||
|
from: Int,
|
||||||
|
white: Boolean,
|
||||||
|
steps: List<Pair<Int, Int>>,
|
||||||
|
moves: MutableList<ChessMove>,
|
||||||
|
) {
|
||||||
|
val file = fileOf(from)
|
||||||
|
val rank = rankOf(from)
|
||||||
|
for ((df, dr) in steps) {
|
||||||
|
val f = file + df
|
||||||
|
val r = rank + dr
|
||||||
|
if (f !in 0..7 || r !in 0..7) continue
|
||||||
|
val to = squareOf(f, r)
|
||||||
|
val occupant = state.board[to]
|
||||||
|
if (occupant == null || occupant.white != white) moves += ChessMove(from, to)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun slideMoves(
|
||||||
|
state: ChessState,
|
||||||
|
from: Int,
|
||||||
|
white: Boolean,
|
||||||
|
directions: List<Pair<Int, Int>>,
|
||||||
|
moves: MutableList<ChessMove>,
|
||||||
|
) {
|
||||||
|
val file = fileOf(from)
|
||||||
|
val rank = rankOf(from)
|
||||||
|
for ((df, dr) in directions) {
|
||||||
|
var f = file + df
|
||||||
|
var r = rank + dr
|
||||||
|
while (f in 0..7 && r in 0..7) {
|
||||||
|
val to = squareOf(f, r)
|
||||||
|
val occupant = state.board[to]
|
||||||
|
if (occupant == null) {
|
||||||
|
moves += ChessMove(from, to)
|
||||||
|
} else {
|
||||||
|
if (occupant.white != white) moves += ChessMove(from, to)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
f += df
|
||||||
|
r += dr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun castlingMoves(
|
||||||
|
state: ChessState,
|
||||||
|
from: Int,
|
||||||
|
white: Boolean,
|
||||||
|
moves: MutableList<ChessMove>,
|
||||||
|
) {
|
||||||
|
val homeRank = if (white) 0 else 7
|
||||||
|
// A hand-written FEN can claim rights for a king that has moved, so the
|
||||||
|
// king's own square is checked rather than trusted.
|
||||||
|
if (from != squareOf(4, homeRank)) return
|
||||||
|
// Castling out of check is never allowed.
|
||||||
|
if (isAttacked(state.board, from, byWhite = !white)) return
|
||||||
|
|
||||||
|
fun clear(vararg files: Int) = files.all { state.board[squareOf(it, homeRank)] == null }
|
||||||
|
fun safe(vararg files: Int) = files.all {
|
||||||
|
!isAttacked(state.board, squareOf(it, homeRank), byWhite = !white)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rookAt(file: Int): Boolean {
|
||||||
|
val piece = state.board[squareOf(file, homeRank)]
|
||||||
|
return piece != null && piece.white == white && piece.kind == PieceKind.ROOK
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.castling.kingSide(white) && rookAt(7) && clear(5, 6) && safe(5, 6)) {
|
||||||
|
moves += ChessMove(from, squareOf(6, homeRank))
|
||||||
|
}
|
||||||
|
// The b-file square must be empty but may be attacked — the king does
|
||||||
|
// not pass over it.
|
||||||
|
if (state.castling.queenSide(white) && rookAt(0) && clear(1, 2, 3) && safe(2, 3)) {
|
||||||
|
moves += ChessMove(from, squareOf(2, homeRank))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Making a move
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** The position a move produces, before any terminal test. */
|
||||||
|
private data class Made(
|
||||||
|
val board: List<Piece?>,
|
||||||
|
val castling: CastlingRights,
|
||||||
|
val enPassant: Int?,
|
||||||
|
val halfmoveClock: Int,
|
||||||
|
val captured: Piece?,
|
||||||
|
val castled: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun make(state: ChessState, move: ChessMove): Made {
|
||||||
|
val board = state.board.toMutableList()
|
||||||
|
val piece = requireNotNull(board[move.from]) { "No piece on ${squareName(move.from)}" }
|
||||||
|
var captured = board[move.to]
|
||||||
|
|
||||||
|
// Taking en passant removes a pawn that is not on the target square.
|
||||||
|
if (piece.kind == PieceKind.PAWN && move.to == state.enPassant && captured == null) {
|
||||||
|
val victim = squareOf(fileOf(move.to), rankOf(move.from))
|
||||||
|
captured = board[victim]
|
||||||
|
board[victim] = null
|
||||||
|
}
|
||||||
|
|
||||||
|
board[move.from] = null
|
||||||
|
board[move.to] = move.promotion?.let { Piece(it, piece.white) } ?: piece
|
||||||
|
|
||||||
|
val castled = piece.kind == PieceKind.KING &&
|
||||||
|
abs(fileOf(move.to) - fileOf(move.from)) == 2
|
||||||
|
if (castled) {
|
||||||
|
val homeRank = rankOf(move.from)
|
||||||
|
val rookFrom = squareOf(if (fileOf(move.to) == 6) 7 else 0, homeRank)
|
||||||
|
val rookTo = squareOf(if (fileOf(move.to) == 6) 5 else 3, homeRank)
|
||||||
|
board[rookTo] = board[rookFrom]
|
||||||
|
board[rookFrom] = null
|
||||||
|
}
|
||||||
|
|
||||||
|
val doubleStep = piece.kind == PieceKind.PAWN &&
|
||||||
|
abs(rankOf(move.to) - rankOf(move.from)) == 2
|
||||||
|
val enPassant = if (doubleStep) {
|
||||||
|
squareOf(fileOf(move.from), (rankOf(move.from) + rankOf(move.to)) / 2)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
var castling = state.castling
|
||||||
|
if (piece.kind == PieceKind.KING) {
|
||||||
|
castling = if (piece.white) {
|
||||||
|
castling.copy(whiteKingSide = false, whiteQueenSide = false)
|
||||||
|
} else {
|
||||||
|
castling.copy(blackKingSide = false, blackQueenSide = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A rook that moves loses its right; so does one that is captured on
|
||||||
|
// its own corner, which is the case that is easy to forget.
|
||||||
|
castling = clearCornerRight(castling, move.from)
|
||||||
|
castling = clearCornerRight(castling, move.to)
|
||||||
|
|
||||||
|
val resetsClock = piece.kind == PieceKind.PAWN || captured != null
|
||||||
|
return Made(
|
||||||
|
board = board,
|
||||||
|
castling = castling,
|
||||||
|
enPassant = enPassant,
|
||||||
|
halfmoveClock = if (resetsClock) 0 else state.halfmoveClock + 1,
|
||||||
|
captured = captured,
|
||||||
|
castled = castled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearCornerRight(rights: CastlingRights, square: Int): CastlingRights =
|
||||||
|
when (square) {
|
||||||
|
squareOf(0, 0) -> rights.copy(whiteQueenSide = false)
|
||||||
|
squareOf(7, 0) -> rights.copy(whiteKingSide = false)
|
||||||
|
squareOf(0, 7) -> rights.copy(blackQueenSide = false)
|
||||||
|
squareOf(7, 7) -> rights.copy(blackKingSide = false)
|
||||||
|
else -> rights
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun applyMove(state: ChessState, seat: Int, move: ChessMove): ChessState {
|
||||||
|
require(currentSeat(state) == seat) { "Not seat $seat's turn" }
|
||||||
|
require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" }
|
||||||
|
|
||||||
|
val notation = baseNotation(state, move)
|
||||||
|
val made = make(state, move)
|
||||||
|
val key = positionKey(made.board, !state.whiteToMove, made.castling, made.enPassant)
|
||||||
|
|
||||||
|
val next = state.copy(
|
||||||
|
board = made.board,
|
||||||
|
whiteToMove = !state.whiteToMove,
|
||||||
|
castling = made.castling,
|
||||||
|
enPassant = made.enPassant,
|
||||||
|
halfmoveClock = made.halfmoveClock,
|
||||||
|
fullmoveNumber = state.fullmoveNumber + if (state.whiteToMove) 0 else 1,
|
||||||
|
lastMove = move,
|
||||||
|
repetitionKeys = state.repetitionKeys + key,
|
||||||
|
).withTerminalCheck()
|
||||||
|
|
||||||
|
val suffix = when {
|
||||||
|
next.outcome == ChessOutcome.WHITE_WINS || next.outcome == ChessOutcome.BLACK_WINS ->
|
||||||
|
"#"
|
||||||
|
|
||||||
|
inCheck(next) -> "+"
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
return next.copy(moveLog = state.moveLog + (notation + suffix))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies the terminal tests to a position whose side to move is set. */
|
||||||
|
private fun ChessState.withTerminalCheck(): ChessState {
|
||||||
|
if (legalMoves(this).isEmpty()) {
|
||||||
|
val outcome = when {
|
||||||
|
// Whoever is to move has been mated, so the other side wins.
|
||||||
|
inCheck(this) -> if (whiteToMove) {
|
||||||
|
ChessOutcome.BLACK_WINS
|
||||||
|
} else {
|
||||||
|
ChessOutcome.WHITE_WINS
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> ChessOutcome.STALEMATE
|
||||||
|
}
|
||||||
|
return copy(phase = ChessPhase.GAME_OVER, outcome = outcome)
|
||||||
|
}
|
||||||
|
if (insufficientMaterial(board)) {
|
||||||
|
return copy(phase = ChessPhase.GAME_OVER, outcome = ChessOutcome.INSUFFICIENT_MATERIAL)
|
||||||
|
}
|
||||||
|
if (options.fiftyMoveRule && halfmoveClock >= 100) {
|
||||||
|
return copy(phase = ChessPhase.GAME_OVER, outcome = ChessOutcome.FIFTY_MOVE)
|
||||||
|
}
|
||||||
|
if (options.threefoldRepetition &&
|
||||||
|
repetitionKeys.count { it == repetitionKeys.last() } >= 3
|
||||||
|
) {
|
||||||
|
return copy(phase = ChessPhase.GAME_OVER, outcome = ChessOutcome.REPETITION)
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Positions from which no sequence of legal moves can produce mate. */
|
||||||
|
fun insufficientMaterial(board: List<Piece?>): Boolean {
|
||||||
|
val pieces = board.withIndex().mapNotNull { (square, piece) ->
|
||||||
|
piece?.takeIf { it.kind != PieceKind.KING }?.let { square to it }
|
||||||
|
}
|
||||||
|
if (pieces.any {
|
||||||
|
it.second.kind == PieceKind.PAWN ||
|
||||||
|
it.second.kind == PieceKind.ROOK ||
|
||||||
|
it.second.kind == PieceKind.QUEEN
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return when (pieces.size) {
|
||||||
|
// Bare kings, or a king and one minor piece.
|
||||||
|
0, 1 -> true
|
||||||
|
// Two bishops on the same colour square can never mate, whichever
|
||||||
|
// sides they belong to.
|
||||||
|
2 -> pieces.all { it.second.kind == PieceKind.BISHOP } &&
|
||||||
|
isLightSquare(pieces[0].first) == isLightSquare(pieces[1].first)
|
||||||
|
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Notation
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Algebraic notation without the check or mate suffix, which needs the result. */
|
||||||
|
private fun baseNotation(state: ChessState, move: ChessMove): String {
|
||||||
|
val piece = state.board[move.from] ?: return move.toString()
|
||||||
|
if (piece.kind == PieceKind.KING && abs(fileOf(move.to) - fileOf(move.from)) == 2) {
|
||||||
|
return if (fileOf(move.to) == 6) "O-O" else "O-O-O"
|
||||||
|
}
|
||||||
|
|
||||||
|
val captures = state.board[move.to] != null ||
|
||||||
|
(piece.kind == PieceKind.PAWN && move.to == state.enPassant)
|
||||||
|
|
||||||
|
return buildString {
|
||||||
|
if (piece.kind == PieceKind.PAWN) {
|
||||||
|
if (captures) append('a' + fileOf(move.from))
|
||||||
|
} else {
|
||||||
|
append(piece.kind.letter)
|
||||||
|
append(disambiguation(state, move, piece.kind))
|
||||||
|
}
|
||||||
|
if (captures) append('x')
|
||||||
|
append(squareName(move.to))
|
||||||
|
move.promotion?.let { append('=').append(it.letter) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The least that distinguishes this move from another of the same kind to
|
||||||
|
* the same square — the file if that is enough, otherwise the rank, and
|
||||||
|
* both when two pieces share a file and a rank with a third.
|
||||||
|
*/
|
||||||
|
private fun disambiguation(state: ChessState, move: ChessMove, kind: PieceKind): String {
|
||||||
|
val rivals = legalMoves(state).filter { other ->
|
||||||
|
other.to == move.to &&
|
||||||
|
other.from != move.from &&
|
||||||
|
state.board[other.from]?.kind == kind
|
||||||
|
}
|
||||||
|
if (rivals.isEmpty()) return ""
|
||||||
|
val fileIsUnique = rivals.none { fileOf(it.from) == fileOf(move.from) }
|
||||||
|
if (fileIsUnique) return ('a' + fileOf(move.from)).toString()
|
||||||
|
val rankIsUnique = rivals.none { rankOf(it.from) == rankOf(move.from) }
|
||||||
|
if (rankIsUnique) return (rankOf(move.from) + 1).toString()
|
||||||
|
return squareName(move.from)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Engine contract
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
override fun isFinished(state: ChessState): Boolean = state.phase == ChessPhase.GAME_OVER
|
||||||
|
|
||||||
|
override fun summary(state: ChessState): String = state.outcome?.label
|
||||||
|
?: if (state.whiteToMove) "White to move" else "Black to move"
|
||||||
|
|
||||||
|
/** Chess hides nothing: both players see the same board. */
|
||||||
|
override fun viewFor(state: ChessState, seat: Int): ChessState = state
|
||||||
|
|
||||||
|
override fun encodeState(state: ChessState): String = json.encodeToString(state)
|
||||||
|
override fun decodeState(json: String): ChessState = this.json.decodeFromString(json)
|
||||||
|
override fun encodeMove(move: ChessMove): String = json.encodeToString(move)
|
||||||
|
override fun decodeMove(json: String): ChessMove = this.json.decodeFromString(json)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts the leaf nodes at [depth]. Only used by the tests, but it lives
|
||||||
|
* here because it is the single most valuable thing that can be said about
|
||||||
|
* a move generator: the published counts for a given position are exact, so
|
||||||
|
* a generator that matches them at depth four has essentially no room left
|
||||||
|
* for a rule it gets wrong.
|
||||||
|
*/
|
||||||
|
fun perft(state: ChessState, depth: Int): Long {
|
||||||
|
if (depth == 0) return 1L
|
||||||
|
val moves = legalMoves(state)
|
||||||
|
if (depth == 1) return moves.size.toLong()
|
||||||
|
return moves.sumOf { perft(advanced(state, it), depth - 1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The position after [move] with none of the terminal tests applied.
|
||||||
|
*
|
||||||
|
* The search walks millions of positions it will never keep, and running
|
||||||
|
* the repetition and fifty-move checks on every one of them would cost more
|
||||||
|
* than the search itself. Anything that ends up on the board goes through
|
||||||
|
* [applyMove] instead.
|
||||||
|
*/
|
||||||
|
fun advanced(state: ChessState, move: ChessMove): ChessState {
|
||||||
|
val made = make(state, move)
|
||||||
|
return state.copy(
|
||||||
|
board = made.board,
|
||||||
|
whiteToMove = !state.whiteToMove,
|
||||||
|
castling = made.castling,
|
||||||
|
enPassant = made.enPassant,
|
||||||
|
halfmoveClock = made.halfmoveClock,
|
||||||
|
lastMove = move,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether [move] takes something — including en passant, which takes nothing on its square. */
|
||||||
|
fun isCapture(state: ChessState, move: ChessMove): Boolean =
|
||||||
|
state.board[move.to] != null ||
|
||||||
|
(state.board[move.from]?.kind == PieceKind.PAWN && move.to == state.enPassant)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package org.prolibertate.games.game.crazyeights
|
||||||
|
|
||||||
|
import org.prolibertate.games.game.cards.Suit
|
||||||
|
import org.prolibertate.games.game.engine.GameAi
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A heuristic Crazy 8s opponent.
|
||||||
|
*
|
||||||
|
* It sheds expensive cards first, keeps its eights back for when it is stuck,
|
||||||
|
* and when it does play one it names the suit it holds most of.
|
||||||
|
*/
|
||||||
|
class CrazyEightsAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||||
|
GameAi<CrazyEightsState, CrazyEightsMove> {
|
||||||
|
|
||||||
|
enum class Difficulty(
|
||||||
|
/** Play an eight anyway once the hand is down to this size. */
|
||||||
|
val eightEndgame: Int,
|
||||||
|
) {
|
||||||
|
EASY(eightEndgame = 99),
|
||||||
|
NORMAL(eightEndgame = 3),
|
||||||
|
CAUTIOUS(eightEndgame = 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun chooseMove(
|
||||||
|
state: CrazyEightsState,
|
||||||
|
seat: Int,
|
||||||
|
legal: List<CrazyEightsMove>,
|
||||||
|
): CrazyEightsMove {
|
||||||
|
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||||
|
val plays = legal.filterIsInstance<PlayCard>()
|
||||||
|
if (plays.isEmpty()) return legal.first()
|
||||||
|
|
||||||
|
val hand = state.hands[seat]
|
||||||
|
val ordinary = plays.filter { !isWild(it.card) }
|
||||||
|
|
||||||
|
// An eight is the only card that always plays, so it is worth more held
|
||||||
|
// than spent — unless the hand is nearly gone, or nothing else works.
|
||||||
|
val useEight = ordinary.isEmpty() || hand.size <= difficulty.eightEndgame
|
||||||
|
val pool = if (useEight) plays else ordinary
|
||||||
|
|
||||||
|
val best = pool
|
||||||
|
.filter { !isWild(it.card) }
|
||||||
|
.maxByOrNull { crazyEightsPenalty(it.card) }
|
||||||
|
|
||||||
|
if (best != null) return best
|
||||||
|
|
||||||
|
// Playing an eight: switch the game to whatever we hold most of.
|
||||||
|
val strongest = Suit.entries.maxByOrNull { suit -> hand.count { it.suit == suit } }
|
||||||
|
?: Suit.SPADES
|
||||||
|
return pool.firstOrNull { it.nominatedSuit == strongest } ?: pool.first()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package org.prolibertate.games.game.crazyeights
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.prolibertate.games.game.cards.Card
|
||||||
|
import org.prolibertate.games.game.cards.Rank
|
||||||
|
import org.prolibertate.games.game.cards.Suit
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CrazyEightsOptions(
|
||||||
|
val playerCount: Int = 4,
|
||||||
|
/** Zero means the usual count: seven for two players, five otherwise. */
|
||||||
|
val startingHand: Int = 0,
|
||||||
|
/** Keep drawing until something is playable, rather than drawing one and passing. */
|
||||||
|
val drawUntilPlayable: Boolean = true,
|
||||||
|
val roundsToPlay: Int = 3,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(playerCount in 2..6) { "Crazy 8s seats two to six" }
|
||||||
|
require(roundsToPlay >= 1) { "roundsToPlay must be positive" }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handSize(): Int = if (startingHand > 0) startingHand else if (playerCount == 2) 7 else 5
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eights are wild and expensive; pictures cost ten; everything else is face value. */
|
||||||
|
fun crazyEightsPenalty(card: Card): Int = when (card.rank) {
|
||||||
|
Rank.EIGHT -> 50
|
||||||
|
Rank.KING, Rank.QUEEN, Rank.JACK, Rank.TEN -> 10
|
||||||
|
Rank.ACE -> 1
|
||||||
|
else -> card.rank.order
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isWild(card: Card): Boolean = card.rank == Rank.EIGHT
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class CrazyEightsPhase { PLAYING, ROUND_OVER, GAME_OVER }
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CrazyEightsState(
|
||||||
|
val options: CrazyEightsOptions,
|
||||||
|
val seed: Long,
|
||||||
|
val roundNumber: Int,
|
||||||
|
val hands: List<List<Card>>,
|
||||||
|
val handCounts: List<Int>,
|
||||||
|
val stock: List<Card>,
|
||||||
|
val discard: List<Card>,
|
||||||
|
/**
|
||||||
|
* The suit that must be followed. Usually the top card's suit, but an eight
|
||||||
|
* is wild and its player names this instead — so it cannot be read off the
|
||||||
|
* discard pile.
|
||||||
|
*/
|
||||||
|
val suitInForce: Suit,
|
||||||
|
val turn: Int,
|
||||||
|
/** Cards drawn so far this turn, which limits how much more may be drawn. */
|
||||||
|
val drawnThisTurn: Int,
|
||||||
|
/** Passes in a row. Reaching the player count means the round is blocked. */
|
||||||
|
val consecutivePasses: Int = 0,
|
||||||
|
val scores: List<Int>,
|
||||||
|
val roundWinner: Int?,
|
||||||
|
val phase: CrazyEightsPhase,
|
||||||
|
val log: List<String>,
|
||||||
|
) {
|
||||||
|
val topCard: Card? get() = discard.lastOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed interface CrazyEightsMove
|
||||||
|
|
||||||
|
/** [nominatedSuit] is set only when playing an eight. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("play")
|
||||||
|
data class PlayCard(val card: Card, val nominatedSuit: Suit? = null) : CrazyEightsMove
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("draw")
|
||||||
|
data object DrawCard : CrazyEightsMove
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("pass")
|
||||||
|
data object PassTurn : CrazyEightsMove
|
||||||
|
|
||||||
|
/** Whether [card] may be played on a pile showing [top] with [suitInForce]. */
|
||||||
|
fun canPlay(card: Card, top: Card?, suitInForce: Suit): Boolean = when {
|
||||||
|
isWild(card) -> true
|
||||||
|
top == null -> true
|
||||||
|
card.suit == suitInForce -> true
|
||||||
|
card.rank == top.rank -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
package org.prolibertate.games.game.crazyeights
|
||||||
|
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.prolibertate.games.game.GameCatalog
|
||||||
|
import org.prolibertate.games.game.cards.Card
|
||||||
|
import org.prolibertate.games.game.cards.Decks
|
||||||
|
import org.prolibertate.games.game.cards.Suit
|
||||||
|
import org.prolibertate.games.game.cards.shuffledWith
|
||||||
|
import org.prolibertate.games.game.engine.GameRules
|
||||||
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crazy 8s as a pure state machine. See RULES-crazy8s.md.
|
||||||
|
*/
|
||||||
|
object CrazyEightsRules : GameRules<CrazyEightsState, CrazyEightsMove> {
|
||||||
|
|
||||||
|
override val gameId: String = GameCatalog.CRAZY_EIGHTS
|
||||||
|
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun initialState(config: TableConfig): CrazyEightsState {
|
||||||
|
val options = json.decodeFromString<CrazyEightsOptions>(config.optionsJson)
|
||||||
|
require(config.seats.size == options.playerCount) {
|
||||||
|
"Seat count ${config.seats.size} does not match ${options.playerCount} players"
|
||||||
|
}
|
||||||
|
return deal(options, config.seed, 0, List(options.playerCount) { 0 }, listOf("Round 1."))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deal(
|
||||||
|
options: CrazyEightsOptions,
|
||||||
|
seed: Long,
|
||||||
|
roundNumber: Int,
|
||||||
|
scores: List<Int>,
|
||||||
|
log: List<String>,
|
||||||
|
): CrazyEightsState {
|
||||||
|
val random = Random(seed + roundNumber * 7919L)
|
||||||
|
var deck = Decks.standard52().shuffledWith(random)
|
||||||
|
|
||||||
|
val hands = mutableListOf<List<Card>>()
|
||||||
|
repeat(options.playerCount) {
|
||||||
|
hands += deck.take(options.handSize())
|
||||||
|
deck = deck.drop(options.handSize())
|
||||||
|
}
|
||||||
|
|
||||||
|
// An eight on top at the start would leave nobody having named a suit,
|
||||||
|
// so the first upcard is taken from further down if need be.
|
||||||
|
val firstIndex = deck.indexOfFirst { !isWild(it) }.takeIf { it >= 0 } ?: 0
|
||||||
|
val upCard = deck[firstIndex]
|
||||||
|
val stock = deck.filterIndexed { index, _ -> index != firstIndex }
|
||||||
|
|
||||||
|
return CrazyEightsState(
|
||||||
|
options = options,
|
||||||
|
seed = seed,
|
||||||
|
roundNumber = roundNumber,
|
||||||
|
hands = hands,
|
||||||
|
handCounts = hands.map { it.size },
|
||||||
|
stock = stock,
|
||||||
|
discard = listOf(upCard),
|
||||||
|
suitInForce = upCard.suit,
|
||||||
|
turn = roundNumber % options.playerCount,
|
||||||
|
drawnThisTurn = 0,
|
||||||
|
consecutivePasses = 0,
|
||||||
|
scores = scores,
|
||||||
|
roundWinner = null,
|
||||||
|
phase = CrazyEightsPhase.PLAYING,
|
||||||
|
log = log,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Advances from [CrazyEightsPhase.ROUND_OVER] into the next deal. */
|
||||||
|
fun nextRound(state: CrazyEightsState): CrazyEightsState {
|
||||||
|
check(state.phase == CrazyEightsPhase.ROUND_OVER) { "Round is not over" }
|
||||||
|
return deal(
|
||||||
|
options = state.options,
|
||||||
|
seed = state.seed,
|
||||||
|
roundNumber = state.roundNumber + 1,
|
||||||
|
scores = state.scores,
|
||||||
|
log = state.log + "Round ${state.roundNumber + 2}.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun currentSeat(state: CrazyEightsState): Int? =
|
||||||
|
if (state.phase == CrazyEightsPhase.PLAYING) state.turn else null
|
||||||
|
|
||||||
|
override fun legalMoves(state: CrazyEightsState, seat: Int): List<CrazyEightsMove> {
|
||||||
|
if (currentSeat(state) != seat) return emptyList()
|
||||||
|
val hand = state.hands[seat]
|
||||||
|
val playable = hand.filter { canPlay(it, state.topCard, state.suitInForce) }
|
||||||
|
|
||||||
|
if (playable.isNotEmpty()) {
|
||||||
|
return playable.flatMap { card ->
|
||||||
|
if (isWild(card)) {
|
||||||
|
// Playing an eight is really four different moves: the card
|
||||||
|
// plus the suit it turns the game to.
|
||||||
|
Suit.entries.map { PlayCard(card, it) }
|
||||||
|
} else {
|
||||||
|
listOf(PlayCard(card))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing to play. There is something to draw as long as the stock has
|
||||||
|
// a card or the discards can be recycled into one — the pile in play
|
||||||
|
// does not count, since it stays put.
|
||||||
|
val somethingToDraw = state.stock.isNotEmpty() || state.discard.size > 1
|
||||||
|
val mayDraw = somethingToDraw &&
|
||||||
|
(state.options.drawUntilPlayable || state.drawnThisTurn == 0)
|
||||||
|
return if (mayDraw) listOf(DrawCard) else listOf(PassTurn)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun applyMove(
|
||||||
|
state: CrazyEightsState,
|
||||||
|
seat: Int,
|
||||||
|
move: CrazyEightsMove,
|
||||||
|
): CrazyEightsState {
|
||||||
|
require(currentSeat(state) == seat) { "Not seat $seat's turn" }
|
||||||
|
require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" }
|
||||||
|
return when (move) {
|
||||||
|
is PlayCard -> applyPlay(state, seat, move)
|
||||||
|
is DrawCard -> applyDraw(state, seat)
|
||||||
|
is PassTurn -> applyPass(state, seat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyPlay(
|
||||||
|
state: CrazyEightsState,
|
||||||
|
seat: Int,
|
||||||
|
move: PlayCard,
|
||||||
|
): CrazyEightsState {
|
||||||
|
val hands = state.hands.toMutableList()
|
||||||
|
hands[seat] = hands[seat] - move.card
|
||||||
|
|
||||||
|
val suit = if (isWild(move.card)) {
|
||||||
|
requireNotNull(move.nominatedSuit) { "An eight must name a suit" }
|
||||||
|
} else {
|
||||||
|
move.card.suit
|
||||||
|
}
|
||||||
|
|
||||||
|
val played = state.copy(
|
||||||
|
hands = hands,
|
||||||
|
handCounts = hands.map { it.size },
|
||||||
|
discard = state.discard + move.card,
|
||||||
|
suitInForce = suit,
|
||||||
|
drawnThisTurn = 0,
|
||||||
|
consecutivePasses = 0,
|
||||||
|
log = state.log + buildString {
|
||||||
|
append("Seat $seat plays ${move.card.label}")
|
||||||
|
if (isWild(move.card)) append(" and calls ${suit.symbol}")
|
||||||
|
append(".")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (hands[seat].isEmpty()) return scoreRound(played, seat)
|
||||||
|
return advance(played, seat)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyDraw(state: CrazyEightsState, seat: Int): CrazyEightsState {
|
||||||
|
var stock = state.stock
|
||||||
|
var discard = state.discard
|
||||||
|
|
||||||
|
if (stock.isEmpty()) {
|
||||||
|
// Recycle everything but the card in play.
|
||||||
|
if (discard.size <= 1) return advance(state, seat)
|
||||||
|
val top = discard.last()
|
||||||
|
stock = discard.dropLast(1).shuffledWith(Random(state.seed + state.log.size))
|
||||||
|
discard = listOf(top)
|
||||||
|
}
|
||||||
|
|
||||||
|
val hands = state.hands.toMutableList()
|
||||||
|
hands[seat] = hands[seat] + stock.first()
|
||||||
|
|
||||||
|
return state.copy(
|
||||||
|
hands = hands,
|
||||||
|
handCounts = hands.map { it.size },
|
||||||
|
stock = stock.drop(1),
|
||||||
|
discard = discard,
|
||||||
|
drawnThisTurn = state.drawnThisTurn + 1,
|
||||||
|
consecutivePasses = 0,
|
||||||
|
log = state.log + "Seat $seat draws.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everyone passing in succession means nobody can move and the deck is
|
||||||
|
* spent. The round is blocked: it ends there and the smallest hand takes it.
|
||||||
|
*/
|
||||||
|
private fun applyPass(state: CrazyEightsState, seat: Int): CrazyEightsState {
|
||||||
|
val passes = state.consecutivePasses + 1
|
||||||
|
val passed = state.copy(
|
||||||
|
consecutivePasses = passes,
|
||||||
|
log = state.log + "Seat $seat passes.",
|
||||||
|
)
|
||||||
|
if (passes >= state.options.playerCount) {
|
||||||
|
val smallest = passed.hands.indices
|
||||||
|
.minByOrNull { passed.hands[it].sumOf { card -> crazyEightsPenalty(card) } }
|
||||||
|
return scoreRound(
|
||||||
|
passed.copy(log = passed.log + "Nobody can move — the round is blocked."),
|
||||||
|
smallest,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return advance(passed, seat)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun advance(state: CrazyEightsState, seat: Int): CrazyEightsState = state.copy(
|
||||||
|
turn = (seat + 1) % state.options.playerCount,
|
||||||
|
drawnThisTurn = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player who went out scores nothing; everyone else is charged for what
|
||||||
|
* they are still holding.
|
||||||
|
*/
|
||||||
|
private fun scoreRound(state: CrazyEightsState, winner: Int?): CrazyEightsState {
|
||||||
|
val scores = state.scores.mapIndexed { seat, total ->
|
||||||
|
total + state.hands[seat].sumOf { crazyEightsPenalty(it) }
|
||||||
|
}
|
||||||
|
val lastRound = state.roundNumber + 1 >= state.options.roundsToPlay
|
||||||
|
return state.copy(
|
||||||
|
scores = scores,
|
||||||
|
roundWinner = winner,
|
||||||
|
phase = if (lastRound) CrazyEightsPhase.GAME_OVER else CrazyEightsPhase.ROUND_OVER,
|
||||||
|
log = state.log + if (winner != null) "Seat $winner goes out." else "Round ends.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isFinished(state: CrazyEightsState): Boolean =
|
||||||
|
state.phase == CrazyEightsPhase.GAME_OVER
|
||||||
|
|
||||||
|
override fun summary(state: CrazyEightsState): String = when (state.phase) {
|
||||||
|
CrazyEightsPhase.GAME_OVER -> {
|
||||||
|
val best = state.scores.indices.minByOrNull { state.scores[it] } ?: 0
|
||||||
|
"Seat $best wins on ${state.scores[best]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> state.scores.mapIndexed { seat, s -> "S$seat:$s" }.joinToString(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun viewFor(state: CrazyEightsState, seat: Int): CrazyEightsState = state.copy(
|
||||||
|
hands = state.hands.mapIndexed { index, hand -> if (index == seat) hand else emptyList() },
|
||||||
|
handCounts = state.handCounts,
|
||||||
|
stock = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun encodeState(state: CrazyEightsState): String = json.encodeToString(state)
|
||||||
|
override fun decodeState(json: String): CrazyEightsState = this.json.decodeFromString(json)
|
||||||
|
override fun encodeMove(move: CrazyEightsMove): String = json.encodeToString(move)
|
||||||
|
override fun decodeMove(json: String): CrazyEightsMove = this.json.decodeFromString(json)
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package org.prolibertate.games.game.kaiser
|
||||||
|
|
||||||
|
import org.prolibertate.games.game.cards.Card
|
||||||
|
import org.prolibertate.games.game.cards.Rank
|
||||||
|
import org.prolibertate.games.game.cards.Suit
|
||||||
|
import org.prolibertate.games.game.engine.GameAi
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A heuristic Kaiser opponent.
|
||||||
|
*
|
||||||
|
* It rates a hand by its best suit, bids only when it can see the points, and
|
||||||
|
* in play chases the five of hearts while trying not to be the one holding the
|
||||||
|
* trick the three of spades lands in.
|
||||||
|
*/
|
||||||
|
class KaiserAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||||
|
GameAi<KaiserState, KaiserMove> {
|
||||||
|
|
||||||
|
enum class Difficulty(
|
||||||
|
/** Hand strength needed before bidding above the floor. */
|
||||||
|
val bidThreshold: Int,
|
||||||
|
) {
|
||||||
|
EASY(bidThreshold = 12),
|
||||||
|
NORMAL(bidThreshold = 16),
|
||||||
|
CAUTIOUS(bidThreshold = 19),
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun chooseMove(state: KaiserState, seat: Int, legal: List<KaiserMove>): KaiserMove {
|
||||||
|
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||||
|
return when (state.phase) {
|
||||||
|
KaiserPhase.BIDDING -> chooseBid(state, seat, legal)
|
||||||
|
KaiserPhase.PLAYING -> choosePlay(state, seat, legal)
|
||||||
|
else -> legal.first()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trump length and top cards, plus a nod to holding the counters. */
|
||||||
|
private fun suitValue(hand: List<Card>, trump: Suit): Int {
|
||||||
|
var value = hand.count { it.suit == trump } * 3
|
||||||
|
for (card in hand) {
|
||||||
|
value += when {
|
||||||
|
card.suit == trump && card.rank == Rank.ACE -> 4
|
||||||
|
card.suit == trump && card.rank == Rank.KING -> 3
|
||||||
|
card == FIVE_OF_HEARTS -> 3
|
||||||
|
card.rank == Rank.ACE -> 2
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Being long in spades makes the three easier to bury.
|
||||||
|
if (hand.contains(THREE_OF_SPADES)) value -= 2
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chooseBid(state: KaiserState, seat: Int, legal: List<KaiserMove>): KaiserMove {
|
||||||
|
val naming = legal.filterIsInstance<NameTrump>()
|
||||||
|
if (naming.isNotEmpty()) {
|
||||||
|
val hand = state.hands[seat]
|
||||||
|
val best = naming
|
||||||
|
.filter { it.suit != null }
|
||||||
|
.maxByOrNull { suitValue(hand, it.suit!!) }
|
||||||
|
return best ?: naming.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
val hand = state.hands[seat]
|
||||||
|
val bestSuit = Suit.entries.maxByOrNull { suitValue(hand, it) } ?: Suit.SPADES
|
||||||
|
val strength = suitValue(hand, bestSuit)
|
||||||
|
|
||||||
|
val bids = legal.filterIsInstance<MakeBid>().filter { !it.bid.noTrump }
|
||||||
|
if (strength >= difficulty.bidThreshold && bids.isNotEmpty()) {
|
||||||
|
// Bid the floor rather than reaching; the contract is what matters.
|
||||||
|
return bids.minBy { it.bid.points }
|
||||||
|
}
|
||||||
|
return legal.firstOrNull { it is PassBid } ?: bids.minByOrNull { it.bid.points } ?: legal.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun choosePlay(state: KaiserState, seat: Int, legal: List<KaiserMove>): KaiserMove {
|
||||||
|
val cards = legal.filterIsInstance<PlayCard>().map { it.card }
|
||||||
|
if (cards.size == 1) return PlayCard(cards.first())
|
||||||
|
|
||||||
|
if (state.trick.isEmpty()) {
|
||||||
|
// Leading: pull with the top trump, else lead an ace.
|
||||||
|
val trump = state.trump
|
||||||
|
val topTrump = cards.filter { trump != null && it.suit == trump }
|
||||||
|
.maxByOrNull { it.rank.order }
|
||||||
|
if (topTrump != null && topTrump.rank.order >= Rank.KING.order) return PlayCard(topTrump)
|
||||||
|
val ace = cards.filter { it.rank == Rank.ACE }.maxByOrNull { it.rank.order }
|
||||||
|
if (ace != null) return PlayCard(ace)
|
||||||
|
return PlayCard(cards.minBy { it.rank.order })
|
||||||
|
}
|
||||||
|
|
||||||
|
val ledSuit = state.trick.first().card.suit
|
||||||
|
val best = state.trick.maxBy { trickStrength(it.card, state.trump, ledSuit) }
|
||||||
|
val partnerWinning = partnerOf(best.seat) == seat
|
||||||
|
val bestStrength = trickStrength(best.card, state.trump, ledSuit)
|
||||||
|
val winners = cards.filter { trickStrength(it, state.trump, ledSuit) > bestStrength }
|
||||||
|
|
||||||
|
// The five of hearts is worth more than the trick it rides in on.
|
||||||
|
val fiveOnTable = state.trick.any { it.card == FIVE_OF_HEARTS }
|
||||||
|
val threeOnTable = state.trick.any { it.card == THREE_OF_SPADES }
|
||||||
|
|
||||||
|
return when {
|
||||||
|
// Partner has it and the trick is worth taking: let them have it,
|
||||||
|
// unless the three of spades is in there.
|
||||||
|
partnerWinning && !threeOnTable -> PlayCard(cards.minBy { it.rank.order })
|
||||||
|
|
||||||
|
fiveOnTable && winners.isNotEmpty() ->
|
||||||
|
PlayCard(winners.maxBy { trickStrength(it, state.trump, ledSuit) })
|
||||||
|
|
||||||
|
// Nobody wants the three; duck if the trick is already lost.
|
||||||
|
threeOnTable && winners.isEmpty() -> PlayCard(cards.minBy { it.rank.order })
|
||||||
|
|
||||||
|
winners.isNotEmpty() && !threeOnTable ->
|
||||||
|
PlayCard(winners.minBy { trickStrength(it, state.trump, ledSuit) })
|
||||||
|
|
||||||
|
else -> PlayCard(cards.minBy { it.rank.order })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package org.prolibertate.games.game.kaiser
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.prolibertate.games.game.cards.Card
|
||||||
|
import org.prolibertate.games.game.cards.Rank
|
||||||
|
import org.prolibertate.games.game.cards.Suit
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KaiserOptions(
|
||||||
|
/** Lowest opening bid. Five to eight, depending on the table. */
|
||||||
|
val minimumBid: Int = 6,
|
||||||
|
/** A no-trump bid outranks the same number bid with a trump suit. */
|
||||||
|
val allowNoTrump: Boolean = true,
|
||||||
|
val pointsToWin: Int = 52,
|
||||||
|
/**
|
||||||
|
* Going out the bottom loses. Without a floor a table that keeps bidding
|
||||||
|
* and going down never reaches a result at all.
|
||||||
|
*/
|
||||||
|
val losingScore: Int = -52,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(minimumBid in 5..8) { "Kaiser opens between five and eight" }
|
||||||
|
require(pointsToWin > 0) { "pointsToWin must be positive" }
|
||||||
|
require(losingScore < 0) { "losingScore must be negative" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The most a hand can be worth: eight tricks, plus five for the five of hearts,
|
||||||
|
* less three for the three of spades. Bidding above it is bidding to fail, so
|
||||||
|
* nothing above it is offered.
|
||||||
|
*/
|
||||||
|
const val MAX_BID = 10
|
||||||
|
|
||||||
|
/** The five of hearts, worth five to whoever takes the trick it falls in. */
|
||||||
|
val FIVE_OF_HEARTS: Card = Card(Rank.FIVE, Suit.HEARTS)
|
||||||
|
|
||||||
|
/** The three of spades, which costs three. */
|
||||||
|
val THREE_OF_SPADES: Card = Card(Rank.THREE, Suit.SPADES)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Points in a trick: one for the trick itself, plus or minus whatever the two
|
||||||
|
* counters are worth. Eight tricks and the two counters make ten points a hand.
|
||||||
|
*/
|
||||||
|
fun trickValue(cards: List<Card>): Int {
|
||||||
|
var value = 1
|
||||||
|
if (cards.contains(FIVE_OF_HEARTS)) value += 5
|
||||||
|
if (cards.contains(THREE_OF_SPADES)) value -= 3
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun partnerOf(seat: Int): Int = (seat + 2) % 4
|
||||||
|
|
||||||
|
fun teamOf(seat: Int): Int = seat % 2
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class KaiserPhase { BIDDING, PLAYING, HAND_OVER, GAME_OVER }
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PlayedCard(val seat: Int, val card: Card)
|
||||||
|
|
||||||
|
/** A bid of [points] tricks-worth, optionally in no trump. */
|
||||||
|
@Serializable
|
||||||
|
data class Bid(val points: Int, val noTrump: Boolean) {
|
||||||
|
/** No trump beats the same number with a suit. */
|
||||||
|
fun beats(other: Bid?): Boolean = when {
|
||||||
|
other == null -> true
|
||||||
|
points != other.points -> points > other.points
|
||||||
|
else -> noTrump && !other.noTrump
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KaiserState(
|
||||||
|
val options: KaiserOptions,
|
||||||
|
val seed: Long,
|
||||||
|
val handNumber: Int,
|
||||||
|
val dealer: Int,
|
||||||
|
val phase: KaiserPhase,
|
||||||
|
val hands: List<List<Card>>,
|
||||||
|
val handCounts: List<Int>,
|
||||||
|
val turn: Int,
|
||||||
|
val passes: Int,
|
||||||
|
val highBid: Bid?,
|
||||||
|
val highBidder: Int?,
|
||||||
|
val trump: Suit?,
|
||||||
|
val trick: List<PlayedCard>,
|
||||||
|
/** Held on the table so a finished trick can be read before it is swept. */
|
||||||
|
val completedTrick: List<PlayedCard> = emptyList(),
|
||||||
|
val leader: Int,
|
||||||
|
val lastTrickWinner: Int?,
|
||||||
|
/** Points taken this hand, per team. */
|
||||||
|
val handPoints: List<Int>,
|
||||||
|
val scores: List<Int>,
|
||||||
|
val log: List<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed interface KaiserMove
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("bid")
|
||||||
|
data class MakeBid(val bid: Bid) : KaiserMove
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("pass")
|
||||||
|
data object PassBid : KaiserMove
|
||||||
|
|
||||||
|
/** Only the winning bidder names trump, and only after the bidding. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("name_trump")
|
||||||
|
data class NameTrump(val suit: Suit?) : KaiserMove
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("play")
|
||||||
|
data class PlayCard(val card: Card) : KaiserMove
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trick ranking. Trump beats everything else; otherwise only the led suit can
|
||||||
|
* win. There are no bowers — Kaiser ranks straight down from the ace.
|
||||||
|
*/
|
||||||
|
fun trickStrength(card: Card, trump: Suit?, ledSuit: Suit): Int = when {
|
||||||
|
trump != null && card.suit == trump -> 1000 + card.rank.order
|
||||||
|
card.suit == ledSuit -> 100 + card.rank.order
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
package org.prolibertate.games.game.kaiser
|
||||||
|
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.prolibertate.games.game.GameCatalog
|
||||||
|
import org.prolibertate.games.game.cards.Card
|
||||||
|
import org.prolibertate.games.game.cards.Decks
|
||||||
|
import org.prolibertate.games.game.cards.Suit
|
||||||
|
import org.prolibertate.games.game.cards.shuffledWith
|
||||||
|
import org.prolibertate.games.game.engine.GameRules
|
||||||
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kaiser as a pure state machine. See RULES-kaiser.md.
|
||||||
|
*/
|
||||||
|
object KaiserRules : GameRules<KaiserState, KaiserMove> {
|
||||||
|
|
||||||
|
override val gameId: String = GameCatalog.KAISER
|
||||||
|
|
||||||
|
private const val CARDS_PER_HAND = 8
|
||||||
|
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun initialState(config: TableConfig): KaiserState {
|
||||||
|
require(config.seats.size == 4) { "Kaiser is a four-handed game" }
|
||||||
|
val options = json.decodeFromString<KaiserOptions>(config.optionsJson)
|
||||||
|
return deal(options, config.seed, 0, 0, listOf(0, 0), listOf("Hand 1."))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deal(
|
||||||
|
options: KaiserOptions,
|
||||||
|
seed: Long,
|
||||||
|
handNumber: Int,
|
||||||
|
dealer: Int,
|
||||||
|
scores: List<Int>,
|
||||||
|
log: List<String>,
|
||||||
|
): KaiserState {
|
||||||
|
val random = Random(seed + handNumber * 7919L)
|
||||||
|
val deck = Decks.kaiser().shuffledWith(random)
|
||||||
|
val hands = (0 until 4).map { seat ->
|
||||||
|
deck.subList(seat * CARDS_PER_HAND, (seat + 1) * CARDS_PER_HAND).toList()
|
||||||
|
}
|
||||||
|
return KaiserState(
|
||||||
|
options = options,
|
||||||
|
seed = seed,
|
||||||
|
handNumber = handNumber,
|
||||||
|
dealer = dealer,
|
||||||
|
phase = KaiserPhase.BIDDING,
|
||||||
|
hands = hands,
|
||||||
|
handCounts = hands.map { it.size },
|
||||||
|
turn = (dealer + 1) % 4,
|
||||||
|
passes = 0,
|
||||||
|
highBid = null,
|
||||||
|
highBidder = null,
|
||||||
|
trump = null,
|
||||||
|
trick = emptyList(),
|
||||||
|
completedTrick = emptyList(),
|
||||||
|
leader = (dealer + 1) % 4,
|
||||||
|
lastTrickWinner = null,
|
||||||
|
handPoints = listOf(0, 0),
|
||||||
|
scores = scores,
|
||||||
|
log = log,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Advances from [KaiserPhase.HAND_OVER] into the next deal. */
|
||||||
|
fun nextHand(state: KaiserState): KaiserState {
|
||||||
|
check(state.phase == KaiserPhase.HAND_OVER) { "Hand is not over" }
|
||||||
|
val dealer = (state.dealer + 1) % 4
|
||||||
|
return deal(
|
||||||
|
options = state.options,
|
||||||
|
seed = state.seed,
|
||||||
|
handNumber = state.handNumber + 1,
|
||||||
|
dealer = dealer,
|
||||||
|
scores = state.scores,
|
||||||
|
log = state.log + "Hand ${state.handNumber + 2}.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun currentSeat(state: KaiserState): Int? = when (state.phase) {
|
||||||
|
KaiserPhase.BIDDING, KaiserPhase.PLAYING -> state.turn
|
||||||
|
KaiserPhase.HAND_OVER, KaiserPhase.GAME_OVER -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Legal moves
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
override fun legalMoves(state: KaiserState, seat: Int): List<KaiserMove> {
|
||||||
|
if (currentSeat(state) != seat) return emptyList()
|
||||||
|
return when (state.phase) {
|
||||||
|
KaiserPhase.BIDDING -> biddingMoves(state, seat)
|
||||||
|
KaiserPhase.PLAYING -> playableCards(state, seat).map { PlayCard(it) }
|
||||||
|
KaiserPhase.HAND_OVER, KaiserPhase.GAME_OVER -> emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun biddingMoves(state: KaiserState, seat: Int): List<KaiserMove> {
|
||||||
|
// Trump is named as a separate move once the bidding is settled.
|
||||||
|
if (state.highBidder == seat && state.trump == null && biddingClosed(state)) {
|
||||||
|
return buildList {
|
||||||
|
Suit.entries.forEach { add(NameTrump(it)) }
|
||||||
|
if (state.highBid?.noTrump == true) {
|
||||||
|
// A no-trump bid has already committed to none.
|
||||||
|
clear()
|
||||||
|
add(NameTrump(null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val moves = mutableListOf<KaiserMove>()
|
||||||
|
val floor = state.options.minimumBid
|
||||||
|
val ceiling = MAX_BID
|
||||||
|
for (points in floor..ceiling) {
|
||||||
|
val plain = Bid(points, noTrump = false)
|
||||||
|
if (plain.beats(state.highBid)) moves += MakeBid(plain)
|
||||||
|
if (state.options.allowNoTrump) {
|
||||||
|
val noTrump = Bid(points, noTrump = true)
|
||||||
|
if (noTrump.beats(state.highBid)) moves += MakeBid(noTrump)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The dealer is stuck with the minimum if the bidding would die out.
|
||||||
|
val dealerMustBid = seat == state.dealer && state.highBid == null && state.passes == 3
|
||||||
|
if (!dealerMustBid) moves += PassBid
|
||||||
|
return moves
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun biddingClosed(state: KaiserState): Boolean =
|
||||||
|
state.highBid != null && state.passes >= 3
|
||||||
|
|
||||||
|
/** Follow the led suit if you hold it. */
|
||||||
|
fun playableCards(state: KaiserState, seat: Int): List<Card> {
|
||||||
|
val hand = state.hands[seat]
|
||||||
|
val led = state.trick.firstOrNull() ?: return hand
|
||||||
|
val following = hand.filter { it.suit == led.card.suit }
|
||||||
|
return following.ifEmpty { hand }
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Move application
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
override fun applyMove(state: KaiserState, seat: Int, move: KaiserMove): KaiserState {
|
||||||
|
require(currentSeat(state) == seat) { "Not seat $seat's turn" }
|
||||||
|
require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" }
|
||||||
|
return when (move) {
|
||||||
|
is MakeBid -> applyBid(state, seat, move.bid)
|
||||||
|
is PassBid -> applyPass(state, seat)
|
||||||
|
is NameTrump -> applyNameTrump(state, move.suit)
|
||||||
|
is PlayCard -> applyPlay(state, seat, move.card)
|
||||||
|
}.normalised()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun KaiserState.normalised(): KaiserState = copy(handCounts = hands.map { it.size })
|
||||||
|
|
||||||
|
private fun applyBid(state: KaiserState, seat: Int, bid: Bid): KaiserState {
|
||||||
|
val next = state.copy(
|
||||||
|
highBid = bid,
|
||||||
|
highBidder = seat,
|
||||||
|
// A fresh bid restarts the count of players willing to give way.
|
||||||
|
passes = 0,
|
||||||
|
turn = (seat + 1) % 4,
|
||||||
|
log = state.log + "Seat $seat bids ${bid.points}${if (bid.noTrump) " no trump" else ""}.",
|
||||||
|
)
|
||||||
|
return if (biddingClosed(next)) next.copy(turn = seat) else next
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyPass(state: KaiserState, seat: Int): KaiserState {
|
||||||
|
val passes = state.passes + 1
|
||||||
|
val passed = state.copy(passes = passes, log = state.log + "Seat $seat passes.")
|
||||||
|
|
||||||
|
if (passes >= 4 && passed.highBid == null) {
|
||||||
|
// Nobody wanted it: throw the hand in.
|
||||||
|
return deal(
|
||||||
|
options = state.options,
|
||||||
|
seed = state.seed,
|
||||||
|
handNumber = state.handNumber + 1,
|
||||||
|
dealer = (state.dealer + 1) % 4,
|
||||||
|
scores = state.scores,
|
||||||
|
log = passed.log + "Hand thrown in; redealing.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Bidding is settled once everyone else has given way.
|
||||||
|
if (passed.highBid != null && passes >= 3) {
|
||||||
|
return passed.copy(turn = passed.highBidder!!)
|
||||||
|
}
|
||||||
|
return passed.copy(turn = (seat + 1) % 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyNameTrump(state: KaiserState, suit: Suit?): KaiserState {
|
||||||
|
val leader = state.highBidder!!
|
||||||
|
return state.copy(
|
||||||
|
phase = KaiserPhase.PLAYING,
|
||||||
|
trump = suit,
|
||||||
|
turn = leader,
|
||||||
|
leader = leader,
|
||||||
|
trick = emptyList(),
|
||||||
|
completedTrick = emptyList(),
|
||||||
|
log = state.log + if (suit == null) {
|
||||||
|
"No trump."
|
||||||
|
} else {
|
||||||
|
"${suit.symbol} are trump."
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyPlay(state: KaiserState, seat: Int, card: Card): KaiserState {
|
||||||
|
val hands = state.hands.toMutableList()
|
||||||
|
hands[seat] = hands[seat] - card
|
||||||
|
val trick = state.trick + PlayedCard(seat, card)
|
||||||
|
val played = state.copy(hands = hands, trick = trick, completedTrick = emptyList())
|
||||||
|
|
||||||
|
if (trick.size < 4) return played.copy(turn = (seat + 1) % 4)
|
||||||
|
|
||||||
|
val ledSuit = trick.first().card.suit
|
||||||
|
val winner = trick.maxBy { trickStrength(it.card, state.trump, ledSuit) }.seat
|
||||||
|
val points = trickValue(trick.map { it.card })
|
||||||
|
|
||||||
|
val handPoints = played.handPoints.toMutableList()
|
||||||
|
handPoints[teamOf(winner)] = handPoints[teamOf(winner)] + points
|
||||||
|
|
||||||
|
val afterTrick = played.copy(
|
||||||
|
trick = emptyList(),
|
||||||
|
completedTrick = trick,
|
||||||
|
handPoints = handPoints,
|
||||||
|
leader = winner,
|
||||||
|
turn = winner,
|
||||||
|
lastTrickWinner = winner,
|
||||||
|
log = state.log + "Seat $winner takes the trick ($points).",
|
||||||
|
)
|
||||||
|
|
||||||
|
return if (afterTrick.hands.all { it.isEmpty() }) scoreHand(afterTrick) else afterTrick
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Scoring
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun scoreHand(state: KaiserState): KaiserState {
|
||||||
|
val bidder = requireNotNull(state.highBidder)
|
||||||
|
val bid = requireNotNull(state.highBid)
|
||||||
|
val bidderTeam = teamOf(bidder)
|
||||||
|
val defenders = 1 - bidderTeam
|
||||||
|
val taken = state.handPoints[bidderTeam]
|
||||||
|
|
||||||
|
val scores = state.scores.toMutableList()
|
||||||
|
val note: String
|
||||||
|
if (taken >= bid.points) {
|
||||||
|
// No-trump contracts pay double, which is what makes them worth bidding.
|
||||||
|
val gained = if (bid.noTrump) taken * 2 else taken
|
||||||
|
scores[bidderTeam] = scores[bidderTeam] + gained
|
||||||
|
note = "Team $bidderTeam made ${bid.points} with $taken."
|
||||||
|
} else {
|
||||||
|
val lost = if (bid.noTrump) bid.points * 2 else bid.points
|
||||||
|
scores[bidderTeam] = scores[bidderTeam] - lost
|
||||||
|
note = "Team $bidderTeam went down ${bid.points}, taking only $taken."
|
||||||
|
}
|
||||||
|
// Defenders always keep what they took.
|
||||||
|
scores[defenders] = scores[defenders] + state.handPoints[defenders]
|
||||||
|
|
||||||
|
// A game ends either by climbing to the target or by falling through
|
||||||
|
// the floor, and the floor is what guarantees it ends at all.
|
||||||
|
val reachedTarget = scores.indexOfFirst { it >= state.options.pointsToWin }
|
||||||
|
val fellThrough = scores.indexOfFirst { it <= state.options.losingScore }
|
||||||
|
val over = reachedTarget >= 0 || fellThrough >= 0
|
||||||
|
val winner = when {
|
||||||
|
reachedTarget >= 0 -> reachedTarget
|
||||||
|
fellThrough >= 0 -> 1 - fellThrough
|
||||||
|
else -> -1
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.copy(
|
||||||
|
phase = if (over) KaiserPhase.GAME_OVER else KaiserPhase.HAND_OVER,
|
||||||
|
scores = scores,
|
||||||
|
log = state.log + note + if (over) listOf("Team $winner wins.") else emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isFinished(state: KaiserState): Boolean = state.phase == KaiserPhase.GAME_OVER
|
||||||
|
|
||||||
|
override fun summary(state: KaiserState): String = when (state.phase) {
|
||||||
|
KaiserPhase.GAME_OVER -> {
|
||||||
|
val winner = state.scores.indices.maxByOrNull { state.scores[it] } ?: 0
|
||||||
|
"Team $winner wins ${state.scores[0]}–${state.scores[1]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> "We ${state.scores[0]} — They ${state.scores[1]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun viewFor(state: KaiserState, seat: Int): KaiserState = state.copy(
|
||||||
|
hands = state.hands.mapIndexed { index, hand -> if (index == seat) hand else emptyList() },
|
||||||
|
handCounts = state.handCounts,
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun encodeState(state: KaiserState): String = json.encodeToString(state)
|
||||||
|
override fun decodeState(json: String): KaiserState = this.json.decodeFromString(json)
|
||||||
|
override fun encodeMove(move: KaiserMove): String = json.encodeToString(move)
|
||||||
|
override fun decodeMove(json: String): KaiserMove = this.json.decodeFromString(json)
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package org.prolibertate.games.game.wizard
|
||||||
|
|
||||||
|
import org.prolibertate.games.game.cards.Card
|
||||||
|
import org.prolibertate.games.game.cards.Rank
|
||||||
|
import org.prolibertate.games.game.cards.Suit
|
||||||
|
import org.prolibertate.games.game.engine.GameAi
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A heuristic Wizard opponent.
|
||||||
|
*
|
||||||
|
* Bidding counts the tricks the hand can reasonably expect: a wizard is one,
|
||||||
|
* a jester is none, and everything else is worth a fraction depending on how
|
||||||
|
* high it is and whether it is trump. In play it chases tricks while it is
|
||||||
|
* short of its bid and ducks once it has them, which is the whole game.
|
||||||
|
*/
|
||||||
|
class WizardAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||||
|
GameAi<WizardState, WizardMove> {
|
||||||
|
|
||||||
|
enum class Difficulty(
|
||||||
|
/**
|
||||||
|
* Added to the expected-trick count before rounding. Optimists overbid
|
||||||
|
* and go down; pessimists underbid and get caught taking tricks they
|
||||||
|
* did not want.
|
||||||
|
*/
|
||||||
|
val bidBias: Double,
|
||||||
|
) {
|
||||||
|
EASY(bidBias = 0.35),
|
||||||
|
NORMAL(bidBias = 0.0),
|
||||||
|
CAUTIOUS(bidBias = -0.25),
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun chooseMove(state: WizardState, seat: Int, legal: List<WizardMove>): WizardMove {
|
||||||
|
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||||
|
return when (state.phase) {
|
||||||
|
WizardPhase.BIDDING -> chooseBid(state, seat, legal)
|
||||||
|
WizardPhase.PLAYING -> choosePlay(state, seat, legal)
|
||||||
|
else -> legal.first()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Bidding
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun chooseBid(state: WizardState, seat: Int, legal: List<WizardMove>): WizardMove {
|
||||||
|
val naming = legal.filterIsInstance<ChooseTrump>()
|
||||||
|
if (naming.isNotEmpty()) {
|
||||||
|
val hand = state.hands[seat]
|
||||||
|
val best = naming.maxByOrNull { suitStrength(hand, it.suit) }
|
||||||
|
return best ?: naming.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
val bids = legal.filterIsInstance<MakeBid>()
|
||||||
|
if (bids.isEmpty()) return legal.first()
|
||||||
|
|
||||||
|
val wanted = (expectedTricks(state.hands[seat], state.trump) + difficulty.bidBias)
|
||||||
|
.roundToInt()
|
||||||
|
.coerceIn(0, state.cardsThisRound)
|
||||||
|
// Screw-the-dealer may have taken the number we wanted away, so settle
|
||||||
|
// for the nearest one still on offer.
|
||||||
|
return bids.minBy { abs(it.tricks - wanted) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How much of a hand a given trump suit would be worth. */
|
||||||
|
private fun suitStrength(hand: List<Card>, suit: Suit): Double =
|
||||||
|
expectedTricks(hand, suit)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roughly how many tricks a hand should take. Deliberately blunt: it does
|
||||||
|
* not know how many cards are out, only how good its own are.
|
||||||
|
*/
|
||||||
|
private fun expectedTricks(hand: List<Card>, trump: Suit?): Double {
|
||||||
|
var expected = 0.0
|
||||||
|
for (card in hand) {
|
||||||
|
expected += when {
|
||||||
|
isWizardCard(card) -> 1.0
|
||||||
|
isJester(card) -> 0.0
|
||||||
|
trump != null && card.suit == trump -> when {
|
||||||
|
card.rank.order >= Rank.KING.order -> 0.9
|
||||||
|
card.rank.order >= Rank.JACK.order -> 0.6
|
||||||
|
else -> 0.3
|
||||||
|
}
|
||||||
|
|
||||||
|
card.rank == Rank.ACE -> 0.75
|
||||||
|
card.rank == Rank.KING -> 0.45
|
||||||
|
card.rank == Rank.QUEEN -> 0.2
|
||||||
|
else -> 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return expected
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Play
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun choosePlay(state: WizardState, seat: Int, legal: List<WizardMove>): WizardMove {
|
||||||
|
val cards = legal.filterIsInstance<PlayCard>().map { it.card }
|
||||||
|
if (cards.size == 1) return PlayCard(cards.first())
|
||||||
|
|
||||||
|
val bid = state.bids[seat] ?: 0
|
||||||
|
val stillNeeded = bid - state.tricksWon[seat]
|
||||||
|
val tricksLeft = state.hands[seat].size
|
||||||
|
// Chase while we are short, and only while there are enough tricks left
|
||||||
|
// to be worth chasing.
|
||||||
|
val wantsTrick = stillNeeded > 0 && stillNeeded <= tricksLeft
|
||||||
|
|
||||||
|
if (state.trick.isEmpty()) return PlayCard(chooseLead(cards, state.trump, wantsTrick))
|
||||||
|
|
||||||
|
val ledSuit = ledSuitOf(state.trick)
|
||||||
|
val order = state.trick.size
|
||||||
|
val bestOnTable = state.trick
|
||||||
|
.withIndex()
|
||||||
|
.maxOf { (index, played) -> trickStrength(played.card, index, state.trump, ledSuit) }
|
||||||
|
|
||||||
|
// A card played now takes the position after everything already down.
|
||||||
|
val strengthOf = { card: Card -> trickStrength(card, order, state.trump, ledSuit) }
|
||||||
|
val winners = cards.filter { strengthOf(it) > bestOnTable }
|
||||||
|
val losers = cards.filter { strengthOf(it) <= bestOnTable }
|
||||||
|
|
||||||
|
return when {
|
||||||
|
// Take it as cheaply as we can.
|
||||||
|
wantsTrick && winners.isNotEmpty() -> PlayCard(winners.minBy { strengthOf(it) })
|
||||||
|
// Cannot take it, or do not want it: throw the least useful card.
|
||||||
|
losers.isNotEmpty() -> PlayCard(losers.minBy { strengthOf(it) })
|
||||||
|
// Everything we hold wins, and we did not want the trick. Take it
|
||||||
|
// with the weakest winner so the good cards stay for later.
|
||||||
|
else -> PlayCard(cards.minBy { strengthOf(it) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chooseLead(cards: List<Card>, trump: Suit?, wantsTrick: Boolean): Card {
|
||||||
|
if (wantsTrick) {
|
||||||
|
// A led wizard is an unloseable trick.
|
||||||
|
cards.firstOrNull { isWizardCard(it) }?.let { return it }
|
||||||
|
val topTrump = cards.filter { trump != null && it.suit == trump }
|
||||||
|
.maxByOrNull { it.rank.order }
|
||||||
|
if (topTrump != null && topTrump.rank.order >= Rank.QUEEN.order) return topTrump
|
||||||
|
cards.firstOrNull { it.rank == Rank.ACE && !isJester(it) }?.let { return it }
|
||||||
|
return cards.filterNot { isJester(it) }.maxByOrNull { it.rank.order } ?: cards.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ducking: a led jester cannot take anything at all.
|
||||||
|
cards.firstOrNull { isJester(it) }?.let { return it }
|
||||||
|
return cards.filterNot { isWizardCard(it) }
|
||||||
|
.minByOrNull { it.rank.order + if (trump != null && it.suit == trump) 20 else 0 }
|
||||||
|
?: cards.first()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package org.prolibertate.games.game.wizard
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.prolibertate.games.game.cards.Card
|
||||||
|
import org.prolibertate.games.game.cards.Rank
|
||||||
|
import org.prolibertate.games.game.cards.Suit
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class WizardOptions(
|
||||||
|
val playerCount: Int = 4,
|
||||||
|
/**
|
||||||
|
* Zero plays the full game: sixty cards divided by the table, so four
|
||||||
|
* players get fifteen rounds. A smaller number shortens the evening.
|
||||||
|
*/
|
||||||
|
val rounds: Int = 0,
|
||||||
|
/**
|
||||||
|
* The dealer may not bid the number that would make the bids add up to the
|
||||||
|
* tricks available, so somebody always misses. A common house rule.
|
||||||
|
*/
|
||||||
|
val screwTheDealer: Boolean = false,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(playerCount in 3..6) { "Wizard seats three to six" }
|
||||||
|
require(rounds >= 0) { "rounds cannot be negative" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sixty cards share out into this many rounds. */
|
||||||
|
fun totalRounds(): Int {
|
||||||
|
val full = 60 / playerCount
|
||||||
|
return if (rounds in 1..full) rounds else full
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isWizardCard(card: Card): Boolean = card.rank == Rank.WIZARD
|
||||||
|
fun isJester(card: Card): Boolean = card.rank == Rank.JESTER
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class WizardPhase { BIDDING, PLAYING, ROUND_OVER, GAME_OVER }
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PlayedCard(val seat: Int, val card: Card)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class WizardState(
|
||||||
|
val options: WizardOptions,
|
||||||
|
val seed: Long,
|
||||||
|
/** Zero-based; round n deals n+1 cards. */
|
||||||
|
val round: Int,
|
||||||
|
val dealer: Int,
|
||||||
|
val phase: WizardPhase,
|
||||||
|
val hands: List<List<Card>>,
|
||||||
|
val handCounts: List<Int>,
|
||||||
|
/** Turned up after the deal to set trump. Null in the last round. */
|
||||||
|
val trumpCard: Card?,
|
||||||
|
val trump: Suit?,
|
||||||
|
/** Null until a seat has bid; one entry per seat. */
|
||||||
|
val bids: List<Int?>,
|
||||||
|
val tricksWon: List<Int>,
|
||||||
|
val turn: Int,
|
||||||
|
val trick: List<PlayedCard>,
|
||||||
|
/** Held on the table so a finished trick can be read before it is swept. */
|
||||||
|
val completedTrick: List<PlayedCard> = emptyList(),
|
||||||
|
val leader: Int,
|
||||||
|
val lastTrickWinner: Int?,
|
||||||
|
val scores: List<Int>,
|
||||||
|
val roundScores: List<Int>,
|
||||||
|
val log: List<String>,
|
||||||
|
) {
|
||||||
|
val cardsThisRound: Int get() = round + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed interface WizardMove
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("bid")
|
||||||
|
data class MakeBid(val tricks: Int) : WizardMove
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("play")
|
||||||
|
data class PlayCard(val card: Card) : WizardMove
|
||||||
|
|
||||||
|
/** The dealer picks trump when a wizard is turned up. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("choose_trump")
|
||||||
|
data class ChooseTrump(val suit: Suit) : WizardMove
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The suit a trick is being played to.
|
||||||
|
*
|
||||||
|
* A jester leads nothing — if the first card is a jester the suit is set by the
|
||||||
|
* next ordinary card instead. A wizard reaching the front of that queue means
|
||||||
|
* no suit is led at all and anything may be played.
|
||||||
|
*/
|
||||||
|
fun ledSuitOf(trick: List<PlayedCard>): Suit? {
|
||||||
|
for (played in trick) {
|
||||||
|
if (isWizardCard(played.card)) return null
|
||||||
|
if (isJester(played.card)) continue
|
||||||
|
return played.card.suit
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ranks a card within a trick. The first wizard played takes it outright;
|
||||||
|
* jesters never win unless nothing else was played.
|
||||||
|
*/
|
||||||
|
fun trickStrength(card: Card, order: Int, trump: Suit?, ledSuit: Suit?): Int = when {
|
||||||
|
// Earlier wizards beat later ones, so subtract the position.
|
||||||
|
isWizardCard(card) -> 10_000 - order
|
||||||
|
// A jester only ever takes a trick of nothing but jesters, and then it is
|
||||||
|
// the first one played that wins.
|
||||||
|
isJester(card) -> 1
|
||||||
|
trump != null && card.suit == trump -> 1_000 + card.rank.order
|
||||||
|
ledSuit != null && card.suit == ledSuit -> 100 + card.rank.order
|
||||||
|
// Off-suit and not trump: cannot win, but still beats a jester.
|
||||||
|
else -> 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exact bids pay twenty plus ten a trick; missing costs ten a trick out. */
|
||||||
|
fun scoreRound(bid: Int, won: Int): Int =
|
||||||
|
if (bid == won) 20 + 10 * won else -10 * kotlin.math.abs(bid - won)
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
package org.prolibertate.games.game.wizard
|
||||||
|
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.prolibertate.games.game.GameCatalog
|
||||||
|
import org.prolibertate.games.game.cards.Card
|
||||||
|
import org.prolibertate.games.game.cards.Decks
|
||||||
|
import org.prolibertate.games.game.cards.Suit
|
||||||
|
import org.prolibertate.games.game.cards.shuffledWith
|
||||||
|
import org.prolibertate.games.game.engine.GameRules
|
||||||
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wizard as a pure state machine. See RULES-wizard.md.
|
||||||
|
*/
|
||||||
|
object WizardRules : GameRules<WizardState, WizardMove> {
|
||||||
|
|
||||||
|
override val gameId: String = GameCatalog.WIZARD
|
||||||
|
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun initialState(config: TableConfig): WizardState {
|
||||||
|
val options = json.decodeFromString<WizardOptions>(config.optionsJson)
|
||||||
|
require(config.seats.size == options.playerCount) {
|
||||||
|
"Seat count ${config.seats.size} does not match ${options.playerCount} players"
|
||||||
|
}
|
||||||
|
return deal(options, config.seed, 0, 0, List(options.playerCount) { 0 }, listOf("Round 1."))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deal(
|
||||||
|
options: WizardOptions,
|
||||||
|
seed: Long,
|
||||||
|
round: Int,
|
||||||
|
dealer: Int,
|
||||||
|
scores: List<Int>,
|
||||||
|
log: List<String>,
|
||||||
|
): WizardState {
|
||||||
|
val random = Random(seed + round * 7919L)
|
||||||
|
var deck = Decks.wizard().shuffledWith(random)
|
||||||
|
val cards = round + 1
|
||||||
|
|
||||||
|
val hands = mutableListOf<List<Card>>()
|
||||||
|
repeat(options.playerCount) {
|
||||||
|
hands += deck.take(cards)
|
||||||
|
deck = deck.drop(cards)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trump comes off the top of what is left. In the last round the whole
|
||||||
|
// deck has been dealt, so there is nothing to turn and no trump.
|
||||||
|
val trumpCard = deck.firstOrNull()
|
||||||
|
val trump = when {
|
||||||
|
trumpCard == null -> null
|
||||||
|
isWizardCard(trumpCard) -> null // dealer chooses; see BIDDING below
|
||||||
|
isJester(trumpCard) -> null
|
||||||
|
else -> trumpCard.suit
|
||||||
|
}
|
||||||
|
|
||||||
|
return WizardState(
|
||||||
|
options = options,
|
||||||
|
seed = seed,
|
||||||
|
round = round,
|
||||||
|
dealer = dealer,
|
||||||
|
phase = WizardPhase.BIDDING,
|
||||||
|
hands = hands,
|
||||||
|
handCounts = hands.map { it.size },
|
||||||
|
trumpCard = trumpCard,
|
||||||
|
trump = trump,
|
||||||
|
bids = List(options.playerCount) { null },
|
||||||
|
tricksWon = List(options.playerCount) { 0 },
|
||||||
|
// A turned wizard means the dealer picks trump before anyone bids.
|
||||||
|
turn = if (trumpCard != null && isWizardCard(trumpCard)) {
|
||||||
|
dealer
|
||||||
|
} else {
|
||||||
|
(dealer + 1) % options.playerCount
|
||||||
|
},
|
||||||
|
trick = emptyList(),
|
||||||
|
completedTrick = emptyList(),
|
||||||
|
leader = (dealer + 1) % options.playerCount,
|
||||||
|
lastTrickWinner = null,
|
||||||
|
scores = scores,
|
||||||
|
roundScores = List(options.playerCount) { 0 },
|
||||||
|
log = log,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Advances from [WizardPhase.ROUND_OVER] into the next deal. */
|
||||||
|
fun nextRound(state: WizardState): WizardState {
|
||||||
|
check(state.phase == WizardPhase.ROUND_OVER) { "Round is not over" }
|
||||||
|
val dealer = (state.dealer + 1) % state.options.playerCount
|
||||||
|
return deal(
|
||||||
|
options = state.options,
|
||||||
|
seed = state.seed,
|
||||||
|
round = state.round + 1,
|
||||||
|
dealer = dealer,
|
||||||
|
scores = state.scores,
|
||||||
|
log = state.log + "Round ${state.round + 2}.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun currentSeat(state: WizardState): Int? = when (state.phase) {
|
||||||
|
WizardPhase.BIDDING, WizardPhase.PLAYING -> state.turn
|
||||||
|
WizardPhase.ROUND_OVER, WizardPhase.GAME_OVER -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Legal moves
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
override fun legalMoves(state: WizardState, seat: Int): List<WizardMove> {
|
||||||
|
if (currentSeat(state) != seat) return emptyList()
|
||||||
|
return when (state.phase) {
|
||||||
|
WizardPhase.BIDDING -> biddingMoves(state, seat)
|
||||||
|
WizardPhase.PLAYING -> playableCards(state, seat).map { PlayCard(it) }
|
||||||
|
WizardPhase.ROUND_OVER, WizardPhase.GAME_OVER -> emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun biddingMoves(state: WizardState, seat: Int): List<WizardMove> {
|
||||||
|
if (needsTrumpChoice(state)) return Suit.entries.map { ChooseTrump(it) }
|
||||||
|
|
||||||
|
val available = state.cardsThisRound
|
||||||
|
val bids = (0..available).toMutableList()
|
||||||
|
|
||||||
|
// Screw the dealer: the last bid may not make the bids balance, so at
|
||||||
|
// least one player is always going to be wrong.
|
||||||
|
if (state.options.screwTheDealer && seat == state.dealer) {
|
||||||
|
val alreadyBid = state.bids.filterNotNull().sum()
|
||||||
|
val forbidden = available - alreadyBid
|
||||||
|
bids.remove(forbidden)
|
||||||
|
}
|
||||||
|
return bids.map { MakeBid(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A wizard on top means the dealer names trump before bidding starts. */
|
||||||
|
private fun needsTrumpChoice(state: WizardState): Boolean =
|
||||||
|
state.trumpCard != null &&
|
||||||
|
isWizardCard(state.trumpCard) &&
|
||||||
|
state.trump == null &&
|
||||||
|
state.bids.all { it == null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Follow the led suit if you hold it. Wizards and jesters are always legal
|
||||||
|
* — playing one is how you get out of following.
|
||||||
|
*/
|
||||||
|
fun playableCards(state: WizardState, seat: Int): List<Card> {
|
||||||
|
val hand = state.hands[seat]
|
||||||
|
val led = ledSuitOf(state.trick) ?: return hand
|
||||||
|
val following = hand.filter { it.suit == led && !isWizardCard(it) && !isJester(it) }
|
||||||
|
if (following.isEmpty()) return hand
|
||||||
|
// Keep the hand's own order so the UI does not reshuffle under the player.
|
||||||
|
return hand.filter { it.suit == led || isWizardCard(it) || isJester(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Move application
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
override fun applyMove(state: WizardState, seat: Int, move: WizardMove): WizardState {
|
||||||
|
require(currentSeat(state) == seat) { "Not seat $seat's turn" }
|
||||||
|
require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" }
|
||||||
|
return when (move) {
|
||||||
|
is ChooseTrump -> state.copy(
|
||||||
|
trump = move.suit,
|
||||||
|
turn = (state.dealer + 1) % state.options.playerCount,
|
||||||
|
log = state.log + "Dealer names ${move.suit.symbol} trump.",
|
||||||
|
)
|
||||||
|
|
||||||
|
is MakeBid -> applyBid(state, seat, move.tricks)
|
||||||
|
is PlayCard -> applyPlay(state, seat, move.card)
|
||||||
|
}.normalised()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun WizardState.normalised(): WizardState = copy(handCounts = hands.map { it.size })
|
||||||
|
|
||||||
|
private fun applyBid(state: WizardState, seat: Int, tricks: Int): WizardState {
|
||||||
|
val bids = state.bids.toMutableList()
|
||||||
|
bids[seat] = tricks
|
||||||
|
val next = state.copy(
|
||||||
|
bids = bids,
|
||||||
|
log = state.log + "Seat $seat bids $tricks.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if (bids.all { it != null }) {
|
||||||
|
val leader = (state.dealer + 1) % state.options.playerCount
|
||||||
|
return next.copy(phase = WizardPhase.PLAYING, turn = leader, leader = leader)
|
||||||
|
}
|
||||||
|
return next.copy(turn = (seat + 1) % state.options.playerCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyPlay(state: WizardState, seat: Int, card: Card): WizardState {
|
||||||
|
val hands = state.hands.toMutableList()
|
||||||
|
hands[seat] = hands[seat] - card
|
||||||
|
val trick = state.trick + PlayedCard(seat, card)
|
||||||
|
val played = state.copy(hands = hands, trick = trick, completedTrick = emptyList())
|
||||||
|
|
||||||
|
if (trick.size < state.options.playerCount) {
|
||||||
|
return played.copy(turn = (seat + 1) % state.options.playerCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
val ledSuit = ledSuitOf(trick)
|
||||||
|
val winner = trick
|
||||||
|
.withIndex()
|
||||||
|
.maxBy { (index, played) -> trickStrength(played.card, index, state.trump, ledSuit) }
|
||||||
|
.value.seat
|
||||||
|
|
||||||
|
val tricksWon = played.tricksWon.toMutableList()
|
||||||
|
tricksWon[winner] = tricksWon[winner] + 1
|
||||||
|
|
||||||
|
val afterTrick = played.copy(
|
||||||
|
trick = emptyList(),
|
||||||
|
completedTrick = trick,
|
||||||
|
tricksWon = tricksWon,
|
||||||
|
leader = winner,
|
||||||
|
turn = winner,
|
||||||
|
lastTrickWinner = winner,
|
||||||
|
log = state.log + "Seat $winner takes the trick.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return if (afterTrick.hands.all { it.isEmpty() }) scoreRoundEnd(afterTrick) else afterTrick
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scoreRoundEnd(state: WizardState): WizardState {
|
||||||
|
val roundScores = state.bids.mapIndexed { seat, bid ->
|
||||||
|
scoreRound(bid ?: 0, state.tricksWon[seat])
|
||||||
|
}
|
||||||
|
val scores = state.scores.mapIndexed { seat, total -> total + roundScores[seat] }
|
||||||
|
val lastRound = state.round + 1 >= state.options.totalRounds()
|
||||||
|
|
||||||
|
return state.copy(
|
||||||
|
phase = if (lastRound) WizardPhase.GAME_OVER else WizardPhase.ROUND_OVER,
|
||||||
|
roundScores = roundScores,
|
||||||
|
scores = scores,
|
||||||
|
log = state.log + roundScores.mapIndexed { seat, points ->
|
||||||
|
"Seat $seat bid ${state.bids[seat]}, took ${state.tricksWon[seat]}: $points"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isFinished(state: WizardState): Boolean = state.phase == WizardPhase.GAME_OVER
|
||||||
|
|
||||||
|
override fun summary(state: WizardState): String = when (state.phase) {
|
||||||
|
WizardPhase.GAME_OVER -> {
|
||||||
|
val best = state.scores.indices.maxByOrNull { state.scores[it] } ?: 0
|
||||||
|
"Seat $best wins with ${state.scores[best]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> state.scores.mapIndexed { seat, s -> "S$seat:$s" }.joinToString(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun viewFor(state: WizardState, seat: Int): WizardState = state.copy(
|
||||||
|
hands = state.hands.mapIndexed { index, hand -> if (index == seat) hand else emptyList() },
|
||||||
|
handCounts = state.handCounts,
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun encodeState(state: WizardState): String = json.encodeToString(state)
|
||||||
|
override fun decodeState(json: String): WizardState = this.json.decodeFromString(json)
|
||||||
|
override fun encodeMove(move: WizardMove): String = json.encodeToString(move)
|
||||||
|
override fun decodeMove(json: String): WizardMove = this.json.decodeFromString(json)
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
package org.prolibertate.games.net
|
package org.prolibertate.games.net
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -231,17 +233,24 @@ class MatchController<S : Any, M : Any>(
|
|||||||
if (seatKinds[seat] != PlayerKind.AI) return // a person's turn
|
if (seatKinds[seat] != PlayerKind.AI) return // a person's turn
|
||||||
|
|
||||||
delay(holdBeforeNextMove(current) + aiThinkingMillis())
|
delay(holdBeforeNextMove(current) + aiThinkingMillis())
|
||||||
val chosen = lock.withLock {
|
|
||||||
|
val legal = rules.legalMoves(current, seat)
|
||||||
|
if (legal.isEmpty()) return
|
||||||
|
// Off the caller's thread. A card AI answers instantly, but a chess
|
||||||
|
// search runs for a second or more, and this scope belongs to the
|
||||||
|
// screen — thinking here would freeze the board it is drawn on.
|
||||||
|
val move = withContext(Dispatchers.Default) { ai.chooseMove(current, seat, legal) }
|
||||||
|
|
||||||
|
val applied = lock.withLock {
|
||||||
val live = authoritative ?: return
|
val live = authoritative ?: return
|
||||||
if (rules.currentSeat(live) != seat) return@withLock null
|
// Nothing else can move while an AI seat is on the clock, but
|
||||||
val legal = rules.legalMoves(live, seat)
|
// the state is re-read rather than assumed.
|
||||||
if (legal.isEmpty()) return@withLock null
|
if (rules.currentSeat(live) != seat) return@withLock false
|
||||||
val move = ai.chooseMove(live, seat, legal)
|
|
||||||
authoritative = rules.applyMove(live, seat, move)
|
authoritative = rules.applyMove(live, seat, move)
|
||||||
publish()
|
publish()
|
||||||
move
|
true
|
||||||
}
|
}
|
||||||
if (chosen == null) return
|
if (!applied) return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ private fun AppContent(
|
|||||||
descriptor = GameCatalog.byId(route.gameId)!!,
|
descriptor = GameCatalog.byId(route.gameId)!!,
|
||||||
playerName = settings.displayName,
|
playerName = settings.displayName,
|
||||||
onBack = { pop() },
|
onBack = { pop() },
|
||||||
onPlayOffline = { config -> push(Route.Play(route.gameId, config, true, 0)) },
|
onPlayOffline = { config, seat -> push(Route.Play(route.gameId, config, true, seat)) },
|
||||||
onHostOnline = { optionsJson ->
|
onHostOnline = { optionsJson ->
|
||||||
push(Route.Lobby(route.gameId, optionsJson, hosting = true))
|
push(Route.Lobby(route.gameId, optionsJson, hosting = true))
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,12 +30,20 @@ import org.prolibertate.games.game.GameDescriptor
|
|||||||
import org.prolibertate.games.game.engine.PlayerKind
|
import org.prolibertate.games.game.engine.PlayerKind
|
||||||
import org.prolibertate.games.game.engine.PlayerSlot
|
import org.prolibertate.games.game.engine.PlayerSlot
|
||||||
import org.prolibertate.games.game.engine.TableConfig
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
import org.prolibertate.games.game.chess.ChessLevel
|
||||||
|
import org.prolibertate.games.game.chess.ChessOptions
|
||||||
|
import org.prolibertate.games.game.crazyeights.CrazyEightsOptions
|
||||||
import org.prolibertate.games.game.euchre.EuchreOptions
|
import org.prolibertate.games.game.euchre.EuchreOptions
|
||||||
import org.prolibertate.games.game.golf.GolfOptions
|
import org.prolibertate.games.game.golf.GolfOptions
|
||||||
|
import org.prolibertate.games.game.kaiser.KaiserOptions
|
||||||
import org.prolibertate.games.game.president.PresidentOptions
|
import org.prolibertate.games.game.president.PresidentOptions
|
||||||
import org.prolibertate.games.game.sequence.SequenceOptions
|
import org.prolibertate.games.game.sequence.SequenceOptions
|
||||||
|
import org.prolibertate.games.game.wizard.WizardOptions
|
||||||
|
|
||||||
private val setupJson = Json { encodeDefaults = true }
|
private val setupJson = Json {
|
||||||
|
encodeDefaults = true
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Choose who is playing and which house rules apply.
|
* Choose who is playing and which house rules apply.
|
||||||
@@ -48,28 +56,33 @@ fun GameSetupScreen(
|
|||||||
descriptor: GameDescriptor,
|
descriptor: GameDescriptor,
|
||||||
playerName: String,
|
playerName: String,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onPlayOffline: (TableConfig) -> Unit,
|
onPlayOffline: (TableConfig, Int) -> Unit,
|
||||||
onHostOnline: (String) -> Unit,
|
onHostOnline: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
var euchre by remember { mutableStateOf(EuchreOptions()) }
|
var euchre by remember { mutableStateOf(EuchreOptions()) }
|
||||||
var sequence by remember { mutableStateOf(SequenceOptions()) }
|
var sequence by remember { mutableStateOf(SequenceOptions()) }
|
||||||
var president by remember { mutableStateOf(PresidentOptions()) }
|
var president by remember { mutableStateOf(PresidentOptions()) }
|
||||||
var golf by remember { mutableStateOf(GolfOptions()) }
|
var golf by remember { mutableStateOf(GolfOptions()) }
|
||||||
|
var kaiser by remember { mutableStateOf(KaiserOptions()) }
|
||||||
|
var crazyEights by remember { mutableStateOf(CrazyEightsOptions()) }
|
||||||
|
var wizard by remember { mutableStateOf(WizardOptions()) }
|
||||||
|
var chess by remember { mutableStateOf(ChessOptions()) }
|
||||||
|
// Not an engine option: which seat the local player takes. Seat 0 is
|
||||||
|
// always White, so choosing Black means sitting in seat 1.
|
||||||
|
var playWhite by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
val optionsJson = when (descriptor.id) {
|
val optionsJson = when (descriptor.id) {
|
||||||
GameCatalog.EUCHRE -> setupJson.encodeToString(euchre)
|
GameCatalog.EUCHRE -> setupJson.encodeToString(euchre)
|
||||||
GameCatalog.SEQUENCE -> setupJson.encodeToString(sequence)
|
GameCatalog.SEQUENCE -> setupJson.encodeToString(sequence)
|
||||||
GameCatalog.PRESIDENT -> setupJson.encodeToString(president)
|
GameCatalog.PRESIDENT -> setupJson.encodeToString(president)
|
||||||
GameCatalog.GOLF -> setupJson.encodeToString(golf)
|
GameCatalog.GOLF -> setupJson.encodeToString(golf)
|
||||||
|
GameCatalog.KAISER -> setupJson.encodeToString(kaiser)
|
||||||
|
GameCatalog.CRAZY_EIGHTS -> setupJson.encodeToString(crazyEights)
|
||||||
|
GameCatalog.WIZARD -> setupJson.encodeToString(wizard)
|
||||||
|
GameCatalog.CHESS -> setupJson.encodeToString(chess)
|
||||||
else -> "{}"
|
else -> "{}"
|
||||||
}
|
}
|
||||||
val seatCount = when (descriptor.id) {
|
val seatCount = seatCountFor(descriptor.id, optionsJson)
|
||||||
GameCatalog.EUCHRE -> 4
|
|
||||||
GameCatalog.SEQUENCE -> sequence.playerCount
|
|
||||||
GameCatalog.PRESIDENT -> president.playerCount
|
|
||||||
GameCatalog.GOLF -> golf.playerCount
|
|
||||||
else -> descriptor.minPlayers
|
|
||||||
}
|
|
||||||
|
|
||||||
ScreenScaffold(title = descriptor.title, onBack = onBack) { modifier ->
|
ScreenScaffold(title = descriptor.title, onBack = onBack) { modifier ->
|
||||||
Column(
|
Column(
|
||||||
@@ -86,6 +99,19 @@ fun GameSetupScreen(
|
|||||||
GameCatalog.SEQUENCE -> SequenceOptionsEditor(sequence) { sequence = it }
|
GameCatalog.SEQUENCE -> SequenceOptionsEditor(sequence) { sequence = it }
|
||||||
GameCatalog.PRESIDENT -> PresidentOptionsEditor(president) { president = it }
|
GameCatalog.PRESIDENT -> PresidentOptionsEditor(president) { president = it }
|
||||||
GameCatalog.GOLF -> GolfOptionsEditor(golf) { golf = it }
|
GameCatalog.GOLF -> GolfOptionsEditor(golf) { golf = it }
|
||||||
|
GameCatalog.KAISER -> KaiserOptionsEditor(kaiser) { kaiser = it }
|
||||||
|
GameCatalog.CRAZY_EIGHTS -> CrazyEightsOptionsEditor(crazyEights) {
|
||||||
|
crazyEights = it
|
||||||
|
}
|
||||||
|
|
||||||
|
GameCatalog.WIZARD -> WizardOptionsEditor(wizard) { wizard = it }
|
||||||
|
GameCatalog.CHESS -> ChessOptionsEditor(
|
||||||
|
options = chess,
|
||||||
|
playWhite = playWhite,
|
||||||
|
onChange = { chess = it },
|
||||||
|
onColour = { playWhite = it },
|
||||||
|
)
|
||||||
|
|
||||||
else -> Text("No options yet for this game.")
|
else -> Text("No options yet for this game.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,9 +124,13 @@ fun GameSetupScreen(
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Chess is the only game so far where the local player picks a
|
||||||
|
// seat rather than always taking the first one.
|
||||||
|
val localSeat = if (descriptor.id == GameCatalog.CHESS && !playWhite) 1 else 0
|
||||||
PrimaryAction(text = "Play against the computer") {
|
PrimaryAction(text = "Play against the computer") {
|
||||||
onPlayOffline(
|
onPlayOffline(
|
||||||
offlineConfig(descriptor.id, optionsJson, seatCount, playerName)
|
offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat),
|
||||||
|
localSeat,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
PrimaryAction(text = "Host a game for others to join") {
|
PrimaryAction(text = "Host a game for others to join") {
|
||||||
@@ -110,19 +140,20 @@ fun GameSetupScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Seat 0 is the local player; the rest start as AI. */
|
/** [localSeat] is the person at this device; every other seat starts as AI. */
|
||||||
private fun offlineConfig(
|
private fun offlineConfig(
|
||||||
gameId: String,
|
gameId: String,
|
||||||
optionsJson: String,
|
optionsJson: String,
|
||||||
seatCount: Int,
|
seatCount: Int,
|
||||||
playerName: String,
|
playerName: String,
|
||||||
|
localSeat: Int = 0,
|
||||||
): TableConfig = TableConfig(
|
): TableConfig = TableConfig(
|
||||||
gameId = gameId,
|
gameId = gameId,
|
||||||
seats = (0 until seatCount).map { seat ->
|
seats = (0 until seatCount).map { seat ->
|
||||||
PlayerSlot(
|
PlayerSlot(
|
||||||
seat = seat,
|
seat = seat,
|
||||||
name = if (seat == 0) playerName else "Computer $seat",
|
name = if (seat == localSeat) playerName else "Computer $seat",
|
||||||
kind = if (seat == 0) PlayerKind.HUMAN_LOCAL else PlayerKind.AI,
|
kind = if (seat == localSeat) PlayerKind.HUMAN_LOCAL else PlayerKind.AI,
|
||||||
team = teamForSeat(gameId, seat),
|
team = teamForSeat(gameId, seat),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -131,9 +162,42 @@ private fun offlineConfig(
|
|||||||
seed = System.currentTimeMillis(),
|
seed = System.currentTimeMillis(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many seats a table needs, read back out of the options the host chose.
|
||||||
|
*
|
||||||
|
* The lobby only carries the options as JSON, so this is the one place that
|
||||||
|
* knows a six-handed Wizard game needs six seats rather than the catalogue's
|
||||||
|
* minimum — seat the wrong number and the rules engine rejects the table.
|
||||||
|
*/
|
||||||
|
fun seatCountFor(gameId: String, optionsJson: String): Int {
|
||||||
|
val fallback = GameCatalog.byId(gameId)?.minPlayers ?: 2
|
||||||
|
return runCatching {
|
||||||
|
when (gameId) {
|
||||||
|
GameCatalog.EUCHRE, GameCatalog.KAISER -> 4
|
||||||
|
GameCatalog.SEQUENCE ->
|
||||||
|
setupJson.decodeFromString<SequenceOptions>(optionsJson).playerCount
|
||||||
|
|
||||||
|
GameCatalog.PRESIDENT ->
|
||||||
|
setupJson.decodeFromString<PresidentOptions>(optionsJson).playerCount
|
||||||
|
|
||||||
|
GameCatalog.GOLF ->
|
||||||
|
setupJson.decodeFromString<GolfOptions>(optionsJson).playerCount
|
||||||
|
|
||||||
|
GameCatalog.CRAZY_EIGHTS ->
|
||||||
|
setupJson.decodeFromString<CrazyEightsOptions>(optionsJson).playerCount
|
||||||
|
|
||||||
|
GameCatalog.WIZARD ->
|
||||||
|
setupJson.decodeFromString<WizardOptions>(optionsJson).playerCount
|
||||||
|
|
||||||
|
GameCatalog.CHESS -> 2
|
||||||
|
else -> fallback
|
||||||
|
}
|
||||||
|
}.getOrDefault(fallback)
|
||||||
|
}
|
||||||
|
|
||||||
fun teamForSeat(gameId: String, seat: Int): Int = when (gameId) {
|
fun teamForSeat(gameId: String, seat: Int): Int = when (gameId) {
|
||||||
// Partners sit opposite each other.
|
// Partners sit opposite each other.
|
||||||
GameCatalog.EUCHRE, GameCatalog.SEQUENCE -> seat % 2
|
GameCatalog.EUCHRE, GameCatalog.KAISER, GameCatalog.SEQUENCE -> seat % 2
|
||||||
// Everyone else plays for themselves.
|
// Everyone else plays for themselves.
|
||||||
else -> seat
|
else -> seat
|
||||||
}
|
}
|
||||||
@@ -305,6 +369,155 @@ private fun GolfOptionsEditor(options: GolfOptions, onChange: (GolfOptions) -> U
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun KaiserOptionsEditor(options: KaiserOptions, onChange: (KaiserOptions) -> Unit) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
ChipRow(
|
||||||
|
label = "Lowest opening bid",
|
||||||
|
values = listOf(5, 6, 7, 8),
|
||||||
|
selected = options.minimumBid,
|
||||||
|
display = { "$it" },
|
||||||
|
onSelect = { onChange(options.copy(minimumBid = it)) },
|
||||||
|
)
|
||||||
|
ChipRow(
|
||||||
|
label = "Game to",
|
||||||
|
values = listOf(32, 52, 62),
|
||||||
|
selected = options.pointsToWin,
|
||||||
|
display = { "$it points" },
|
||||||
|
onSelect = { target ->
|
||||||
|
// The floor moves with the target: a long game needs a long
|
||||||
|
// rope before a team that keeps going down is written off.
|
||||||
|
onChange(options.copy(pointsToWin = target, losingScore = -target))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ToggleRow(
|
||||||
|
title = "No-trump bids",
|
||||||
|
subtitle = "Pays double, and costs double when it goes down",
|
||||||
|
checked = options.allowNoTrump,
|
||||||
|
onChange = { onChange(options.copy(allowNoTrump = it)) },
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Four players in two partnerships. The 5♥ is worth five and the 3♠ " +
|
||||||
|
"costs three, so a hand is worth ten points in all.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CrazyEightsOptionsEditor(
|
||||||
|
options: CrazyEightsOptions,
|
||||||
|
onChange: (CrazyEightsOptions) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
ChipRow(
|
||||||
|
label = "Players",
|
||||||
|
values = listOf(2, 3, 4, 5, 6),
|
||||||
|
selected = options.playerCount,
|
||||||
|
display = { "$it" },
|
||||||
|
onSelect = { onChange(options.copy(playerCount = it)) },
|
||||||
|
)
|
||||||
|
ChipRow(
|
||||||
|
label = "Cards each",
|
||||||
|
values = listOf(0, 5, 7, 9),
|
||||||
|
selected = options.startingHand,
|
||||||
|
display = { if (it == 0) "standard" else "$it" },
|
||||||
|
onSelect = { onChange(options.copy(startingHand = it)) },
|
||||||
|
)
|
||||||
|
ChipRow(
|
||||||
|
label = "Rounds",
|
||||||
|
values = listOf(1, 3, 5),
|
||||||
|
selected = options.roundsToPlay,
|
||||||
|
display = { "$it" },
|
||||||
|
onSelect = { onChange(options.copy(roundsToPlay = it)) },
|
||||||
|
)
|
||||||
|
ToggleRow(
|
||||||
|
title = "Draw until you can play",
|
||||||
|
subtitle = "Otherwise you draw a single card and the turn passes on",
|
||||||
|
checked = options.drawUntilPlayable,
|
||||||
|
onChange = { onChange(options.copy(drawUntilPlayable = it)) },
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Standard is seven cards heads-up and five otherwise. Eights are wild " +
|
||||||
|
"and cost fifty if you are caught holding one.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WizardOptionsEditor(options: WizardOptions, onChange: (WizardOptions) -> Unit) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
ChipRow(
|
||||||
|
label = "Players",
|
||||||
|
values = listOf(3, 4, 5, 6),
|
||||||
|
selected = options.playerCount,
|
||||||
|
display = { "$it" },
|
||||||
|
onSelect = { onChange(options.copy(playerCount = it)) },
|
||||||
|
)
|
||||||
|
ChipRow(
|
||||||
|
label = "Rounds",
|
||||||
|
values = listOf(0, 5, 10),
|
||||||
|
selected = options.rounds,
|
||||||
|
display = { if (it == 0) "full game" else "$it" },
|
||||||
|
onSelect = { onChange(options.copy(rounds = it)) },
|
||||||
|
)
|
||||||
|
ToggleRow(
|
||||||
|
title = "Screw the dealer",
|
||||||
|
subtitle = "The dealer may not make the bids add up, so somebody always misses",
|
||||||
|
checked = options.screwTheDealer,
|
||||||
|
onChange = { onChange(options.copy(screwTheDealer = it)) },
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "A full game deals the whole sixty-card pack out: " +
|
||||||
|
"${options.totalRounds()} rounds at this table.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChessOptionsEditor(
|
||||||
|
options: ChessOptions,
|
||||||
|
playWhite: Boolean,
|
||||||
|
onChange: (ChessOptions) -> Unit,
|
||||||
|
onColour: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
ChipRow(
|
||||||
|
label = "You play",
|
||||||
|
values = listOf(true, false),
|
||||||
|
selected = playWhite,
|
||||||
|
display = { if (it) "White" else "Black" },
|
||||||
|
onSelect = onColour,
|
||||||
|
)
|
||||||
|
ChipRow(
|
||||||
|
label = "Opponent",
|
||||||
|
values = ChessLevel.entries.toList(),
|
||||||
|
selected = options.level,
|
||||||
|
display = { it.label },
|
||||||
|
onSelect = { onChange(options.copy(level = it)) },
|
||||||
|
)
|
||||||
|
ToggleRow(
|
||||||
|
title = "Fifty-move rule",
|
||||||
|
subtitle = "Fifty moves each with no capture and no pawn move is a draw",
|
||||||
|
checked = options.fiftyMoveRule,
|
||||||
|
onChange = { onChange(options.copy(fiftyMoveRule = it)) },
|
||||||
|
)
|
||||||
|
ToggleRow(
|
||||||
|
title = "Threefold repetition",
|
||||||
|
subtitle = "The same position three times over is a draw",
|
||||||
|
checked = options.threefoldRepetition,
|
||||||
|
onChange = { onChange(options.copy(threefoldRepetition = it)) },
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Full rules: castling, en passant, promotion, stalemate, and a draw " +
|
||||||
|
"when neither side has enough material to mate.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun <T> ChipRow(
|
private fun <T> ChipRow(
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import org.prolibertate.games.game.GameCatalog
|
|
||||||
import org.prolibertate.games.game.engine.PlayerKind
|
import org.prolibertate.games.game.engine.PlayerKind
|
||||||
import org.prolibertate.games.game.engine.TableConfig
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
import org.prolibertate.games.net.DiscoveredHost
|
import org.prolibertate.games.net.DiscoveredHost
|
||||||
@@ -46,10 +45,9 @@ fun LobbyScreen(
|
|||||||
LaunchedEffect(route) {
|
LaunchedEffect(route) {
|
||||||
env.requestBluetoothPermissions()
|
env.requestBluetoothPermissions()
|
||||||
if (route.hosting) {
|
if (route.hosting) {
|
||||||
val descriptor = GameCatalog.byId(route.gameId)
|
// Seat the table the host actually set up, not the catalogue
|
||||||
val seatCount = descriptor?.let {
|
// minimum: the rules engine rejects a table of the wrong size.
|
||||||
if (it.id == GameCatalog.EUCHRE) 4 else it.minPlayers
|
val seatCount = seatCountFor(route.gameId, route.optionsJson)
|
||||||
} ?: 4
|
|
||||||
env.lobby.startHosting(
|
env.lobby.startHosting(
|
||||||
gameId = route.gameId,
|
gameId = route.gameId,
|
||||||
optionsJson = route.optionsJson,
|
optionsJson = route.optionsJson,
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import org.prolibertate.games.game.GameCatalog
|
import org.prolibertate.games.game.GameCatalog
|
||||||
|
import org.prolibertate.games.game.chess.ChessAi
|
||||||
|
import org.prolibertate.games.game.chess.ChessMove
|
||||||
|
import org.prolibertate.games.game.chess.ChessRules
|
||||||
|
import org.prolibertate.games.game.chess.ChessState
|
||||||
|
import org.prolibertate.games.game.crazyeights.CrazyEightsAi
|
||||||
|
import org.prolibertate.games.game.crazyeights.CrazyEightsMove
|
||||||
|
import org.prolibertate.games.game.crazyeights.CrazyEightsPhase
|
||||||
|
import org.prolibertate.games.game.crazyeights.CrazyEightsRules
|
||||||
|
import org.prolibertate.games.game.crazyeights.CrazyEightsState
|
||||||
import org.prolibertate.games.game.euchre.EuchreAi
|
import org.prolibertate.games.game.euchre.EuchreAi
|
||||||
import org.prolibertate.games.game.euchre.EuchreMove
|
import org.prolibertate.games.game.euchre.EuchreMove
|
||||||
import org.prolibertate.games.game.euchre.EuchrePhase
|
import org.prolibertate.games.game.euchre.EuchrePhase
|
||||||
@@ -17,6 +26,11 @@ import org.prolibertate.games.game.golf.GolfMove
|
|||||||
import org.prolibertate.games.game.golf.GolfPhase
|
import org.prolibertate.games.game.golf.GolfPhase
|
||||||
import org.prolibertate.games.game.golf.GolfRules
|
import org.prolibertate.games.game.golf.GolfRules
|
||||||
import org.prolibertate.games.game.golf.GolfState
|
import org.prolibertate.games.game.golf.GolfState
|
||||||
|
import org.prolibertate.games.game.kaiser.KaiserAi
|
||||||
|
import org.prolibertate.games.game.kaiser.KaiserMove
|
||||||
|
import org.prolibertate.games.game.kaiser.KaiserPhase
|
||||||
|
import org.prolibertate.games.game.kaiser.KaiserRules
|
||||||
|
import org.prolibertate.games.game.kaiser.KaiserState
|
||||||
import org.prolibertate.games.game.president.PresidentAi
|
import org.prolibertate.games.game.president.PresidentAi
|
||||||
import org.prolibertate.games.game.president.PresidentMove
|
import org.prolibertate.games.game.president.PresidentMove
|
||||||
import org.prolibertate.games.game.president.PresidentPhase
|
import org.prolibertate.games.game.president.PresidentPhase
|
||||||
@@ -26,13 +40,22 @@ import org.prolibertate.games.game.sequence.SequenceAi
|
|||||||
import org.prolibertate.games.game.sequence.SequenceMove
|
import org.prolibertate.games.game.sequence.SequenceMove
|
||||||
import org.prolibertate.games.game.sequence.SequenceRules
|
import org.prolibertate.games.game.sequence.SequenceRules
|
||||||
import org.prolibertate.games.game.sequence.SequenceState
|
import org.prolibertate.games.game.sequence.SequenceState
|
||||||
|
import org.prolibertate.games.game.wizard.WizardAi
|
||||||
|
import org.prolibertate.games.game.wizard.WizardMove
|
||||||
|
import org.prolibertate.games.game.wizard.WizardPhase
|
||||||
|
import org.prolibertate.games.game.wizard.WizardRules
|
||||||
|
import org.prolibertate.games.game.wizard.WizardState
|
||||||
import org.prolibertate.games.net.MatchController
|
import org.prolibertate.games.net.MatchController
|
||||||
import org.prolibertate.games.settings.Settings
|
import org.prolibertate.games.settings.Settings
|
||||||
|
import org.prolibertate.games.ui.game.ChessScreen
|
||||||
|
import org.prolibertate.games.ui.game.CrazyEightsScreen
|
||||||
import org.prolibertate.games.ui.game.EuchreScreen
|
import org.prolibertate.games.ui.game.EuchreScreen
|
||||||
import org.prolibertate.games.ui.game.GolfScreen
|
import org.prolibertate.games.ui.game.GolfScreen
|
||||||
|
import org.prolibertate.games.ui.game.KaiserScreen
|
||||||
import org.prolibertate.games.ui.game.PresidentScreen
|
import org.prolibertate.games.ui.game.PresidentScreen
|
||||||
import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS
|
import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS
|
||||||
import org.prolibertate.games.ui.game.SequenceScreen
|
import org.prolibertate.games.ui.game.SequenceScreen
|
||||||
|
import org.prolibertate.games.ui.game.WizardScreen
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the match for the chosen game and hands it to that game's screen.
|
* Builds the match for the chosen game and hands it to that game's screen.
|
||||||
@@ -154,6 +177,128 @@ fun PlayScreen(
|
|||||||
GolfScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
GolfScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GameCatalog.KAISER -> {
|
||||||
|
val controller = remember(route) {
|
||||||
|
MatchController<KaiserState, KaiserMove>(
|
||||||
|
rules = KaiserRules,
|
||||||
|
ai = KaiserAi(),
|
||||||
|
config = route.config,
|
||||||
|
scope = scope,
|
||||||
|
role = role,
|
||||||
|
localSeats = setOf(route.localSeat),
|
||||||
|
primarySeat = route.localSeat,
|
||||||
|
advanceIdle = { state ->
|
||||||
|
if (state.phase == KaiserPhase.HAND_OVER) {
|
||||||
|
KaiserRules.nextHand(state)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
aiThinkingMillis = { settings.scaled(700L) },
|
||||||
|
holdBeforeNextMove = { state ->
|
||||||
|
if (state.completedTrick.isNotEmpty()) {
|
||||||
|
settings.scaled(TRICK_HOLD_MILLIS)
|
||||||
|
} else {
|
||||||
|
0L
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
StartMatch(env, controller, route)
|
||||||
|
KaiserScreen(
|
||||||
|
controller = controller,
|
||||||
|
localSeat = route.localSeat,
|
||||||
|
trickHoldMillis = settings.scaled(TRICK_HOLD_MILLIS),
|
||||||
|
onExit = onExit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
GameCatalog.WIZARD -> {
|
||||||
|
val controller = remember(route) {
|
||||||
|
MatchController<WizardState, WizardMove>(
|
||||||
|
rules = WizardRules,
|
||||||
|
ai = WizardAi(),
|
||||||
|
config = route.config,
|
||||||
|
scope = scope,
|
||||||
|
role = role,
|
||||||
|
localSeats = setOf(route.localSeat),
|
||||||
|
primarySeat = route.localSeat,
|
||||||
|
advanceIdle = { state ->
|
||||||
|
if (state.phase == WizardPhase.ROUND_OVER) {
|
||||||
|
WizardRules.nextRound(state)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Bids against tricks taken are read before the next deal.
|
||||||
|
awaitsConfirmation = { state -> state.phase == WizardPhase.ROUND_OVER },
|
||||||
|
aiThinkingMillis = { settings.scaled(700L) },
|
||||||
|
holdBeforeNextMove = { state ->
|
||||||
|
if (state.completedTrick.isNotEmpty()) {
|
||||||
|
settings.scaled(TRICK_HOLD_MILLIS)
|
||||||
|
} else {
|
||||||
|
0L
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
StartMatch(env, controller, route)
|
||||||
|
WizardScreen(
|
||||||
|
controller = controller,
|
||||||
|
localSeat = route.localSeat,
|
||||||
|
trickHoldMillis = settings.scaled(TRICK_HOLD_MILLIS),
|
||||||
|
onExit = onExit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
GameCatalog.CRAZY_EIGHTS -> {
|
||||||
|
val controller = remember(route) {
|
||||||
|
MatchController<CrazyEightsState, CrazyEightsMove>(
|
||||||
|
rules = CrazyEightsRules,
|
||||||
|
ai = CrazyEightsAi(),
|
||||||
|
config = route.config,
|
||||||
|
scope = scope,
|
||||||
|
role = role,
|
||||||
|
localSeats = setOf(route.localSeat),
|
||||||
|
primarySeat = route.localSeat,
|
||||||
|
advanceIdle = { state ->
|
||||||
|
if (state.phase == CrazyEightsPhase.ROUND_OVER) {
|
||||||
|
CrazyEightsRules.nextRound(state)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
awaitsConfirmation = { state ->
|
||||||
|
state.phase == CrazyEightsPhase.ROUND_OVER
|
||||||
|
},
|
||||||
|
aiThinkingMillis = { settings.scaled(700L) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
StartMatch(env, controller, route)
|
||||||
|
CrazyEightsScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||||
|
}
|
||||||
|
|
||||||
|
GameCatalog.CHESS -> {
|
||||||
|
val controller = remember(route) {
|
||||||
|
MatchController<ChessState, ChessMove>(
|
||||||
|
rules = ChessRules,
|
||||||
|
// Strength comes from the table's own options, so the setup
|
||||||
|
// screen sets it without this having to decode anything.
|
||||||
|
ai = ChessAi(),
|
||||||
|
config = route.config,
|
||||||
|
scope = scope,
|
||||||
|
role = role,
|
||||||
|
localSeats = setOf(route.localSeat),
|
||||||
|
primarySeat = route.localSeat,
|
||||||
|
// The search is the thinking time; adding more on top of it
|
||||||
|
// would only make the computer look slower than it is.
|
||||||
|
aiThinkingMillis = { settings.scaled(250L) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
StartMatch(env, controller, route)
|
||||||
|
ChessScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||||
|
}
|
||||||
|
|
||||||
else -> Text("That game isn't playable yet.")
|
else -> Text("That game isn't playable yet.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import org.prolibertate.games.game.chess.ChessMove
|
||||||
|
import org.prolibertate.games.game.chess.ChessPhase
|
||||||
|
import org.prolibertate.games.game.chess.ChessRules
|
||||||
|
import org.prolibertate.games.game.chess.ChessState
|
||||||
|
import org.prolibertate.games.game.chess.Piece
|
||||||
|
import org.prolibertate.games.game.chess.PieceKind
|
||||||
|
import org.prolibertate.games.game.chess.fileOf
|
||||||
|
import org.prolibertate.games.game.chess.isLightSquare
|
||||||
|
import org.prolibertate.games.game.chess.rankOf
|
||||||
|
import org.prolibertate.games.game.chess.seatIsWhite
|
||||||
|
import org.prolibertate.games.game.chess.squareName
|
||||||
|
import org.prolibertate.games.net.MatchController
|
||||||
|
import org.prolibertate.games.ui.ScreenScaffold
|
||||||
|
|
||||||
|
/** Board colours, kept off the theme so the board reads the same in either mode. */
|
||||||
|
private val LightSquare = Color(0xFFEEDAB4)
|
||||||
|
private val DarkSquare = Color(0xFF9A7248)
|
||||||
|
private val SelectedSquare = Color(0x8830C030)
|
||||||
|
private val LastMoveSquare = Color(0x55FFC107)
|
||||||
|
private val CheckSquare = Color(0x99D32F2F)
|
||||||
|
private val MoveDot = Color(0x66000000)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chess board.
|
||||||
|
*
|
||||||
|
* Tap a piece, then tap where it goes. The board is always drawn from the local
|
||||||
|
* player's side, so a game against the computer as Black is played the way it
|
||||||
|
* would be over a real board rather than upside down.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ChessScreen(
|
||||||
|
controller: MatchController<ChessState, ChessMove>,
|
||||||
|
localSeat: Int,
|
||||||
|
onExit: () -> Unit,
|
||||||
|
) {
|
||||||
|
val state by controller.state.collectAsState()
|
||||||
|
val legal by controller.legalMoves.collectAsState()
|
||||||
|
|
||||||
|
var selected by remember { mutableStateOf<Int?>(null) }
|
||||||
|
// A pawn reaching the last rank is one tap that stands for four moves, so
|
||||||
|
// the destination is held while the piece is chosen.
|
||||||
|
var promotionTo by remember { mutableStateOf<Int?>(null) }
|
||||||
|
|
||||||
|
val current = state
|
||||||
|
if (current == null) {
|
||||||
|
ScreenScaffold(title = "Chess", onBack = onExit) { modifier ->
|
||||||
|
Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val flipped = !seatIsWhite(localSeat)
|
||||||
|
val fromSelected = selected?.let { from -> legal.filter { it.from == from } }.orEmpty()
|
||||||
|
|
||||||
|
val onSquareTapped: (Int) -> Unit = { square ->
|
||||||
|
val destinations = fromSelected.filter { it.to == square }
|
||||||
|
when {
|
||||||
|
destinations.any { it.promotion != null } -> promotionTo = square
|
||||||
|
destinations.isNotEmpty() -> {
|
||||||
|
controller.submit(destinations.first())
|
||||||
|
selected = null
|
||||||
|
}
|
||||||
|
// Tapping another of your own pieces switches the selection rather
|
||||||
|
// than clearing it, which is what a player expects after a misclick.
|
||||||
|
legal.any { it.from == square } -> selected = square
|
||||||
|
else -> selected = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ScreenScaffold(title = "Chess", onBack = onExit) { modifier ->
|
||||||
|
BoxWithConstraints(modifier = modifier) {
|
||||||
|
val wide = maxWidth > maxHeight
|
||||||
|
val boardSide = if (wide) {
|
||||||
|
minOf(maxHeight, maxWidth * 0.62f)
|
||||||
|
} else {
|
||||||
|
minOf(maxWidth, maxHeight * 0.72f)
|
||||||
|
}
|
||||||
|
|
||||||
|
val board: @Composable () -> Unit = {
|
||||||
|
Board(
|
||||||
|
state = current,
|
||||||
|
legalFromSelected = fromSelected,
|
||||||
|
selected = selected,
|
||||||
|
flipped = flipped,
|
||||||
|
side = boardSide,
|
||||||
|
onTap = onSquareTapped,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val panel: @Composable (Modifier) -> Unit = { panelModifier ->
|
||||||
|
Column(
|
||||||
|
modifier = panelModifier,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
StatusLine(current, localSeat)
|
||||||
|
MoveList(current, modifier = Modifier.weight(1f, fill = false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wide) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
board()
|
||||||
|
panel(Modifier.weight(1f).fillMaxSize())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
board()
|
||||||
|
panel(Modifier.fillMaxWidth().weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
promotionTo?.let { destination ->
|
||||||
|
PromotionDialog(
|
||||||
|
white = current.whiteToMove,
|
||||||
|
choices = fromSelected.filter { it.to == destination },
|
||||||
|
onPick = {
|
||||||
|
controller.submit(it)
|
||||||
|
promotionTo = null
|
||||||
|
selected = null
|
||||||
|
},
|
||||||
|
onDismiss = { promotionTo = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.phase == ChessPhase.GAME_OVER) {
|
||||||
|
ResultDialog(current, onExit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Board(
|
||||||
|
state: ChessState,
|
||||||
|
legalFromSelected: List<ChessMove>,
|
||||||
|
selected: Int?,
|
||||||
|
flipped: Boolean,
|
||||||
|
side: Dp,
|
||||||
|
onTap: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
val squareSide = side / 8
|
||||||
|
val destinations = legalFromSelected.map { it.to }.toSet()
|
||||||
|
val checkedKing = if (ChessRules.inCheck(state)) state.kingSquare(state.whiteToMove) else null
|
||||||
|
|
||||||
|
Column(modifier = Modifier.size(side).border(2.dp, DarkSquare)) {
|
||||||
|
for (row in 0 until 8) {
|
||||||
|
Row(modifier = Modifier.weight(1f)) {
|
||||||
|
for (column in 0 until 8) {
|
||||||
|
val display = row * 8 + column
|
||||||
|
// Turning the board round is one subtraction, so the rest of
|
||||||
|
// the screen never has to think about which side is which.
|
||||||
|
val square = if (flipped) 63 - display else display
|
||||||
|
Square(
|
||||||
|
state = state,
|
||||||
|
square = square,
|
||||||
|
squareSide = squareSide,
|
||||||
|
selected = square == selected,
|
||||||
|
isDestination = square in destinations,
|
||||||
|
inCheck = square == checkedKing,
|
||||||
|
modifier = Modifier.weight(1f).fillMaxSize(),
|
||||||
|
onTap = { onTap(square) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Square(
|
||||||
|
state: ChessState,
|
||||||
|
square: Int,
|
||||||
|
squareSide: Dp,
|
||||||
|
selected: Boolean,
|
||||||
|
isDestination: Boolean,
|
||||||
|
inCheck: Boolean,
|
||||||
|
modifier: Modifier,
|
||||||
|
onTap: () -> Unit,
|
||||||
|
) {
|
||||||
|
val piece = state.board[square]
|
||||||
|
val touchedByLastMove =
|
||||||
|
state.lastMove?.let { square == it.from || square == it.to } == true
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.background(if (isLightSquare(square)) LightSquare else DarkSquare)
|
||||||
|
.then(if (touchedByLastMove) Modifier.background(LastMoveSquare) else Modifier)
|
||||||
|
.then(if (inCheck) Modifier.background(CheckSquare) else Modifier)
|
||||||
|
.then(if (selected) Modifier.background(SelectedSquare) else Modifier)
|
||||||
|
.clickable { onTap() },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (piece != null) {
|
||||||
|
Text(
|
||||||
|
text = piece.glyph,
|
||||||
|
fontSize = (squareSide.value * 0.78f).sp,
|
||||||
|
// The glyphs carry their own colour, but an outline colour keeps
|
||||||
|
// white pieces visible on the light squares.
|
||||||
|
color = if (piece.white) Color(0xFFFFFFFF) else Color(0xFF101010),
|
||||||
|
modifier = Modifier.padding(bottom = squareSide * 0.08f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (isDestination) {
|
||||||
|
// A dot for an empty square, a ring for something to take.
|
||||||
|
if (piece == null) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(squareSide * 0.28f)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MoveDot)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(squareSide * 0.04f)
|
||||||
|
.border(squareSide * 0.08f, MoveDot, CircleShape)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Coordinates on the outer files and ranks, as a printed board has.
|
||||||
|
if (fileOf(square) == 0) {
|
||||||
|
Text(
|
||||||
|
text = "${rankOf(square) + 1}",
|
||||||
|
fontSize = (squareSide.value * 0.2f).sp,
|
||||||
|
color = if (isLightSquare(square)) DarkSquare else LightSquare,
|
||||||
|
modifier = Modifier.align(Alignment.TopStart).padding(1.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (rankOf(square) == 0) {
|
||||||
|
Text(
|
||||||
|
text = "${'a' + fileOf(square)}",
|
||||||
|
fontSize = (squareSide.value * 0.2f).sp,
|
||||||
|
color = if (isLightSquare(square)) DarkSquare else LightSquare,
|
||||||
|
modifier = Modifier.align(Alignment.BottomEnd).padding(1.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StatusLine(state: ChessState, localSeat: Int) {
|
||||||
|
val yourMove = state.turnSeat == localSeat && state.phase == ChessPhase.PLAYING
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = state.outcome?.label ?: when {
|
||||||
|
yourMove && ChessRules.inCheck(state) -> "You are in check"
|
||||||
|
yourMove -> "Your move"
|
||||||
|
ChessRules.inCheck(state) -> "Check"
|
||||||
|
else -> "Thinking…"
|
||||||
|
},
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "You play ${if (seatIsWhite(localSeat)) "White" else "Black"} · " +
|
||||||
|
"move ${state.fullmoveNumber}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The game score, two half-moves to a line, most recent at the bottom. */
|
||||||
|
@Composable
|
||||||
|
private fun MoveList(state: ChessState, modifier: Modifier = Modifier) {
|
||||||
|
if (state.moveLog.isEmpty()) {
|
||||||
|
Text("No moves yet.", style = MaterialTheme.typography.bodySmall, modifier = modifier)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Column(modifier = modifier.verticalScroll(rememberScrollState())) {
|
||||||
|
state.moveLog.chunked(2).forEachIndexed { index, pair ->
|
||||||
|
Text(
|
||||||
|
text = "${index + 1}. ${pair.joinToString(" ")}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PromotionDialog(
|
||||||
|
white: Boolean,
|
||||||
|
choices: List<ChessMove>,
|
||||||
|
onPick: (ChessMove) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val order = listOf(PieceKind.QUEEN, PieceKind.ROOK, PieceKind.BISHOP, PieceKind.KNIGHT)
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Promote to") },
|
||||||
|
text = {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
order.forEach { kind ->
|
||||||
|
val move = choices.firstOrNull { it.promotion == kind } ?: return@forEach
|
||||||
|
Button(onClick = { onPick(move) }, modifier = Modifier.width(64.dp)) {
|
||||||
|
Text(
|
||||||
|
text = Piece(kind, white).glyph,
|
||||||
|
fontSize = 24.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ResultDialog(state: ChessState, onExit: () -> Unit) {
|
||||||
|
var dismissed by remember(state.outcome) { mutableStateOf(false) }
|
||||||
|
if (dismissed) return
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { dismissed = true },
|
||||||
|
title = { Text("Game over") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(state.outcome?.label.orEmpty(), fontWeight = FontWeight.Bold)
|
||||||
|
state.lastMove?.let {
|
||||||
|
Text(
|
||||||
|
text = "Last move ${state.moveLog.lastOrNull() ?: squareName(it.to)}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "${state.moveLog.size} half-moves played.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = onExit) { Text("Back to the menu") } },
|
||||||
|
dismissButton = { TextButton(onClick = { dismissed = true }) { Text("Look at the board") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.prolibertate.games.game.cards.Card
|
||||||
|
import org.prolibertate.games.game.crazyeights.CrazyEightsMove
|
||||||
|
import org.prolibertate.games.game.crazyeights.CrazyEightsPhase
|
||||||
|
import org.prolibertate.games.game.crazyeights.CrazyEightsState
|
||||||
|
import org.prolibertate.games.game.crazyeights.DrawCard
|
||||||
|
import org.prolibertate.games.game.crazyeights.PassTurn
|
||||||
|
import org.prolibertate.games.game.crazyeights.PlayCard
|
||||||
|
import org.prolibertate.games.game.crazyeights.crazyEightsPenalty
|
||||||
|
import org.prolibertate.games.game.crazyeights.isWild
|
||||||
|
import org.prolibertate.games.net.MatchController
|
||||||
|
import org.prolibertate.games.ui.ScreenScaffold
|
||||||
|
import org.prolibertate.games.ui.common.CardBackView
|
||||||
|
import org.prolibertate.games.ui.common.PlayingCardView
|
||||||
|
import org.prolibertate.games.ui.theme.FeltGreenDark
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Crazy 8s table.
|
||||||
|
*
|
||||||
|
* The one thing this screen has to get right is that the suit being followed is
|
||||||
|
* not always the suit on top of the pile — an eight changes it — so the suit in
|
||||||
|
* force is stated in words rather than left to be read off the card.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CrazyEightsScreen(
|
||||||
|
controller: MatchController<CrazyEightsState, CrazyEightsMove>,
|
||||||
|
localSeat: Int,
|
||||||
|
onExit: () -> Unit,
|
||||||
|
) {
|
||||||
|
val state by controller.state.collectAsState()
|
||||||
|
val legal by controller.legalMoves.collectAsState()
|
||||||
|
|
||||||
|
// Playing an eight is two decisions: the card, then the suit it turns the
|
||||||
|
// game to. The card is held here while the suit is chosen.
|
||||||
|
var pendingWild by remember { mutableStateOf<Card?>(null) }
|
||||||
|
|
||||||
|
val current = state
|
||||||
|
if (current == null) {
|
||||||
|
ScreenScaffold(title = "Crazy 8s", onBack = onExit) { modifier ->
|
||||||
|
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ScreenScaffold(title = "Crazy 8s", onBack = onExit) { modifier ->
|
||||||
|
BoxWithConstraints(modifier = modifier) {
|
||||||
|
val cardWidth = (minOf(maxWidth, maxHeight) * 0.16f).coerceIn(44.dp, 92.dp)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
ScoreLine(current, localSeat)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f)
|
||||||
|
.background(FeltGreenDark),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
PileArea(current, cardWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
OpponentCounts(current, localSeat)
|
||||||
|
|
||||||
|
if (current.phase == CrazyEightsPhase.PLAYING) {
|
||||||
|
TurnControls(
|
||||||
|
state = current,
|
||||||
|
localSeat = localSeat,
|
||||||
|
legal = legal,
|
||||||
|
pendingWild = pendingWild,
|
||||||
|
onCancelWild = { pendingWild = null },
|
||||||
|
onMove = { pendingWild = null; controller.submit(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HandRow(
|
||||||
|
state = current,
|
||||||
|
localSeat = localSeat,
|
||||||
|
legal = legal,
|
||||||
|
cardWidth = cardWidth,
|
||||||
|
pendingWild = pendingWild,
|
||||||
|
onTap = { card ->
|
||||||
|
if (isWild(card)) {
|
||||||
|
pendingWild = if (pendingWild == card) null else card
|
||||||
|
} else {
|
||||||
|
pendingWild = null
|
||||||
|
controller.submit(PlayCard(card))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.phase != CrazyEightsPhase.PLAYING) {
|
||||||
|
RoundScoreDialog(
|
||||||
|
state = current,
|
||||||
|
localSeat = localSeat,
|
||||||
|
onContinue = { controller.confirmAdvance() },
|
||||||
|
onExit = onExit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ScoreLine(state: CrazyEightsState, localSeat: Int) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "Round ${state.roundNumber + 1} of ${state.options.roundsToPlay}",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Your penalty so far: ${state.scores[localSeat]}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(horizontalAlignment = Alignment.End) {
|
||||||
|
Text("Play ${state.suitInForce.symbol}", fontWeight = FontWeight.Bold)
|
||||||
|
Text(
|
||||||
|
text = "${state.stock.size} left in the stock",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PileArea(state: CrazyEightsState, cardWidth: Dp) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
text = "Stock",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
if (state.stock.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "empty",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
CardBackView(width = cardWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
text = "Pile",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
state.topCard?.let { PlayingCardView(card = it, width = cardWidth) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An eight on top says nothing about what may be played next, so the
|
||||||
|
// suit in force is spelled out under the pile.
|
||||||
|
Text(
|
||||||
|
text = "Following ${state.suitInForce.symbol} ${state.suitInForce.name.lowercase()}",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OpponentCounts(state: CrazyEightsState, localSeat: Int) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
(0 until state.options.playerCount)
|
||||||
|
.filter { it != localSeat }
|
||||||
|
.forEach { seat ->
|
||||||
|
val count = state.handCounts.getOrElse(seat) { 0 }
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
text = "Seat $seat",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = if (seat == state.turn) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||||
|
repeat(count.coerceAtMost(8)) { CardBackView(width = 12.dp) }
|
||||||
|
}
|
||||||
|
Text("$count", style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun TurnControls(
|
||||||
|
state: CrazyEightsState,
|
||||||
|
localSeat: Int,
|
||||||
|
legal: List<CrazyEightsMove>,
|
||||||
|
pendingWild: Card?,
|
||||||
|
onCancelWild: () -> Unit,
|
||||||
|
onMove: (CrazyEightsMove) -> Unit,
|
||||||
|
) {
|
||||||
|
if (legal.isEmpty()) {
|
||||||
|
Text("Seat ${state.turn} to play…", style = MaterialTheme.typography.bodySmall)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingWild != null) {
|
||||||
|
val suits = legal.filterIsInstance<PlayCard>()
|
||||||
|
.filter { it.card == pendingWild }
|
||||||
|
.mapNotNull { move -> move.nominatedSuit?.let { it to move } }
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Text("Playing ${pendingWild.label} — name the suit", fontWeight = FontWeight.Bold)
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
suits.forEach { (suit, move) ->
|
||||||
|
Button(onClick = { onMove(move) }) {
|
||||||
|
Text("${suit.symbol} ${suit.name.lowercase()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutlinedButton(onClick = onCancelWild, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Pick a different card")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val canDraw = legal.contains(DrawCard)
|
||||||
|
val canPass = legal.contains(PassTurn)
|
||||||
|
val playable = legal.filterIsInstance<PlayCard>().isNotEmpty()
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Text(
|
||||||
|
text = when {
|
||||||
|
playable -> "Your turn — tap a card"
|
||||||
|
canDraw -> "Nothing to play"
|
||||||
|
else -> "Nothing to play and nothing to draw"
|
||||||
|
},
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
if (canDraw) {
|
||||||
|
Button(onClick = { onMove(DrawCard) }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
if (state.options.drawUntilPlayable) {
|
||||||
|
"Draw until something fits"
|
||||||
|
} else {
|
||||||
|
"Draw a card"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (canPass) {
|
||||||
|
OutlinedButton(onClick = { onMove(PassTurn) }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Pass")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HandRow(
|
||||||
|
state: CrazyEightsState,
|
||||||
|
localSeat: Int,
|
||||||
|
legal: List<CrazyEightsMove>,
|
||||||
|
cardWidth: Dp,
|
||||||
|
pendingWild: Card?,
|
||||||
|
onTap: (Card) -> Unit,
|
||||||
|
) {
|
||||||
|
val hand = state.hands.getOrNull(localSeat).orEmpty()
|
||||||
|
val playable = legal.filterIsInstance<PlayCard>().map { it.card }.toSet()
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "Your hand — ${hand.sumOf { crazyEightsPenalty(it) }} in penalties",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalScroll(rememberScrollState())
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
if (hand.isEmpty()) Text("—", style = MaterialTheme.typography.bodySmall)
|
||||||
|
hand.forEach { card ->
|
||||||
|
val canPlay = card in playable
|
||||||
|
PlayingCardView(
|
||||||
|
card = card,
|
||||||
|
width = cardWidth,
|
||||||
|
enabled = canPlay,
|
||||||
|
selected = card == pendingWild,
|
||||||
|
caption = if (isWild(card)) "WILD" else null,
|
||||||
|
onClick = { onTap(card) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The card at the end of a round: what everyone was caught holding. */
|
||||||
|
@Composable
|
||||||
|
private fun RoundScoreDialog(
|
||||||
|
state: CrazyEightsState,
|
||||||
|
localSeat: Int,
|
||||||
|
onContinue: () -> Unit,
|
||||||
|
onExit: () -> Unit,
|
||||||
|
) {
|
||||||
|
var dismissed by remember(state.roundNumber, state.phase) { mutableStateOf(false) }
|
||||||
|
if (dismissed) return
|
||||||
|
|
||||||
|
val finalRound = state.phase == CrazyEightsPhase.GAME_OVER
|
||||||
|
val standings = (0 until state.options.playerCount).sortedBy { state.scores[it] }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { },
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
if (finalRound) {
|
||||||
|
"Final score"
|
||||||
|
} else {
|
||||||
|
"Round ${state.roundNumber + 1} of ${state.options.roundsToPlay}"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(
|
||||||
|
text = state.roundWinner?.let {
|
||||||
|
if (it == localSeat) "You went out." else "Seat $it went out."
|
||||||
|
} ?: "The round was blocked — nobody went out.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
standings.forEach { seat ->
|
||||||
|
val you = seat == localSeat
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = if (you) "You" else "Seat $seat",
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
fontWeight = if (you) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
)
|
||||||
|
Text("${state.scores[seat]}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("Lowest penalty wins.", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
dismissed = true
|
||||||
|
if (finalRound) onExit() else onContinue()
|
||||||
|
}) {
|
||||||
|
Text(if (finalRound) "Back to the menu" else "Deal the next round")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import org.prolibertate.games.game.cards.Card
|
||||||
|
import org.prolibertate.games.game.kaiser.FIVE_OF_HEARTS
|
||||||
|
import org.prolibertate.games.game.kaiser.KaiserMove
|
||||||
|
import org.prolibertate.games.game.kaiser.KaiserPhase
|
||||||
|
import org.prolibertate.games.game.kaiser.KaiserState
|
||||||
|
import org.prolibertate.games.game.kaiser.MakeBid
|
||||||
|
import org.prolibertate.games.game.kaiser.NameTrump
|
||||||
|
import org.prolibertate.games.game.kaiser.PassBid
|
||||||
|
import org.prolibertate.games.game.kaiser.PlayCard
|
||||||
|
import org.prolibertate.games.game.kaiser.THREE_OF_SPADES
|
||||||
|
import org.prolibertate.games.game.kaiser.teamOf
|
||||||
|
import org.prolibertate.games.net.MatchController
|
||||||
|
import org.prolibertate.games.ui.PrimaryAction
|
||||||
|
import org.prolibertate.games.ui.ScreenScaffold
|
||||||
|
import org.prolibertate.games.ui.common.PlayingCardView
|
||||||
|
import org.prolibertate.games.ui.theme.FeltGreenDark
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Kaiser table.
|
||||||
|
*
|
||||||
|
* Laid out like the Euchre table — partners opposite, each card on the side of
|
||||||
|
* whoever played it — with the two counters called out wherever they appear,
|
||||||
|
* since the whole hand is really played for those two cards.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun KaiserScreen(
|
||||||
|
controller: MatchController<KaiserState, KaiserMove>,
|
||||||
|
localSeat: Int,
|
||||||
|
trickHoldMillis: Long,
|
||||||
|
onExit: () -> Unit,
|
||||||
|
) {
|
||||||
|
val state by controller.state.collectAsState()
|
||||||
|
val legal by controller.legalMoves.collectAsState()
|
||||||
|
val notice by controller.notice.collectAsState()
|
||||||
|
|
||||||
|
val current = state
|
||||||
|
if (current == null) {
|
||||||
|
ScreenScaffold(title = "Kaiser", onBack = onExit) { modifier ->
|
||||||
|
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ScreenScaffold(title = "Kaiser", onBack = onExit) { modifier ->
|
||||||
|
BoxWithConstraints(modifier = modifier) {
|
||||||
|
val compact = maxWidth < 600.dp
|
||||||
|
val cardWidth = (minOf(maxWidth, maxHeight) * if (compact) 0.15f else 0.11f)
|
||||||
|
.coerceIn(44.dp, 96.dp)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
ScoreBar(current, localSeat)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f)
|
||||||
|
.background(FeltGreenDark),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
TrickArea(current, localSeat, cardWidth, trickHoldMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
notice?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||||
|
|
||||||
|
when (current.phase) {
|
||||||
|
KaiserPhase.BIDDING ->
|
||||||
|
BiddingControls(current, localSeat, legal) { controller.submit(it) }
|
||||||
|
|
||||||
|
KaiserPhase.HAND_OVER -> Text(
|
||||||
|
text = current.log.lastOrNull().orEmpty(),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
|
||||||
|
KaiserPhase.GAME_OVER -> {
|
||||||
|
Text("Game over.", fontWeight = FontWeight.Bold)
|
||||||
|
PrimaryAction(text = "Back to the menu") { onExit() }
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
HandRow(current, localSeat, legal, cardWidth) { controller.submit(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ScoreBar(state: KaiserState, localSeat: Int) {
|
||||||
|
val myTeam = teamOf(localSeat)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "Us ${state.scores[myTeam]} — Them ${state.scores[1 - myTeam]}",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "This hand: ${state.handPoints[myTeam]} to us, " +
|
||||||
|
"${state.handPoints[1 - myTeam]} to them",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(horizontalAlignment = Alignment.End) {
|
||||||
|
val bid = state.highBid
|
||||||
|
Text(
|
||||||
|
text = when {
|
||||||
|
bid == null -> "Bidding"
|
||||||
|
state.trump == null && state.phase != KaiserPhase.BIDDING -> "No trump"
|
||||||
|
state.trump != null -> "Trump ${state.trump!!.symbol}"
|
||||||
|
else -> "Bid ${bid.points}"
|
||||||
|
},
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
if (bid != null) {
|
||||||
|
val bidder = state.highBidder
|
||||||
|
Text(
|
||||||
|
text = "Contract ${bid.points}${if (bid.noTrump) " no trump" else ""}" +
|
||||||
|
(bidder?.let { " by ${seatLabel(relativePosition(it, localSeat))}" } ?: ""),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a seat sits relative to you. Partners sit opposite, so 2 is yours. */
|
||||||
|
private fun relativePosition(seat: Int, localSeat: Int): Int = ((seat - localSeat) % 4 + 4) % 4
|
||||||
|
|
||||||
|
private fun seatLabel(relative: Int): String = when (relative) {
|
||||||
|
0 -> "You"
|
||||||
|
1 -> "Left"
|
||||||
|
2 -> "Partner"
|
||||||
|
else -> "Right"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Duration of the sweep towards the winner. */
|
||||||
|
private const val SWEEP_MILLIS = 450
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TrickArea(
|
||||||
|
state: KaiserState,
|
||||||
|
localSeat: Int,
|
||||||
|
cardWidth: Dp,
|
||||||
|
trickHoldMillis: Long,
|
||||||
|
) {
|
||||||
|
val sweeping = state.trick.isEmpty() && state.completedTrick.isNotEmpty()
|
||||||
|
val cardsOnTable = if (sweeping) state.completedTrick else state.trick
|
||||||
|
|
||||||
|
var sweepStarted by remember(state.completedTrick) { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(state.completedTrick) {
|
||||||
|
if (state.completedTrick.isNotEmpty()) {
|
||||||
|
delay(trickHoldMillis)
|
||||||
|
sweepStarted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val sweep by animateFloatAsState(
|
||||||
|
targetValue = if (sweeping && sweepStarted) 1f else 0f,
|
||||||
|
animationSpec = tween(durationMillis = SWEEP_MILLIS),
|
||||||
|
label = "kaiserSweep",
|
||||||
|
)
|
||||||
|
|
||||||
|
val winnerRelative = state.lastTrickWinner?.let { relativePosition(it, localSeat) } ?: 0
|
||||||
|
val (dirX, dirY) = when (winnerRelative) {
|
||||||
|
0 -> 0f to 1f
|
||||||
|
1 -> -1f to 0f
|
||||||
|
2 -> 0f to -1f
|
||||||
|
else -> 1f to 0f
|
||||||
|
}
|
||||||
|
val travelPx = with(LocalDensity.current) { (cardWidth * 2.5f).toPx() }
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize().padding(4.dp)) {
|
||||||
|
if (cardsOnTable.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = when {
|
||||||
|
state.phase == KaiserPhase.BIDDING -> "Bidding"
|
||||||
|
state.turn == localSeat -> "Lead a card"
|
||||||
|
else -> "Waiting…"
|
||||||
|
},
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
cardsOnTable.forEach { played ->
|
||||||
|
val relative = relativePosition(played.seat, localSeat)
|
||||||
|
val alignment = when (relative) {
|
||||||
|
0 -> Alignment.BottomCenter
|
||||||
|
1 -> Alignment.CenterStart
|
||||||
|
2 -> Alignment.TopCenter
|
||||||
|
else -> Alignment.CenterEnd
|
||||||
|
}
|
||||||
|
val rotation = when (relative) {
|
||||||
|
1 -> 90f
|
||||||
|
2 -> 180f
|
||||||
|
3 -> -90f
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(alignment)
|
||||||
|
.offset {
|
||||||
|
IntOffset(
|
||||||
|
x = (dirX * travelPx * sweep).roundToInt(),
|
||||||
|
y = (dirY * travelPx * sweep).roundToInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.alpha(1f - sweep),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
PlayingCardView(
|
||||||
|
card = played.card,
|
||||||
|
width = cardWidth,
|
||||||
|
caption = counterCaption(played.card),
|
||||||
|
modifier = Modifier.rotate(rotation),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = seatLabel(relative),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sweeping && state.lastTrickWinner != null) {
|
||||||
|
Text(
|
||||||
|
text = "${seatLabel(winnerRelative)} takes it",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
modifier = Modifier.align(Alignment.Center).alpha(1f - sweep),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.phase == KaiserPhase.PLAYING && state.trick.size < 4) {
|
||||||
|
val waitingOn = relativePosition(state.turn, localSeat)
|
||||||
|
if (waitingOn != 0 && state.trick.none { it.seat == state.turn }) {
|
||||||
|
Text(
|
||||||
|
text = "${seatLabel(waitingOn)} to play",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
modifier = Modifier.align(Alignment.BottomStart),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The two cards that decide the hand, named on their faces. */
|
||||||
|
private fun counterCaption(card: Card): String? = when (card) {
|
||||||
|
FIVE_OF_HEARTS -> "+5"
|
||||||
|
THREE_OF_SPADES -> "−3"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun BiddingControls(
|
||||||
|
state: KaiserState,
|
||||||
|
localSeat: Int,
|
||||||
|
legal: List<KaiserMove>,
|
||||||
|
onMove: (KaiserMove) -> Unit,
|
||||||
|
) {
|
||||||
|
if (legal.isEmpty()) {
|
||||||
|
val waitingOn = relativePosition(state.turn, localSeat)
|
||||||
|
Text("Waiting for ${seatLabel(waitingOn).lowercase()} to bid…")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val naming = legal.filterIsInstance<NameTrump>()
|
||||||
|
if (naming.isNotEmpty()) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Text("You won the bidding — name trump", fontWeight = FontWeight.Bold)
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
naming.forEach { move ->
|
||||||
|
Button(onClick = { onMove(move) }) {
|
||||||
|
Text(move.suit?.let { "${it.symbol} ${it.name.lowercase()}" } ?: "No trump")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val bids = legal.filterIsInstance<MakeBid>()
|
||||||
|
val canPass = legal.contains(PassBid)
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Text(
|
||||||
|
text = state.highBid?.let { high ->
|
||||||
|
"Bidding stands at ${high.points}${if (high.noTrump) " no trump" else ""}"
|
||||||
|
} ?: "Open the bidding",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
if (!canPass) {
|
||||||
|
Text(
|
||||||
|
text = "Everyone passed, so as dealer you have to take it.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Wraps rather than scrolls, so no bid can be pushed off the edge.
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
bids.forEach { move ->
|
||||||
|
Button(onClick = { onMove(move) }) {
|
||||||
|
Text("${move.bid.points}${if (move.bid.noTrump) " NT" else ""}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "A no-trump contract pays double, and costs double if it goes down.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
if (canPass) {
|
||||||
|
OutlinedButton(onClick = { onMove(PassBid) }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Pass")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HandRow(
|
||||||
|
state: KaiserState,
|
||||||
|
seat: Int,
|
||||||
|
legal: List<KaiserMove>,
|
||||||
|
cardWidth: Dp,
|
||||||
|
onPlay: (KaiserMove) -> Unit,
|
||||||
|
) {
|
||||||
|
val hand = state.hands.getOrNull(seat).orEmpty()
|
||||||
|
val playable = legal.filterIsInstance<PlayCard>().map { it.card }.toSet()
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = if (playable.isNotEmpty()) "Your turn" else "Your hand",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalScroll(rememberScrollState())
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
if (hand.isEmpty()) {
|
||||||
|
Box(modifier = Modifier.size(cardWidth)) {
|
||||||
|
Text("—", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hand.forEach { card ->
|
||||||
|
val canPlay = card in playable
|
||||||
|
PlayingCardView(
|
||||||
|
card = card,
|
||||||
|
width = cardWidth,
|
||||||
|
enabled = canPlay,
|
||||||
|
caption = counterCaption(card),
|
||||||
|
onClick = { if (canPlay) onPlay(PlayCard(card)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import org.prolibertate.games.game.cards.Card
|
||||||
|
import org.prolibertate.games.game.wizard.ChooseTrump
|
||||||
|
import org.prolibertate.games.game.wizard.MakeBid
|
||||||
|
import org.prolibertate.games.game.wizard.PlayCard
|
||||||
|
import org.prolibertate.games.game.wizard.WizardMove
|
||||||
|
import org.prolibertate.games.game.wizard.WizardPhase
|
||||||
|
import org.prolibertate.games.game.wizard.WizardState
|
||||||
|
import org.prolibertate.games.game.wizard.isJester
|
||||||
|
import org.prolibertate.games.game.wizard.isWizardCard
|
||||||
|
import org.prolibertate.games.net.MatchController
|
||||||
|
import org.prolibertate.games.ui.ScreenScaffold
|
||||||
|
import org.prolibertate.games.ui.common.PlayingCardView
|
||||||
|
import org.prolibertate.games.ui.theme.FeltGreenDark
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Wizard table.
|
||||||
|
*
|
||||||
|
* Three to six can play, so seats are placed round a circle rather than at four
|
||||||
|
* fixed edges: you are always at the bottom and everyone else is spaced out from
|
||||||
|
* there, which keeps the same layout code working at every table size.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun WizardScreen(
|
||||||
|
controller: MatchController<WizardState, WizardMove>,
|
||||||
|
localSeat: Int,
|
||||||
|
trickHoldMillis: Long,
|
||||||
|
onExit: () -> Unit,
|
||||||
|
) {
|
||||||
|
val state by controller.state.collectAsState()
|
||||||
|
val legal by controller.legalMoves.collectAsState()
|
||||||
|
val notice by controller.notice.collectAsState()
|
||||||
|
|
||||||
|
val current = state
|
||||||
|
if (current == null) {
|
||||||
|
ScreenScaffold(title = "Wizard", onBack = onExit) { modifier ->
|
||||||
|
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ScreenScaffold(title = "Wizard", onBack = onExit) { modifier ->
|
||||||
|
BoxWithConstraints(modifier = modifier) {
|
||||||
|
val cardWidth = (minOf(maxWidth, maxHeight) * 0.14f).coerceIn(40.dp, 88.dp)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
RoundBar(current, localSeat)
|
||||||
|
BidTally(current, localSeat)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f)
|
||||||
|
.background(FeltGreenDark),
|
||||||
|
) {
|
||||||
|
TrickArea(current, localSeat, cardWidth, trickHoldMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
notice?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||||
|
|
||||||
|
if (current.phase == WizardPhase.BIDDING) {
|
||||||
|
BiddingControls(current, localSeat, legal) { controller.submit(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
HandRow(current, localSeat, legal, cardWidth) { controller.submit(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.phase == WizardPhase.ROUND_OVER || current.phase == WizardPhase.GAME_OVER) {
|
||||||
|
RoundScoreDialog(
|
||||||
|
state = current,
|
||||||
|
localSeat = localSeat,
|
||||||
|
onContinue = { controller.confirmAdvance() },
|
||||||
|
onExit = onExit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RoundBar(state: WizardState, localSeat: Int) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "Round ${state.round + 1} of ${state.options.totalRounds()}" +
|
||||||
|
" — ${state.cardsThisRound} card(s)",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Your score: ${state.scores[localSeat]}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(horizontalAlignment = Alignment.End) {
|
||||||
|
Text(
|
||||||
|
text = state.trump?.let { "Trump ${it.symbol}" } ?: "No trump",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
state.trumpCard?.let {
|
||||||
|
Text("Turned ${it.label}", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everyone's bid against what they have actually taken — the whole game in a row. */
|
||||||
|
@Composable
|
||||||
|
private fun BidTally(state: WizardState, localSeat: Int) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
(0 until state.options.playerCount).forEach { seat ->
|
||||||
|
val bid = state.bids[seat]
|
||||||
|
val won = state.tricksWon[seat]
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
text = if (seat == localSeat) "You" else seatLabel(seat, localSeat),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = if (seat == state.turn) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = bid?.let { "$won / $it" } ?: "—",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
// Green while the bid is still on, red once it cannot be made.
|
||||||
|
color = when {
|
||||||
|
bid == null -> MaterialTheme.colorScheme.onSurface
|
||||||
|
won == bid -> MaterialTheme.colorScheme.primary
|
||||||
|
won > bid -> MaterialTheme.colorScheme.error
|
||||||
|
else -> MaterialTheme.colorScheme.onSurface
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seats are named by how far round the table they sit from you. */
|
||||||
|
private fun seatLabel(seat: Int, localSeat: Int): String =
|
||||||
|
if (seat == localSeat) "You" else "Seat $seat"
|
||||||
|
|
||||||
|
/** Duration of the sweep towards the winner. */
|
||||||
|
private const val SWEEP_MILLIS = 450
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TrickArea(
|
||||||
|
state: WizardState,
|
||||||
|
localSeat: Int,
|
||||||
|
cardWidth: Dp,
|
||||||
|
trickHoldMillis: Long,
|
||||||
|
) {
|
||||||
|
val players = state.options.playerCount
|
||||||
|
val sweeping = state.trick.isEmpty() && state.completedTrick.isNotEmpty()
|
||||||
|
val cardsOnTable = if (sweeping) state.completedTrick else state.trick
|
||||||
|
|
||||||
|
var sweepStarted by remember(state.completedTrick) { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(state.completedTrick) {
|
||||||
|
if (state.completedTrick.isNotEmpty()) {
|
||||||
|
delay(trickHoldMillis)
|
||||||
|
sweepStarted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val sweep by animateFloatAsState(
|
||||||
|
targetValue = if (sweeping && sweepStarted) 1f else 0f,
|
||||||
|
animationSpec = tween(durationMillis = SWEEP_MILLIS),
|
||||||
|
label = "wizardSweep",
|
||||||
|
)
|
||||||
|
|
||||||
|
val winnerRelative = state.lastTrickWinner
|
||||||
|
?.let { ((it - localSeat) % players + players) % players }
|
||||||
|
?: 0
|
||||||
|
val winnerAngle = 2.0 * PI * winnerRelative / players
|
||||||
|
|
||||||
|
BoxWithConstraints(modifier = Modifier.fillMaxSize().padding(4.dp)) {
|
||||||
|
// Cards sit on a circle that fits inside whatever space the table has.
|
||||||
|
val radius = (minOf(maxWidth, maxHeight) / 2 - cardWidth * 0.7f).coerceAtLeast(0.dp)
|
||||||
|
val radiusPx = with(LocalDensity.current) { radius.toPx() }
|
||||||
|
val travelPx = with(LocalDensity.current) { (cardWidth * 2f).toPx() }
|
||||||
|
|
||||||
|
if (cardsOnTable.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = when {
|
||||||
|
state.phase == WizardPhase.BIDDING -> "Bidding"
|
||||||
|
state.turn == localSeat -> "Lead a card"
|
||||||
|
else -> "Waiting…"
|
||||||
|
},
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
cardsOnTable.forEach { played ->
|
||||||
|
val relative = ((played.seat - localSeat) % players + players) % players
|
||||||
|
val angle = 2.0 * PI * relative / players
|
||||||
|
// Relative seat 0 is you, at the bottom; the rest run clockwise.
|
||||||
|
val x = -sin(angle) * radiusPx
|
||||||
|
val y = cos(angle) * radiusPx
|
||||||
|
// Turn each card to face whoever played it.
|
||||||
|
val rotation = (relative * 360f / players)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.offset {
|
||||||
|
IntOffset(
|
||||||
|
x = (x + -sin(winnerAngle) * travelPx * sweep).roundToInt(),
|
||||||
|
y = (y + cos(winnerAngle) * travelPx * sweep).roundToInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.alpha(1f - sweep),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
PlayingCardView(
|
||||||
|
card = played.card,
|
||||||
|
width = cardWidth,
|
||||||
|
caption = specialCaption(played.card),
|
||||||
|
modifier = Modifier.rotate(rotation),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = seatLabel(played.seat, localSeat),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sweeping && state.lastTrickWinner != null) {
|
||||||
|
Text(
|
||||||
|
text = "${seatLabel(state.lastTrickWinner!!, localSeat)} takes it",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
modifier = Modifier.align(Alignment.Center).alpha(1f - sweep),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wizards and jesters carry no rank or suit anyone can read off the face, so
|
||||||
|
* they say what they do instead.
|
||||||
|
*/
|
||||||
|
private fun specialCaption(card: Card): String? = when {
|
||||||
|
isWizardCard(card) -> "WIZARD"
|
||||||
|
isJester(card) -> "JESTER"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun BiddingControls(
|
||||||
|
state: WizardState,
|
||||||
|
localSeat: Int,
|
||||||
|
legal: List<WizardMove>,
|
||||||
|
onMove: (WizardMove) -> Unit,
|
||||||
|
) {
|
||||||
|
if (legal.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "Waiting for ${seatLabel(state.turn, localSeat).lowercase()} to bid…",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val trumpChoices = legal.filterIsInstance<ChooseTrump>()
|
||||||
|
if (trumpChoices.isNotEmpty()) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Text("A wizard was turned — you name trump", fontWeight = FontWeight.Bold)
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
trumpChoices.forEach { move ->
|
||||||
|
Button(onClick = { onMove(move) }) {
|
||||||
|
Text("${move.suit.symbol} ${move.suit.name.lowercase()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val bids = legal.filterIsInstance<MakeBid>()
|
||||||
|
val bidSoFar = state.bids.filterNotNull().sum()
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "How many of the ${state.cardsThisRound} tricks will you take?",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Bid so far: $bidSoFar. Hit your bid exactly for 20 plus 10 a trick; " +
|
||||||
|
"miss it and it costs 10 for every trick either way.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
if (state.options.screwTheDealer && state.turn == state.dealer) {
|
||||||
|
Text(
|
||||||
|
text = "Screw the dealer: you may not make the bids add up.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
bids.forEach { move ->
|
||||||
|
Button(onClick = { onMove(move) }) { Text("${move.tricks}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HandRow(
|
||||||
|
state: WizardState,
|
||||||
|
seat: Int,
|
||||||
|
legal: List<WizardMove>,
|
||||||
|
cardWidth: Dp,
|
||||||
|
onPlay: (WizardMove) -> Unit,
|
||||||
|
) {
|
||||||
|
val hand = state.hands.getOrNull(seat).orEmpty()
|
||||||
|
val playable = legal.filterIsInstance<PlayCard>().map { it.card }.toSet()
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = if (playable.isNotEmpty()) "Your turn" else "Your hand",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalScroll(rememberScrollState())
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
if (hand.isEmpty()) {
|
||||||
|
Box(modifier = Modifier.size(cardWidth)) {
|
||||||
|
Text("—", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hand.forEach { card ->
|
||||||
|
val canPlay = card in playable
|
||||||
|
PlayingCardView(
|
||||||
|
card = card,
|
||||||
|
width = cardWidth,
|
||||||
|
enabled = canPlay,
|
||||||
|
caption = specialCaption(card),
|
||||||
|
onClick = { if (canPlay) onPlay(PlayCard(card)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bids against tricks taken, and what that was worth, before the next deal. */
|
||||||
|
@Composable
|
||||||
|
private fun RoundScoreDialog(
|
||||||
|
state: WizardState,
|
||||||
|
localSeat: Int,
|
||||||
|
onContinue: () -> Unit,
|
||||||
|
onExit: () -> Unit,
|
||||||
|
) {
|
||||||
|
var dismissed by remember(state.round, state.phase) { mutableStateOf(false) }
|
||||||
|
if (dismissed) return
|
||||||
|
|
||||||
|
val finalRound = state.phase == WizardPhase.GAME_OVER
|
||||||
|
val standings = (0 until state.options.playerCount).sortedByDescending { state.scores[it] }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { },
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
if (finalRound) {
|
||||||
|
"Final score"
|
||||||
|
} else {
|
||||||
|
"Round ${state.round + 1} of ${state.options.totalRounds()}"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Player", modifier = Modifier.weight(1f), fontWeight = FontWeight.Bold)
|
||||||
|
Text("Bid", fontWeight = FontWeight.Bold)
|
||||||
|
Text(" Took", fontWeight = FontWeight.Bold)
|
||||||
|
Text(" Round", fontWeight = FontWeight.Bold)
|
||||||
|
Text(" Total", fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
standings.forEach { seat ->
|
||||||
|
val you = seat == localSeat
|
||||||
|
val weight = if (you) FontWeight.Bold else FontWeight.Normal
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = if (you) "You" else "Seat $seat",
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
fontWeight = weight,
|
||||||
|
)
|
||||||
|
Text("${state.bids[seat] ?: 0}")
|
||||||
|
Text(" ${state.tricksWon[seat]}")
|
||||||
|
Text(" ${state.roundScores[seat]}")
|
||||||
|
Text(" ${state.scores[seat]}", fontWeight = weight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = if (finalRound) {
|
||||||
|
"Highest score wins — seat ${standings.first()} takes it."
|
||||||
|
} else {
|
||||||
|
"Highest score wins."
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
dismissed = true
|
||||||
|
if (finalRound) onExit() else onContinue()
|
||||||
|
}) {
|
||||||
|
Text(if (finalRound) "Back to the menu" else "Deal the next round")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -55,6 +55,10 @@ class CatalogTest {
|
|||||||
GameCatalog.SEQUENCE,
|
GameCatalog.SEQUENCE,
|
||||||
GameCatalog.PRESIDENT,
|
GameCatalog.PRESIDENT,
|
||||||
GameCatalog.GOLF,
|
GameCatalog.GOLF,
|
||||||
|
GameCatalog.KAISER,
|
||||||
|
GameCatalog.CRAZY_EIGHTS,
|
||||||
|
GameCatalog.WIZARD,
|
||||||
|
GameCatalog.CHESS,
|
||||||
),
|
),
|
||||||
GameCatalog.playable.map { it.id }.toSet(),
|
GameCatalog.playable.map { it.id }.toSet(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package org.prolibertate.games.game.chess
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ChessAiTest {
|
||||||
|
|
||||||
|
private fun position(fen: String, level: ChessLevel = ChessLevel.CLUB) =
|
||||||
|
ChessRules.fromPosition(ChessOptions(level = level), fen)
|
||||||
|
|
||||||
|
private fun ChessAi.moveIn(state: ChessState): ChessMove =
|
||||||
|
chooseMove(state, state.turnSeat, ChessRules.legalMoves(state))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `it only ever returns a legal move`() {
|
||||||
|
val ai = ChessAi()
|
||||||
|
var state = ChessRules.fromPosition(ChessOptions(), START_FEN)
|
||||||
|
repeat(30) {
|
||||||
|
if (ChessRules.isFinished(state)) return
|
||||||
|
val legal = ChessRules.legalMoves(state)
|
||||||
|
val move = ai.moveIn(state)
|
||||||
|
assertTrue("$move is not legal in ${state.toFen()}", legal.contains(move))
|
||||||
|
state = ChessRules.applyMove(state, state.turnSeat, move)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `it takes a piece that is hanging`() {
|
||||||
|
// The black queen on d5 is undefended and the rook on d1 sees it.
|
||||||
|
val state = position("4k3/8/8/3q4/8/8/8/3RK3 w - - 0 1")
|
||||||
|
assertEquals("d1d5", ChessAi(ChessLevel.STRONG).moveIn(state).toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `it does not give a piece away for nothing`() {
|
||||||
|
// Rb1 and Rb7 are both available; Rb7 hangs the rook to the king.
|
||||||
|
val state = position("8/1k6/8/8/8/8/8/1R2K3 w - - 0 1")
|
||||||
|
val move = ChessAi(ChessLevel.STRONG).moveIn(state)
|
||||||
|
assertTrue("played $move into the king", move.toString() != "b1b7")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `it finds mate in one`() {
|
||||||
|
val state = position("6k1/5ppp/8/8/8/8/8/R5K1 w - - 0 1")
|
||||||
|
assertEquals("a1a8", ChessAi(ChessLevel.STRONG).moveIn(state).toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `it gets out of check rather than ignoring it`() {
|
||||||
|
val state = position("4k3/8/8/8/8/8/4r3/4K3 w - - 0 1")
|
||||||
|
val move = ChessAi().moveIn(state)
|
||||||
|
val after = ChessRules.applyMove(state, state.turnSeat, move)
|
||||||
|
assertTrue("still in check after $move", !ChessRules.inCheck(after.board, white = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `it promotes to a queen when there is nothing to fear`() {
|
||||||
|
val state = position("4k3/P7/8/8/8/8/8/4K3 w - - 0 1")
|
||||||
|
val move = ChessAi(ChessLevel.STRONG).moveIn(state)
|
||||||
|
assertEquals(PieceKind.QUEEN, move.promotion)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same position with the colours swapped and the board turned round. */
|
||||||
|
private fun mirrored(state: ChessState): ChessState {
|
||||||
|
val board = arrayOfNulls<Piece>(64)
|
||||||
|
for (square in 0 until 64) {
|
||||||
|
state.board[square]?.let { board[square xor 56] = it.copy(white = !it.white) }
|
||||||
|
}
|
||||||
|
return state.copy(
|
||||||
|
board = board.toList(),
|
||||||
|
whiteToMove = !state.whiteToMove,
|
||||||
|
castling = CastlingRights(
|
||||||
|
whiteKingSide = state.castling.blackKingSide,
|
||||||
|
whiteQueenSide = state.castling.blackQueenSide,
|
||||||
|
blackKingSide = state.castling.whiteKingSide,
|
||||||
|
blackQueenSide = state.castling.whiteQueenSide,
|
||||||
|
),
|
||||||
|
enPassant = state.enPassant?.let { it xor 56 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the evaluation is symmetric`() {
|
||||||
|
// A position and its mirror image must score identically from the side
|
||||||
|
// to move, or the search believes one colour is better simply for being
|
||||||
|
// that colour — and it will then play for it.
|
||||||
|
listOf(
|
||||||
|
"r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 4 4",
|
||||||
|
"r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1",
|
||||||
|
"8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1",
|
||||||
|
"4k3/8/8/8/8/8/8/3RK3 w - - 0 1",
|
||||||
|
).forEach { fen ->
|
||||||
|
val state = position(fen)
|
||||||
|
assertEquals(fen, ChessAi().evaluate(state), ChessAi().evaluate(mirrored(state)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `two engines play a whole game without an illegal move`() {
|
||||||
|
val ai = ChessAi(ChessLevel.CASUAL)
|
||||||
|
var state = ChessRules.fromPosition(ChessOptions(level = ChessLevel.CASUAL), START_FEN)
|
||||||
|
var plies = 0
|
||||||
|
while (!ChessRules.isFinished(state) && plies++ < 220) {
|
||||||
|
val legal = ChessRules.legalMoves(state)
|
||||||
|
val move = ai.moveIn(state)
|
||||||
|
assertTrue("illegal $move", legal.contains(move))
|
||||||
|
state = ChessRules.applyMove(state, state.turnSeat, move)
|
||||||
|
}
|
||||||
|
// Either somebody was mated or the position ran into one of the draw
|
||||||
|
// rules; both are results. What matters is that it never got stuck.
|
||||||
|
assertTrue(
|
||||||
|
"no result and no legal continuation",
|
||||||
|
ChessRules.isFinished(state) || ChessRules.legalMoves(state).isNotEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a move takes a reasonable amount of time at every level`() {
|
||||||
|
// Rough guard rather than a benchmark: a phone should not sit thinking
|
||||||
|
// for a quarter of a minute, and the node budget is what enforces it.
|
||||||
|
val opening = "r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 4 4"
|
||||||
|
ChessLevel.entries.forEach { level ->
|
||||||
|
val state = position(opening, level)
|
||||||
|
val started = System.nanoTime()
|
||||||
|
ChessAi(level).moveIn(state)
|
||||||
|
val millis = (System.nanoTime() - started) / 1_000_000
|
||||||
|
assertTrue("$level took ${millis}ms", millis < 15_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
package org.prolibertate.games.game.chess
|
||||||
|
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.prolibertate.games.game.engine.PlayerKind
|
||||||
|
import org.prolibertate.games.game.engine.PlayerSlot
|
||||||
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
class ChessRulesTest {
|
||||||
|
|
||||||
|
private val json = Json { encodeDefaults = true }
|
||||||
|
|
||||||
|
private fun config(options: ChessOptions = ChessOptions()) = TableConfig(
|
||||||
|
gameId = "chess",
|
||||||
|
seats = (0 until 2).map {
|
||||||
|
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
|
||||||
|
},
|
||||||
|
optionsJson = json.encodeToString(options),
|
||||||
|
seed = 1L,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun position(fen: String, options: ChessOptions = ChessOptions()) =
|
||||||
|
ChessRules.fromPosition(options, fen)
|
||||||
|
|
||||||
|
private fun ChessState.moveTo(notation: String): ChessState {
|
||||||
|
val move = ChessRules.legalMoves(this).firstOrNull { it.toString() == notation }
|
||||||
|
assertNotNull("$notation is not legal in ${toFen()}", move)
|
||||||
|
return ChessRules.applyMove(this, turnSeat, move!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Squares and FEN ----------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `squares are numbered the way a FEN reads`() {
|
||||||
|
assertEquals(0, squareFromName("a8"))
|
||||||
|
assertEquals(7, squareFromName("h8"))
|
||||||
|
assertEquals(56, squareFromName("a1"))
|
||||||
|
assertEquals(63, squareFromName("h1"))
|
||||||
|
(0 until 64).forEach { assertEquals(it, squareFromName(squareName(it))) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a1 is dark and h1 is light`() {
|
||||||
|
assertFalse(isLightSquare(squareFromName("a1")))
|
||||||
|
assertTrue(isLightSquare(squareFromName("h1")))
|
||||||
|
assertTrue(isLightSquare(squareFromName("e4")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the opening array round trips through FEN`() {
|
||||||
|
val state = ChessRules.initialState(config())
|
||||||
|
assertEquals(START_FEN, state.toFen())
|
||||||
|
assertEquals(32, state.board.count { it != null })
|
||||||
|
assertEquals("white starts", 0, state.turnSeat)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every published test position parses back to itself`() {
|
||||||
|
PERFT_POSITIONS.forEach { (fen, _) ->
|
||||||
|
assertEquals(fen, position(fen).toFen())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Perft --------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// These counts are published and exact. A generator that matches them has
|
||||||
|
// essentially nowhere left to hide a rule it gets wrong: the positions are
|
||||||
|
// chosen to exercise castling through check, en passant that would expose
|
||||||
|
// a king, promotion with capture, and pins.
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `perft matches the published counts from the opening array`() {
|
||||||
|
val state = ChessRules.initialState(config())
|
||||||
|
assertEquals(20L, ChessRules.perft(state, 1))
|
||||||
|
assertEquals(400L, ChessRules.perft(state, 2))
|
||||||
|
assertEquals(8_902L, ChessRules.perft(state, 3))
|
||||||
|
assertEquals(197_281L, ChessRules.perft(state, 4))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `perft matches the published counts for the standard test positions`() {
|
||||||
|
PERFT_POSITIONS.forEach { (fen, counts) ->
|
||||||
|
val state = position(fen)
|
||||||
|
counts.forEachIndexed { index, expected ->
|
||||||
|
assertEquals(
|
||||||
|
"perft(${index + 1}) for $fen",
|
||||||
|
expected,
|
||||||
|
ChessRules.perft(state, index + 1),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Individual rules ---------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a pawn on its home rank may step one or two`() {
|
||||||
|
val state = ChessRules.initialState(config())
|
||||||
|
val fromE2 = ChessRules.legalMoves(state).filter { squareName(it.from) == "e2" }
|
||||||
|
assertEquals(setOf("e2e3", "e2e4"), fromE2.map { it.toString() }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a double step can be answered en passant, and only immediately`() {
|
||||||
|
var state = position("4k3/8/8/8/4p3/8/3P4/4K3 w - - 0 1")
|
||||||
|
state = state.moveTo("d2d4")
|
||||||
|
assertEquals("the skipped square is the target", "d3", squareName(state.enPassant!!))
|
||||||
|
|
||||||
|
val enPassant = ChessRules.legalMoves(state).first { it.toString() == "e4d3" }
|
||||||
|
val after = ChessRules.applyMove(state, state.turnSeat, enPassant)
|
||||||
|
assertNull("the pawn that stepped two is gone", after.board[squareFromName("d4")])
|
||||||
|
assertNotNull("and the capturer stands behind it", after.board[squareFromName("d3")])
|
||||||
|
|
||||||
|
// Waiting a move loses the right.
|
||||||
|
var later = position("4k3/8/8/8/4p3/8/3P4/4K3 w - - 0 1").moveTo("d2d4")
|
||||||
|
later = later.moveTo("e8d8")
|
||||||
|
later = later.moveTo("e1d1")
|
||||||
|
assertTrue(ChessRules.legalMoves(later).none { it.toString() == "e4d3" })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `en passant is refused when it would expose the king`() {
|
||||||
|
// White king on e5, black rook on h5: taking en passant clears two
|
||||||
|
// pawns off the fifth rank at once and hangs the king.
|
||||||
|
val state = position("8/8/8/K1Pp3r/8/8/8/4k3 w - d6 0 1")
|
||||||
|
assertTrue(
|
||||||
|
"c5d6 must not be legal",
|
||||||
|
ChessRules.legalMoves(state).none { it.toString() == "c5d6" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a pawn reaching the last rank offers all four promotions`() {
|
||||||
|
val state = position("4k3/P7/8/8/8/8/8/4K3 w - - 0 1")
|
||||||
|
val promotions = ChessRules.legalMoves(state)
|
||||||
|
.filter { squareName(it.from) == "a7" }
|
||||||
|
.mapNotNull { it.promotion }
|
||||||
|
assertEquals(
|
||||||
|
setOf(PieceKind.QUEEN, PieceKind.ROOK, PieceKind.BISHOP, PieceKind.KNIGHT),
|
||||||
|
promotions.toSet(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val queened = state.moveTo("a7a8q")
|
||||||
|
assertEquals(Piece(PieceKind.QUEEN, white = true), queened.board[squareFromName("a8")])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `castling moves the rook and gives up both rights`() {
|
||||||
|
var state = position("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1")
|
||||||
|
state = state.moveTo("e1g1")
|
||||||
|
assertEquals(Piece(PieceKind.KING, true), state.board[squareFromName("g1")])
|
||||||
|
assertEquals("the rook jumps over", Piece(PieceKind.ROOK, true), state.board[squareFromName("f1")])
|
||||||
|
assertNull(state.board[squareFromName("h1")])
|
||||||
|
assertFalse(state.castling.whiteKingSide)
|
||||||
|
assertFalse(state.castling.whiteQueenSide)
|
||||||
|
assertTrue("black keeps its own", state.castling.blackKingSide)
|
||||||
|
|
||||||
|
val queenSide = position("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1").moveTo("e1c1")
|
||||||
|
assertEquals(Piece(PieceKind.ROOK, true), queenSide.board[squareFromName("d1")])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `you may not castle out of, through, or into check`() {
|
||||||
|
// Rook on e8 attacks the king's own square.
|
||||||
|
assertTrue(
|
||||||
|
"out of check",
|
||||||
|
ChessRules.legalMoves(position("4r3/8/8/8/8/8/8/R3K2R w KQ - 0 1"))
|
||||||
|
.none { it.toString() == "e1g1" || it.toString() == "e1c1" },
|
||||||
|
)
|
||||||
|
// Rook on f8 attacks the square the king crosses.
|
||||||
|
assertTrue(
|
||||||
|
"through check",
|
||||||
|
ChessRules.legalMoves(position("5r2/8/8/8/8/8/8/R3K2R w KQ - 0 1"))
|
||||||
|
.none { it.toString() == "e1g1" },
|
||||||
|
)
|
||||||
|
// Rook on g8 attacks the square the king lands on.
|
||||||
|
assertTrue(
|
||||||
|
"into check",
|
||||||
|
ChessRules.legalMoves(position("6r1/8/8/8/8/8/8/R3K2R w KQ - 0 1"))
|
||||||
|
.none { it.toString() == "e1g1" },
|
||||||
|
)
|
||||||
|
// The b-file square may be attacked; the king does not cross it.
|
||||||
|
assertTrue(
|
||||||
|
"b1 attacked is still fine",
|
||||||
|
ChessRules.legalMoves(position("1r6/8/8/8/8/8/8/R3K2R w KQ - 0 1"))
|
||||||
|
.any { it.toString() == "e1c1" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `castling rights go when a rook moves or is taken on its corner`() {
|
||||||
|
val moved = position("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1").moveTo("a1b1")
|
||||||
|
assertFalse(moved.castling.whiteQueenSide)
|
||||||
|
assertTrue(moved.castling.whiteKingSide)
|
||||||
|
|
||||||
|
// White's rook takes on a8, so Black loses the queen's side.
|
||||||
|
val taken = position("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1")
|
||||||
|
.moveTo("a1a8")
|
||||||
|
assertFalse("captured rook loses its right", taken.castling.blackQueenSide)
|
||||||
|
assertTrue(taken.castling.blackKingSide)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a pinned piece may not step off the line`() {
|
||||||
|
// The knight on e2 is pinned against the king by the rook on e8.
|
||||||
|
val state = position("4r3/8/8/8/8/8/4N3/4K3 w - - 0 1")
|
||||||
|
assertTrue(
|
||||||
|
ChessRules.legalMoves(state).none { squareName(it.from) == "e2" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `in check you must answer the check`() {
|
||||||
|
val state = position("4k3/8/8/8/8/8/4r3/4K3 w - - 0 1")
|
||||||
|
assertTrue(ChessRules.inCheck(state))
|
||||||
|
val moves = ChessRules.legalMoves(state).map { it.toString() }.toSet()
|
||||||
|
// Take the rook, or step off the file. Nothing else.
|
||||||
|
assertEquals(setOf("e1e2", "e1d1", "e1f1"), moves)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Endings ------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the back rank mate ends the game`() {
|
||||||
|
val state = position("6k1/5ppp/8/8/8/8/8/R5K1 w - - 0 1").moveTo("a1a8")
|
||||||
|
assertTrue(ChessRules.isFinished(state))
|
||||||
|
assertEquals(ChessOutcome.WHITE_WINS, state.outcome)
|
||||||
|
assertEquals("recorded as mate", "Ra8#", state.moveLog.last())
|
||||||
|
assertNull("nobody is on the clock", ChessRules.currentSeat(state))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fools mate is mate`() {
|
||||||
|
var state = ChessRules.initialState(config())
|
||||||
|
state = state.moveTo("f2f3")
|
||||||
|
state = state.moveTo("e7e5")
|
||||||
|
state = state.moveTo("g2g4")
|
||||||
|
state = state.moveTo("d8h4")
|
||||||
|
assertEquals(ChessOutcome.BLACK_WINS, state.outcome)
|
||||||
|
assertEquals("Qh4#", state.moveLog.last())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a king with no move and no check is stalemate`() {
|
||||||
|
// Qf7 takes g7, g8 and h7 away without touching h8.
|
||||||
|
val state = position("7k/8/6Q1/8/8/8/8/K7 w - - 0 1").moveTo("g6f7")
|
||||||
|
assertEquals(ChessOutcome.STALEMATE, state.outcome)
|
||||||
|
assertTrue(ChessRules.isFinished(state))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `bare kings cannot mate and the game is drawn`() {
|
||||||
|
val state = position("4k3/8/8/8/8/8/4r3/4K3 w - - 0 1").moveTo("e1e2")
|
||||||
|
assertEquals(ChessOutcome.INSUFFICIENT_MATERIAL, state.outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `what counts as too little material`() {
|
||||||
|
fun material(fen: String) = ChessRules.insufficientMaterial(parseFen(fen).board)
|
||||||
|
assertTrue("bare kings", material("4k3/8/8/8/8/8/8/4K3 w - - 0 1"))
|
||||||
|
assertTrue("one knight", material("4k3/8/8/8/8/8/8/3NK3 w - - 0 1"))
|
||||||
|
assertTrue("one bishop", material("4k3/8/8/8/8/8/8/3BK3 w - - 0 1"))
|
||||||
|
// Both bishops on dark squares: nothing can ever be forced.
|
||||||
|
assertTrue("same-colour bishops", material("2b1k3/8/8/8/8/8/8/3BK3 w - - 0 1"))
|
||||||
|
assertFalse("opposite-colour bishops", material("3bk3/8/8/8/8/8/8/3BK3 w - - 0 1"))
|
||||||
|
assertFalse("a rook is enough", material("4k3/8/8/8/8/8/8/3RK3 w - - 0 1"))
|
||||||
|
assertFalse("a pawn can promote", material("4k3/8/8/8/8/8/3P4/4K3 w - - 0 1"))
|
||||||
|
assertFalse("two knights are not automatic", material("4k3/8/8/8/8/8/8/2NNK3 w - - 0 1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the fifty-move rule ends it, and can be switched off`() {
|
||||||
|
val fen = "4k3/8/8/8/8/8/8/R3K3 w - - 99 60"
|
||||||
|
val drawn = position(fen).moveTo("a1a2")
|
||||||
|
assertEquals(ChessOutcome.FIFTY_MOVE, drawn.outcome)
|
||||||
|
|
||||||
|
val played = position(fen, ChessOptions(fiftyMoveRule = false)).moveTo("a1a2")
|
||||||
|
assertNull("the toggle keeps the game alive", played.outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a capture or a pawn move resets the clock`() {
|
||||||
|
val state = position("4k3/8/8/8/8/8/3P4/4K3 w - - 40 60").moveTo("d2d4")
|
||||||
|
assertEquals(0, state.halfmoveClock)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the same position three times is a draw`() {
|
||||||
|
// Both kings walk back and forth; the third repetition ends it.
|
||||||
|
var state = position("4k3/8/8/8/8/8/8/R3K3 w - - 0 1")
|
||||||
|
var guard = 0
|
||||||
|
while (!ChessRules.isFinished(state) && guard++ < 40) {
|
||||||
|
val square = if (guard % 4 == 1 || guard % 4 == 2) {
|
||||||
|
if (state.whiteToMove) "a1b1" else "e8d8"
|
||||||
|
} else {
|
||||||
|
if (state.whiteToMove) "b1a1" else "d8e8"
|
||||||
|
}
|
||||||
|
state = state.moveTo(square)
|
||||||
|
}
|
||||||
|
assertEquals(ChessOutcome.REPETITION, state.outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Notation -----------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the move log reads as algebraic notation`() {
|
||||||
|
var state = ChessRules.initialState(config())
|
||||||
|
state = state.moveTo("e2e4")
|
||||||
|
state = state.moveTo("e7e5")
|
||||||
|
state = state.moveTo("g1f3")
|
||||||
|
state = state.moveTo("b8c6")
|
||||||
|
state = state.moveTo("f1b5")
|
||||||
|
assertEquals(listOf("e4", "e5", "Nf3", "Nc6", "Bb5"), state.moveLog)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `captures, castling and promotion are all written out`() {
|
||||||
|
val taken = position("4k3/8/8/3p4/4P3/8/8/4K3 w - - 0 1").moveTo("e4d5")
|
||||||
|
assertEquals("exd5", taken.moveLog.last())
|
||||||
|
|
||||||
|
val castled = position("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1").moveTo("e1g1")
|
||||||
|
assertEquals("O-O", castled.moveLog.last())
|
||||||
|
|
||||||
|
val long = position("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1").moveTo("e1c1")
|
||||||
|
assertEquals("O-O-O", long.moveLog.last())
|
||||||
|
|
||||||
|
val promoted = position("4k3/P7/8/8/8/8/8/4K3 w - - 0 1").moveTo("a7a8r")
|
||||||
|
assertEquals("a8=R+", promoted.moveLog.last())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `two pieces reaching the same square are told apart`() {
|
||||||
|
// Knights on c3 and g1 both reach e2: the file separates them.
|
||||||
|
val byFile = position("4k3/8/8/8/8/2N5/8/4K1N1 w - - 0 1").moveTo("c3e2")
|
||||||
|
assertEquals("Nce2", byFile.moveLog.last())
|
||||||
|
|
||||||
|
// Rooks on a1 and a5 both reach a3, sharing a file: the rank separates them.
|
||||||
|
val byRank = position("4k3/8/8/R7/8/8/8/R3K3 w - - 0 1").moveTo("a1a3")
|
||||||
|
assertEquals("R1a3", byRank.moveLog.last())
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Playing it out -----------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `random legal play always reaches a result`() {
|
||||||
|
repeat(12) { iteration ->
|
||||||
|
val random = Random(iteration.toLong())
|
||||||
|
var state = ChessRules.initialState(config())
|
||||||
|
var guard = 0
|
||||||
|
while (!ChessRules.isFinished(state) && guard++ < 1_000) {
|
||||||
|
val seat = ChessRules.currentSeat(state)!!
|
||||||
|
val legal = ChessRules.legalMoves(state, seat)
|
||||||
|
assertTrue("no legal move but the game is not over", legal.isNotEmpty())
|
||||||
|
state = ChessRules.applyMove(state, seat, legal[random.nextInt(legal.size)])
|
||||||
|
}
|
||||||
|
assertTrue("game $iteration never ended", ChessRules.isFinished(state))
|
||||||
|
assertNotNull(state.outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the wrong seat cannot move`() {
|
||||||
|
val state = ChessRules.initialState(config())
|
||||||
|
assertTrue(ChessRules.legalMoves(state, BLACK_SEAT).isEmpty())
|
||||||
|
val move = ChessRules.legalMoves(state, WHITE_SEAT).first()
|
||||||
|
runCatching { ChessRules.applyMove(state, BLACK_SEAT, move) }
|
||||||
|
.onSuccess { throw AssertionError("Black was allowed to play White's move") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Wire format --------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `both sides see the same board`() {
|
||||||
|
val state = ChessRules.initialState(config())
|
||||||
|
assertEquals(state, ChessRules.viewFor(state, WHITE_SEAT))
|
||||||
|
assertEquals(state, ChessRules.viewFor(state, BLACK_SEAT))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `state and moves survive a round trip`() {
|
||||||
|
val state = ChessRules.initialState(config()).moveTo("e2e4")
|
||||||
|
assertEquals(state, ChessRules.decodeState(ChessRules.encodeState(state)))
|
||||||
|
listOf(
|
||||||
|
ChessMove(squareFromName("e2"), squareFromName("e4")),
|
||||||
|
ChessMove(squareFromName("a7"), squareFromName("a8"), PieceKind.QUEEN),
|
||||||
|
).forEach { assertEquals(it, ChessRules.decodeMove(ChessRules.encodeMove(it))) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/**
|
||||||
|
* The standard perft suite. Depths are kept where they run in a second
|
||||||
|
* or two apiece — deep enough that castling, en passant, promotion and
|
||||||
|
* pin handling are all exercised many thousands of times over.
|
||||||
|
*/
|
||||||
|
val PERFT_POSITIONS: List<Pair<String, List<Long>>> = listOf(
|
||||||
|
// Kiwipete: castling both ways, pins, and a board full of tactics.
|
||||||
|
"r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1" to
|
||||||
|
listOf(48L, 2_039L, 97_862L),
|
||||||
|
// A rook-and-pawn endgame that catches en passant errors.
|
||||||
|
"8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1" to
|
||||||
|
listOf(14L, 191L, 2_812L, 43_238L),
|
||||||
|
// Promotion with capture, from both sides.
|
||||||
|
"r3k2r/Pppp1ppp/1b3nbN/nP6/BBP1P3/q4N2/Pp1P2PP/R2Q1RK1 w kq - 0 1" to
|
||||||
|
listOf(6L, 264L, 9_467L),
|
||||||
|
// A cramped position where most moves are illegal.
|
||||||
|
"rnbq1k1r/pp1Pbppp/2p5/8/2B5/8/PPP1NnPP/RNBQK2R w KQ - 1 8" to
|
||||||
|
listOf(44L, 1_486L, 62_379L),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
package org.prolibertate.games.game.crazyeights
|
||||||
|
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.prolibertate.games.game.cards.Card
|
||||||
|
import org.prolibertate.games.game.cards.Rank
|
||||||
|
import org.prolibertate.games.game.cards.Suit
|
||||||
|
import org.prolibertate.games.game.engine.PlayerKind
|
||||||
|
import org.prolibertate.games.game.engine.PlayerSlot
|
||||||
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
class CrazyEightsRulesTest {
|
||||||
|
|
||||||
|
private val json = Json { encodeDefaults = true }
|
||||||
|
|
||||||
|
private fun config(
|
||||||
|
options: CrazyEightsOptions = CrazyEightsOptions(),
|
||||||
|
seed: Long = 21L,
|
||||||
|
) = TableConfig(
|
||||||
|
gameId = "crazy8s",
|
||||||
|
seats = (0 until options.playerCount).map {
|
||||||
|
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
|
||||||
|
},
|
||||||
|
optionsJson = json.encodeToString(options),
|
||||||
|
seed = seed,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun c(rank: Rank, suit: Suit) = Card(rank, suit)
|
||||||
|
|
||||||
|
private fun playingState(
|
||||||
|
hands: List<List<Card>>,
|
||||||
|
top: Card,
|
||||||
|
suitInForce: Suit = top.suit,
|
||||||
|
stock: List<Card> = emptyList(),
|
||||||
|
turn: Int = 0,
|
||||||
|
options: CrazyEightsOptions = CrazyEightsOptions(playerCount = hands.size),
|
||||||
|
drawnThisTurn: Int = 0,
|
||||||
|
) = CrazyEightsState(
|
||||||
|
options = options,
|
||||||
|
seed = 1L,
|
||||||
|
roundNumber = 0,
|
||||||
|
hands = hands,
|
||||||
|
handCounts = hands.map { it.size },
|
||||||
|
stock = stock,
|
||||||
|
discard = listOf(top),
|
||||||
|
suitInForce = suitInForce,
|
||||||
|
turn = turn,
|
||||||
|
drawnThisTurn = drawnThisTurn,
|
||||||
|
scores = List(hands.size) { 0 },
|
||||||
|
roundWinner = null,
|
||||||
|
phase = CrazyEightsPhase.PLAYING,
|
||||||
|
log = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// -- Matching -----------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a card matches on suit or on rank`() {
|
||||||
|
val top = c(Rank.SEVEN, Suit.HEARTS)
|
||||||
|
assertTrue("same suit", canPlay(c(Rank.KING, Suit.HEARTS), top, Suit.HEARTS))
|
||||||
|
assertTrue("same rank", canPlay(c(Rank.SEVEN, Suit.CLUBS), top, Suit.HEARTS))
|
||||||
|
assertFalse("neither", canPlay(c(Rank.KING, Suit.CLUBS), top, Suit.HEARTS))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an eight always plays`() {
|
||||||
|
val top = c(Rank.SEVEN, Suit.HEARTS)
|
||||||
|
Suit.entries.forEach { suit ->
|
||||||
|
assertTrue(canPlay(c(Rank.EIGHT, suit), top, Suit.HEARTS))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the suit in force overrides the top card`() {
|
||||||
|
// An eight was played on a heart and clubs were called.
|
||||||
|
val top = c(Rank.EIGHT, Suit.HEARTS)
|
||||||
|
assertTrue(canPlay(c(Rank.TWO, Suit.CLUBS), top, Suit.CLUBS))
|
||||||
|
assertFalse("hearts no longer run", canPlay(c(Rank.TWO, Suit.HEARTS), top, Suit.CLUBS))
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Legal moves --------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `playing an eight offers a choice of suit`() {
|
||||||
|
val hand = listOf(c(Rank.EIGHT, Suit.SPADES))
|
||||||
|
val state = playingState(listOf(hand, emptyList()), top = c(Rank.THREE, Suit.HEARTS))
|
||||||
|
val moves = CrazyEightsRules.legalMoves(state, 0).filterIsInstance<PlayCard>()
|
||||||
|
assertEquals("one move per suit", 4, moves.size)
|
||||||
|
assertEquals(Suit.entries.toSet(), moves.mapNotNull { it.nominatedSuit }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `you must play when you can`() {
|
||||||
|
val hand = listOf(c(Rank.KING, Suit.HEARTS), c(Rank.TWO, Suit.CLUBS))
|
||||||
|
val state = playingState(
|
||||||
|
hands = listOf(hand, emptyList()),
|
||||||
|
top = c(Rank.THREE, Suit.HEARTS),
|
||||||
|
stock = listOf(c(Rank.NINE, Suit.SPADES)),
|
||||||
|
)
|
||||||
|
val moves = CrazyEightsRules.legalMoves(state, 0)
|
||||||
|
assertTrue("no drawing your way out of a playable hand", moves.none { it is DrawCard })
|
||||||
|
assertEquals(1, moves.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `with nothing playable you draw`() {
|
||||||
|
val hand = listOf(c(Rank.KING, Suit.CLUBS))
|
||||||
|
val state = playingState(
|
||||||
|
hands = listOf(hand, emptyList()),
|
||||||
|
top = c(Rank.THREE, Suit.HEARTS),
|
||||||
|
stock = listOf(c(Rank.NINE, Suit.SPADES)),
|
||||||
|
)
|
||||||
|
assertEquals(listOf(DrawCard), CrazyEightsRules.legalMoves(state, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `with an empty stock and nothing playable you pass`() {
|
||||||
|
val hand = listOf(c(Rank.KING, Suit.CLUBS))
|
||||||
|
val state = playingState(
|
||||||
|
hands = listOf(hand, emptyList()),
|
||||||
|
top = c(Rank.THREE, Suit.HEARTS),
|
||||||
|
stock = emptyList(),
|
||||||
|
)
|
||||||
|
assertEquals(listOf(PassTurn), CrazyEightsRules.legalMoves(state, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `draw one and pass stops after a single card`() {
|
||||||
|
val options = CrazyEightsOptions(playerCount = 2, drawUntilPlayable = false)
|
||||||
|
val hand = listOf(c(Rank.KING, Suit.CLUBS))
|
||||||
|
val state = playingState(
|
||||||
|
hands = listOf(hand, emptyList()),
|
||||||
|
top = c(Rank.THREE, Suit.HEARTS),
|
||||||
|
stock = listOf(c(Rank.NINE, Suit.SPADES), c(Rank.TEN, Suit.SPADES)),
|
||||||
|
options = options,
|
||||||
|
drawnThisTurn = 1,
|
||||||
|
)
|
||||||
|
assertEquals("already drawn, so pass", listOf(PassTurn), CrazyEightsRules.legalMoves(state, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Play ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an eight sets the suit in force`() {
|
||||||
|
val hand = listOf(c(Rank.EIGHT, Suit.SPADES), c(Rank.TWO, Suit.CLUBS))
|
||||||
|
var state = playingState(listOf(hand, listOf(c(Rank.FIVE, Suit.CLUBS))),
|
||||||
|
top = c(Rank.THREE, Suit.HEARTS))
|
||||||
|
state = CrazyEightsRules.applyMove(
|
||||||
|
state, 0, PlayCard(c(Rank.EIGHT, Suit.SPADES), Suit.DIAMONDS)
|
||||||
|
)
|
||||||
|
assertEquals(Suit.DIAMONDS, state.suitInForce)
|
||||||
|
assertEquals(c(Rank.EIGHT, Suit.SPADES), state.topCard)
|
||||||
|
assertEquals(1, state.turn)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `drawing keeps the turn`() {
|
||||||
|
val state = playingState(
|
||||||
|
hands = listOf(listOf(c(Rank.KING, Suit.CLUBS)), emptyList()),
|
||||||
|
top = c(Rank.THREE, Suit.HEARTS),
|
||||||
|
stock = listOf(c(Rank.NINE, Suit.SPADES)),
|
||||||
|
)
|
||||||
|
val after = CrazyEightsRules.applyMove(state, 0, DrawCard)
|
||||||
|
assertEquals("still your turn", 0, after.turn)
|
||||||
|
assertEquals(2, after.hands[0].size)
|
||||||
|
assertEquals(1, after.drawnThisTurn)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an exhausted stock is refilled from the discards`() {
|
||||||
|
val state = playingState(
|
||||||
|
hands = listOf(listOf(c(Rank.KING, Suit.CLUBS)), emptyList()),
|
||||||
|
top = c(Rank.THREE, Suit.HEARTS),
|
||||||
|
stock = emptyList(),
|
||||||
|
).copy(discard = listOf(c(Rank.FOUR, Suit.SPADES), c(Rank.THREE, Suit.HEARTS)))
|
||||||
|
|
||||||
|
val after = CrazyEightsRules.applyMove(state, 0, DrawCard)
|
||||||
|
assertEquals("the card in play stays put", c(Rank.THREE, Suit.HEARTS), after.topCard)
|
||||||
|
assertEquals("and the rest became stock", 2, after.hands[0].size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Scoring ------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `penalties charge eights the most`() {
|
||||||
|
assertEquals(50, crazyEightsPenalty(c(Rank.EIGHT, Suit.CLUBS)))
|
||||||
|
assertEquals(10, crazyEightsPenalty(c(Rank.KING, Suit.CLUBS)))
|
||||||
|
assertEquals(10, crazyEightsPenalty(c(Rank.TEN, Suit.CLUBS)))
|
||||||
|
assertEquals(1, crazyEightsPenalty(c(Rank.ACE, Suit.CLUBS)))
|
||||||
|
assertEquals(5, crazyEightsPenalty(c(Rank.FIVE, Suit.CLUBS)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `going out charges everyone else for what they hold`() {
|
||||||
|
val options = CrazyEightsOptions(playerCount = 3, roundsToPlay = 2)
|
||||||
|
var state = playingState(
|
||||||
|
hands = listOf(
|
||||||
|
listOf(c(Rank.THREE, Suit.HEARTS)),
|
||||||
|
listOf(c(Rank.EIGHT, Suit.CLUBS), c(Rank.ACE, Suit.SPADES)),
|
||||||
|
listOf(c(Rank.KING, Suit.SPADES)),
|
||||||
|
),
|
||||||
|
top = c(Rank.FOUR, Suit.HEARTS),
|
||||||
|
options = options,
|
||||||
|
)
|
||||||
|
state = CrazyEightsRules.applyMove(state, 0, PlayCard(c(Rank.THREE, Suit.HEARTS)))
|
||||||
|
|
||||||
|
assertEquals(CrazyEightsPhase.ROUND_OVER, state.phase)
|
||||||
|
assertEquals(0, state.roundWinner)
|
||||||
|
assertEquals("the winner is charged nothing", 0, state.scores[0])
|
||||||
|
assertEquals("an eight and an ace", 51, state.scores[1])
|
||||||
|
assertEquals(10, state.scores[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the last round ends the game`() {
|
||||||
|
val options = CrazyEightsOptions(playerCount = 2, roundsToPlay = 1)
|
||||||
|
var state = playingState(
|
||||||
|
hands = listOf(listOf(c(Rank.THREE, Suit.HEARTS)), listOf(c(Rank.KING, Suit.SPADES))),
|
||||||
|
top = c(Rank.FOUR, Suit.HEARTS),
|
||||||
|
options = options,
|
||||||
|
)
|
||||||
|
state = CrazyEightsRules.applyMove(state, 0, PlayCard(c(Rank.THREE, Suit.HEARTS)))
|
||||||
|
assertTrue(CrazyEightsRules.isFinished(state))
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Dealing ------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `two players get seven cards and everyone else five`() {
|
||||||
|
assertEquals(7, CrazyEightsOptions(playerCount = 2).handSize())
|
||||||
|
assertEquals(5, CrazyEightsOptions(playerCount = 4).handSize())
|
||||||
|
assertEquals(9, CrazyEightsOptions(playerCount = 4, startingHand = 9).handSize())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the opening card is never an eight`() {
|
||||||
|
// An eight on top would leave the suit in force unnamed.
|
||||||
|
repeat(30) { seed ->
|
||||||
|
val state = CrazyEightsRules.initialState(config(seed = seed.toLong()))
|
||||||
|
assertFalse("seed $seed dealt a wild upcard", isWild(state.topCard!!))
|
||||||
|
assertEquals(state.topCard!!.suit, state.suitInForce)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Full games ---------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `random legal play always finishes`() {
|
||||||
|
repeat(20) { iteration ->
|
||||||
|
val random = Random(iteration.toLong())
|
||||||
|
var state = CrazyEightsRules.initialState(
|
||||||
|
config(CrazyEightsOptions(playerCount = 4, roundsToPlay = 2), iteration.toLong())
|
||||||
|
)
|
||||||
|
var guard = 0
|
||||||
|
while (!CrazyEightsRules.isFinished(state) && guard++ < 8000) {
|
||||||
|
val seat = CrazyEightsRules.currentSeat(state)
|
||||||
|
if (seat == null) {
|
||||||
|
state = CrazyEightsRules.nextRound(state)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val legal = CrazyEightsRules.legalMoves(state, seat)
|
||||||
|
assertTrue("seat $seat had no legal move", legal.isNotEmpty())
|
||||||
|
state = CrazyEightsRules.applyMove(state, seat, legal[random.nextInt(legal.size)])
|
||||||
|
}
|
||||||
|
assertTrue("game $iteration did not finish", CrazyEightsRules.isFinished(state))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ai play always finishes and beats random play`() {
|
||||||
|
val ai = CrazyEightsAi()
|
||||||
|
var aiTotal = 0
|
||||||
|
var randomTotal = 0
|
||||||
|
|
||||||
|
repeat(12) { iteration ->
|
||||||
|
val random = Random(3000L + iteration)
|
||||||
|
var state = CrazyEightsRules.initialState(
|
||||||
|
config(CrazyEightsOptions(playerCount = 2, roundsToPlay = 3), 800L + iteration)
|
||||||
|
)
|
||||||
|
var guard = 0
|
||||||
|
while (!CrazyEightsRules.isFinished(state) && guard++ < 8000) {
|
||||||
|
val seat = CrazyEightsRules.currentSeat(state)
|
||||||
|
if (seat == null) {
|
||||||
|
state = CrazyEightsRules.nextRound(state)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val legal = CrazyEightsRules.legalMoves(state, seat)
|
||||||
|
val move = if (seat == 0) {
|
||||||
|
ai.chooseMove(state, seat, legal)
|
||||||
|
} else {
|
||||||
|
legal[random.nextInt(legal.size)]
|
||||||
|
}
|
||||||
|
assertTrue("ai returned an illegal move $move", legal.contains(move))
|
||||||
|
state = CrazyEightsRules.applyMove(state, seat, move)
|
||||||
|
}
|
||||||
|
assertTrue(CrazyEightsRules.isFinished(state))
|
||||||
|
aiTotal += state.scores[0]
|
||||||
|
randomTotal += state.scores[1]
|
||||||
|
}
|
||||||
|
// Penalty points, so lower is better.
|
||||||
|
assertTrue(
|
||||||
|
"ai took $aiTotal against random's $randomTotal — no better than flailing",
|
||||||
|
aiTotal < randomTotal,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Redaction and wire format -----------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a seat view hides other hands and the stock`() {
|
||||||
|
val state = CrazyEightsRules.initialState(config())
|
||||||
|
val view = CrazyEightsRules.viewFor(state, seat = 2)
|
||||||
|
assertEquals(state.hands[2], view.hands[2])
|
||||||
|
assertTrue(view.hands.filterIndexed { i, _ -> i != 2 }.all { it.isEmpty() })
|
||||||
|
assertTrue(view.stock.isEmpty())
|
||||||
|
assertEquals(state.handCounts, view.handCounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `state and moves survive a round trip`() {
|
||||||
|
val state = CrazyEightsRules.initialState(config())
|
||||||
|
assertEquals(state, CrazyEightsRules.decodeState(CrazyEightsRules.encodeState(state)))
|
||||||
|
listOf(
|
||||||
|
DrawCard,
|
||||||
|
PassTurn,
|
||||||
|
PlayCard(c(Rank.KING, Suit.HEARTS)),
|
||||||
|
PlayCard(c(Rank.EIGHT, Suit.CLUBS), Suit.DIAMONDS),
|
||||||
|
).forEach {
|
||||||
|
assertEquals(it, CrazyEightsRules.decodeMove(CrazyEightsRules.encodeMove(it)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
package org.prolibertate.games.game.kaiser
|
||||||
|
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.prolibertate.games.game.cards.Card
|
||||||
|
import org.prolibertate.games.game.cards.Decks
|
||||||
|
import org.prolibertate.games.game.cards.Rank
|
||||||
|
import org.prolibertate.games.game.cards.Suit
|
||||||
|
import org.prolibertate.games.game.engine.PlayerKind
|
||||||
|
import org.prolibertate.games.game.engine.PlayerSlot
|
||||||
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
class KaiserRulesTest {
|
||||||
|
|
||||||
|
private val json = Json { encodeDefaults = true }
|
||||||
|
|
||||||
|
private fun config(options: KaiserOptions = KaiserOptions(), seed: Long = 5L) = TableConfig(
|
||||||
|
gameId = "kaiser",
|
||||||
|
seats = (0 until 4).map {
|
||||||
|
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it % 2)
|
||||||
|
},
|
||||||
|
optionsJson = json.encodeToString(options),
|
||||||
|
seed = seed,
|
||||||
|
)
|
||||||
|
|
||||||
|
// -- The deck -----------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the deck is thirty-two cards with the two counters in it`() {
|
||||||
|
val deck = Decks.kaiser()
|
||||||
|
assertEquals(32, deck.size)
|
||||||
|
assertEquals("no duplicates", 32, deck.distinct().size)
|
||||||
|
assertTrue("five of hearts", deck.contains(FIVE_OF_HEARTS))
|
||||||
|
assertTrue("three of spades", deck.contains(THREE_OF_SPADES))
|
||||||
|
assertTrue("seven of clubs stays", deck.contains(Card(Rank.SEVEN, Suit.CLUBS)))
|
||||||
|
assertTrue("seven of diamonds stays", deck.contains(Card(Rank.SEVEN, Suit.DIAMONDS)))
|
||||||
|
assertFalse("but not the seven of hearts", deck.contains(Card(Rank.SEVEN, Suit.HEARTS)))
|
||||||
|
assertFalse("nor the seven of spades", deck.contains(Card(Rank.SEVEN, Suit.SPADES)))
|
||||||
|
assertTrue("nothing below the counters", deck.none {
|
||||||
|
it.rank.order < Rank.SEVEN.order && it != FIVE_OF_HEARTS && it != THREE_OF_SPADES
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a hand is worth ten points in total`() {
|
||||||
|
// Eight tricks at one each, plus five, less three.
|
||||||
|
val deck = Decks.kaiser()
|
||||||
|
val allTricks = deck.chunked(4)
|
||||||
|
assertEquals(8, allTricks.size)
|
||||||
|
assertEquals(10, allTricks.sumOf { trickValue(it) })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the counters move a tricks value`() {
|
||||||
|
val plain = listOf(Card(Rank.ACE, Suit.CLUBS), Card(Rank.KING, Suit.CLUBS))
|
||||||
|
assertEquals(1, trickValue(plain))
|
||||||
|
assertEquals(6, trickValue(plain + FIVE_OF_HEARTS))
|
||||||
|
assertEquals(-2, trickValue(plain + THREE_OF_SPADES))
|
||||||
|
assertEquals(3, trickValue(plain + FIVE_OF_HEARTS + THREE_OF_SPADES))
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Bidding ------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `everyone gets eight cards and bidding opens left of the dealer`() {
|
||||||
|
val state = KaiserRules.initialState(config())
|
||||||
|
assertEquals(KaiserPhase.BIDDING, state.phase)
|
||||||
|
state.hands.forEach { assertEquals(8, it.size) }
|
||||||
|
assertEquals((state.dealer + 1) % 4, state.turn)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bid must beat the one before it`() {
|
||||||
|
var state = KaiserRules.initialState(config(KaiserOptions(minimumBid = 6)))
|
||||||
|
val opener = state.turn
|
||||||
|
state = KaiserRules.applyMove(state, opener, MakeBid(Bid(7, noTrump = false)))
|
||||||
|
|
||||||
|
val moves = KaiserRules.legalMoves(state, state.turn).filterIsInstance<MakeBid>()
|
||||||
|
assertTrue("nothing at or below seven in suit",
|
||||||
|
moves.none { !it.bid.noTrump && it.bid.points <= 7 })
|
||||||
|
assertTrue("but seven no trump beats seven",
|
||||||
|
moves.any { it.bid.noTrump && it.bid.points == 7 })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no trump outranks the same number`() {
|
||||||
|
assertTrue(Bid(7, noTrump = true).beats(Bid(7, noTrump = false)))
|
||||||
|
assertFalse(Bid(7, noTrump = false).beats(Bid(7, noTrump = true)))
|
||||||
|
assertTrue(Bid(8, noTrump = false).beats(Bid(7, noTrump = true)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the bidding floor is respected`() {
|
||||||
|
val state = KaiserRules.initialState(config(KaiserOptions(minimumBid = 8)))
|
||||||
|
val moves = KaiserRules.legalMoves(state, state.turn).filterIsInstance<MakeBid>()
|
||||||
|
assertTrue(moves.all { it.bid.points >= 8 })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the dealer is stuck when everyone passes`() {
|
||||||
|
var state = KaiserRules.initialState(config())
|
||||||
|
repeat(3) { state = KaiserRules.applyMove(state, state.turn, PassBid) }
|
||||||
|
assertEquals(state.dealer, state.turn)
|
||||||
|
val moves = KaiserRules.legalMoves(state, state.dealer)
|
||||||
|
assertFalse("the dealer has to bid", moves.contains(PassBid))
|
||||||
|
assertTrue(moves.all { it is MakeBid })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the winning bidder names trump and leads`() {
|
||||||
|
var state = KaiserRules.initialState(config())
|
||||||
|
val bidder = state.turn
|
||||||
|
state = KaiserRules.applyMove(state, bidder, MakeBid(Bid(6, noTrump = false)))
|
||||||
|
repeat(3) { state = KaiserRules.applyMove(state, state.turn, PassBid) }
|
||||||
|
|
||||||
|
assertEquals("back to the bidder", bidder, state.turn)
|
||||||
|
val naming = KaiserRules.legalMoves(state, bidder).filterIsInstance<NameTrump>()
|
||||||
|
assertEquals("a choice of four suits", 4, naming.size)
|
||||||
|
|
||||||
|
state = KaiserRules.applyMove(state, bidder, NameTrump(Suit.SPADES))
|
||||||
|
assertEquals(KaiserPhase.PLAYING, state.phase)
|
||||||
|
assertEquals(Suit.SPADES, state.trump)
|
||||||
|
assertEquals("and leads", bidder, state.turn)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a no trump bid commits to no trump`() {
|
||||||
|
var state = KaiserRules.initialState(config())
|
||||||
|
val bidder = state.turn
|
||||||
|
state = KaiserRules.applyMove(state, bidder, MakeBid(Bid(6, noTrump = true)))
|
||||||
|
repeat(3) { state = KaiserRules.applyMove(state, state.turn, PassBid) }
|
||||||
|
|
||||||
|
val naming = KaiserRules.legalMoves(state, bidder).filterIsInstance<NameTrump>()
|
||||||
|
assertEquals(listOf(NameTrump(null)), naming)
|
||||||
|
state = KaiserRules.applyMove(state, bidder, NameTrump(null))
|
||||||
|
assertEquals(null, state.trump)
|
||||||
|
assertEquals(KaiserPhase.PLAYING, state.phase)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Play ---------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun playingState(
|
||||||
|
hands: List<List<Card>>,
|
||||||
|
trump: Suit?,
|
||||||
|
bid: Bid = Bid(6, noTrump = false),
|
||||||
|
bidder: Int = 0,
|
||||||
|
turn: Int = 0,
|
||||||
|
) = KaiserState(
|
||||||
|
options = KaiserOptions(),
|
||||||
|
seed = 1L,
|
||||||
|
handNumber = 0,
|
||||||
|
dealer = 3,
|
||||||
|
phase = KaiserPhase.PLAYING,
|
||||||
|
hands = hands,
|
||||||
|
handCounts = hands.map { it.size },
|
||||||
|
turn = turn,
|
||||||
|
passes = 3,
|
||||||
|
highBid = bid,
|
||||||
|
highBidder = bidder,
|
||||||
|
trump = trump,
|
||||||
|
trick = emptyList(),
|
||||||
|
completedTrick = emptyList(),
|
||||||
|
leader = turn,
|
||||||
|
lastTrickWinner = null,
|
||||||
|
handPoints = listOf(0, 0),
|
||||||
|
scores = listOf(0, 0),
|
||||||
|
log = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `you must follow suit`() {
|
||||||
|
val hand = listOf(Card(Rank.NINE, Suit.HEARTS), Card(Rank.ACE, Suit.CLUBS))
|
||||||
|
val state = playingState(
|
||||||
|
hands = listOf(emptyList(), hand, emptyList(), emptyList()),
|
||||||
|
trump = Suit.SPADES,
|
||||||
|
turn = 1,
|
||||||
|
).copy(trick = listOf(PlayedCard(0, Card(Rank.KING, Suit.HEARTS))))
|
||||||
|
assertEquals(listOf(Card(Rank.NINE, Suit.HEARTS)), KaiserRules.playableCards(state, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `trump beats the led suit and there are no bowers`() {
|
||||||
|
val trump = Suit.SPADES
|
||||||
|
assertTrue(
|
||||||
|
trickStrength(Card(Rank.EIGHT, trump), trump, Suit.HEARTS) >
|
||||||
|
trickStrength(Card(Rank.ACE, Suit.HEARTS), trump, Suit.HEARTS)
|
||||||
|
)
|
||||||
|
// The jack is just a jack here.
|
||||||
|
assertTrue(
|
||||||
|
trickStrength(Card(Rank.ACE, trump), trump, trump) >
|
||||||
|
trickStrength(Card(Rank.JACK, trump), trump, trump)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `taking the five of hearts is worth five`() {
|
||||||
|
val hands = listOf(
|
||||||
|
listOf(Card(Rank.ACE, Suit.CLUBS)),
|
||||||
|
listOf(FIVE_OF_HEARTS),
|
||||||
|
listOf(Card(Rank.EIGHT, Suit.CLUBS)),
|
||||||
|
listOf(Card(Rank.NINE, Suit.CLUBS)),
|
||||||
|
)
|
||||||
|
var state = playingState(hands, trump = Suit.SPADES)
|
||||||
|
state = KaiserRules.applyMove(state, 0, PlayCard(Card(Rank.ACE, Suit.CLUBS)))
|
||||||
|
state = KaiserRules.applyMove(state, 1, PlayCard(FIVE_OF_HEARTS))
|
||||||
|
state = KaiserRules.applyMove(state, 2, PlayCard(Card(Rank.EIGHT, Suit.CLUBS)))
|
||||||
|
state = KaiserRules.applyMove(state, 3, PlayCard(Card(Rank.NINE, Suit.CLUBS)))
|
||||||
|
|
||||||
|
assertEquals("seat 0 took it", 0, state.lastTrickWinner)
|
||||||
|
assertEquals("one for the trick plus five", 6, state.handPoints[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `taking the three of spades costs three`() {
|
||||||
|
val hands = listOf(
|
||||||
|
listOf(Card(Rank.ACE, Suit.CLUBS)),
|
||||||
|
listOf(THREE_OF_SPADES),
|
||||||
|
listOf(Card(Rank.EIGHT, Suit.CLUBS)),
|
||||||
|
listOf(Card(Rank.NINE, Suit.CLUBS)),
|
||||||
|
)
|
||||||
|
var state = playingState(hands, trump = Suit.HEARTS)
|
||||||
|
state = KaiserRules.applyMove(state, 0, PlayCard(Card(Rank.ACE, Suit.CLUBS)))
|
||||||
|
state = KaiserRules.applyMove(state, 1, PlayCard(THREE_OF_SPADES))
|
||||||
|
state = KaiserRules.applyMove(state, 2, PlayCard(Card(Rank.EIGHT, Suit.CLUBS)))
|
||||||
|
state = KaiserRules.applyMove(state, 3, PlayCard(Card(Rank.NINE, Suit.CLUBS)))
|
||||||
|
|
||||||
|
assertEquals(0, state.lastTrickWinner)
|
||||||
|
assertEquals("one for the trick less three", -2, state.handPoints[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a finished trick is held on the table`() {
|
||||||
|
val hands = listOf(
|
||||||
|
listOf(Card(Rank.ACE, Suit.CLUBS), Card(Rank.KING, Suit.CLUBS)),
|
||||||
|
listOf(Card(Rank.EIGHT, Suit.CLUBS), Card(Rank.NINE, Suit.CLUBS)),
|
||||||
|
listOf(Card(Rank.TEN, Suit.CLUBS), Card(Rank.QUEEN, Suit.CLUBS)),
|
||||||
|
listOf(Card(Rank.JACK, Suit.CLUBS), Card(Rank.EIGHT, Suit.HEARTS)),
|
||||||
|
)
|
||||||
|
var state = playingState(hands, trump = Suit.SPADES)
|
||||||
|
repeat(4) {
|
||||||
|
val seat = KaiserRules.currentSeat(state)!!
|
||||||
|
state = KaiserRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
|
||||||
|
}
|
||||||
|
assertTrue(state.trick.isEmpty())
|
||||||
|
assertEquals("all four held for display", 4, state.completedTrick.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Scoring ------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `making the bid scores what was taken and going down costs the bid`() {
|
||||||
|
val made = scoreOneHand(bid = Bid(6, noTrump = false), bidderTakesAll = true)
|
||||||
|
assertTrue("made contract scores", made.scores[0] > 0)
|
||||||
|
|
||||||
|
val down = scoreOneHand(bid = Bid(10, noTrump = false), bidderTakesAll = false)
|
||||||
|
assertEquals("going down costs the bid", -10, down.scores[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plays a whole hand where one team either wins every trick or none. */
|
||||||
|
private fun scoreOneHand(bid: Bid, bidderTakesAll: Boolean): KaiserState {
|
||||||
|
val strong = Suit.SPADES
|
||||||
|
// Team 0 holds every spade; team 1 holds none.
|
||||||
|
val hands = listOf(
|
||||||
|
(0 until 8).map { Card(Rank.entries.filter { r -> r.order >= Rank.EIGHT.order }[it % 7], strong) },
|
||||||
|
List(8) { Card(Rank.EIGHT, Suit.HEARTS) },
|
||||||
|
List(8) { Card(Rank.NINE, Suit.HEARTS) },
|
||||||
|
List(8) { Card(Rank.TEN, Suit.HEARTS) },
|
||||||
|
)
|
||||||
|
var state = playingState(hands, trump = strong, bid = bid, bidder = if (bidderTakesAll) 0 else 0)
|
||||||
|
if (!bidderTakesAll) {
|
||||||
|
// Give the spades to the other side instead.
|
||||||
|
state = state.copy(
|
||||||
|
hands = listOf(hands[1], hands[0], hands[2], hands[3]),
|
||||||
|
handCounts = hands.map { it.size },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var guard = 0
|
||||||
|
while (state.phase == KaiserPhase.PLAYING && guard++ < 100) {
|
||||||
|
val seat = KaiserRules.currentSeat(state)!!
|
||||||
|
val legal = KaiserRules.legalMoves(state, seat)
|
||||||
|
state = KaiserRules.applyMove(state, seat, legal.first())
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `defenders keep what they take`() {
|
||||||
|
var state = KaiserRules.initialState(config())
|
||||||
|
var guard = 0
|
||||||
|
while (state.phase != KaiserPhase.HAND_OVER && !KaiserRules.isFinished(state) && guard++ < 500) {
|
||||||
|
val seat = KaiserRules.currentSeat(state) ?: break
|
||||||
|
val legal = KaiserRules.legalMoves(state, seat)
|
||||||
|
state = KaiserRules.applyMove(state, seat, legal.first())
|
||||||
|
}
|
||||||
|
assertTrue("a hand was scored", state.scores.any { it != 0 } || state.handPoints.any { it != 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Full games ---------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `random legal play always reaches a winner`() {
|
||||||
|
repeat(15) { iteration ->
|
||||||
|
val random = Random(iteration.toLong())
|
||||||
|
var state = KaiserRules.initialState(config(seed = iteration.toLong()))
|
||||||
|
var guard = 0
|
||||||
|
while (!KaiserRules.isFinished(state) && guard++ < 8000) {
|
||||||
|
val seat = KaiserRules.currentSeat(state)
|
||||||
|
if (seat == null) {
|
||||||
|
state = KaiserRules.nextHand(state)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val legal = KaiserRules.legalMoves(state, seat)
|
||||||
|
assertTrue("seat $seat had no legal move in ${state.phase}", legal.isNotEmpty())
|
||||||
|
state = KaiserRules.applyMove(state, seat, legal[random.nextInt(legal.size)])
|
||||||
|
}
|
||||||
|
assertTrue("game $iteration did not finish", KaiserRules.isFinished(state))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ai play always reaches a winner`() {
|
||||||
|
val ai = KaiserAi()
|
||||||
|
repeat(8) { iteration ->
|
||||||
|
var state = KaiserRules.initialState(config(seed = 600L + iteration))
|
||||||
|
var guard = 0
|
||||||
|
while (!KaiserRules.isFinished(state) && guard++ < 8000) {
|
||||||
|
val seat = KaiserRules.currentSeat(state)
|
||||||
|
if (seat == null) {
|
||||||
|
state = KaiserRules.nextHand(state)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val legal = KaiserRules.legalMoves(state, seat)
|
||||||
|
val move = ai.chooseMove(state, seat, legal)
|
||||||
|
assertTrue("ai returned an illegal move $move", legal.contains(move))
|
||||||
|
state = KaiserRules.applyMove(state, seat, move)
|
||||||
|
}
|
||||||
|
assertTrue("ai game did not finish", KaiserRules.isFinished(state))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Redaction and wire format -----------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a seat view hides every other hand`() {
|
||||||
|
val state = KaiserRules.initialState(config())
|
||||||
|
val view = KaiserRules.viewFor(state, seat = 3)
|
||||||
|
assertEquals(state.hands[3], view.hands[3])
|
||||||
|
assertTrue(view.hands.filterIndexed { i, _ -> i != 3 }.all { it.isEmpty() })
|
||||||
|
assertEquals(listOf(8, 8, 8, 8), view.handCounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `state and moves survive a round trip`() {
|
||||||
|
val state = KaiserRules.initialState(config())
|
||||||
|
assertEquals(state, KaiserRules.decodeState(KaiserRules.encodeState(state)))
|
||||||
|
listOf(
|
||||||
|
PassBid,
|
||||||
|
MakeBid(Bid(7, noTrump = true)),
|
||||||
|
NameTrump(Suit.HEARTS),
|
||||||
|
NameTrump(null),
|
||||||
|
PlayCard(FIVE_OF_HEARTS),
|
||||||
|
).forEach { assertEquals(it, KaiserRules.decodeMove(KaiserRules.encodeMove(it))) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,7 +64,7 @@ class SequenceRulesTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `every non-jack card maps to exactly two squares`() {
|
fun `every non-jack card maps to exactly two squares`() {
|
||||||
val nonJacks = Suit.entries.flatMap { suit ->
|
val nonJacks = Suit.entries.flatMap { suit ->
|
||||||
Rank.entries.filter { it != Rank.JACK }.map { Card(it, suit) }
|
Rank.standard.filter { it != Rank.JACK }.map { Card(it, suit) }
|
||||||
}
|
}
|
||||||
nonJacks.forEach { card ->
|
nonJacks.forEach { card ->
|
||||||
assertEquals("$card", 2, SequenceBoard.squaresByCard[card]?.size)
|
assertEquals("$card", 2, SequenceBoard.squaresByCard[card]?.size)
|
||||||
|
|||||||
@@ -0,0 +1,471 @@
|
|||||||
|
package org.prolibertate.games.game.wizard
|
||||||
|
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.prolibertate.games.game.cards.Card
|
||||||
|
import org.prolibertate.games.game.cards.Decks
|
||||||
|
import org.prolibertate.games.game.cards.Rank
|
||||||
|
import org.prolibertate.games.game.cards.Suit
|
||||||
|
import org.prolibertate.games.game.engine.PlayerKind
|
||||||
|
import org.prolibertate.games.game.engine.PlayerSlot
|
||||||
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
class WizardRulesTest {
|
||||||
|
|
||||||
|
private val json = Json { encodeDefaults = true }
|
||||||
|
|
||||||
|
private fun config(options: WizardOptions = WizardOptions(), seed: Long = 11L) = TableConfig(
|
||||||
|
gameId = "wizard",
|
||||||
|
seats = (0 until options.playerCount).map {
|
||||||
|
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
|
||||||
|
},
|
||||||
|
optionsJson = json.encodeToString(options),
|
||||||
|
seed = seed,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun wizardOf(suit: Suit) = Card(Rank.WIZARD, suit)
|
||||||
|
private fun jesterOf(suit: Suit) = Card(Rank.JESTER, suit)
|
||||||
|
|
||||||
|
// -- The deck -----------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the deck is sixty cards with four wizards and four jesters`() {
|
||||||
|
val deck = Decks.wizard()
|
||||||
|
assertEquals(60, deck.size)
|
||||||
|
assertEquals("no duplicates", 60, deck.distinct().size)
|
||||||
|
assertEquals(4, deck.count { isWizardCard(it) })
|
||||||
|
assertEquals(4, deck.count { isJester(it) })
|
||||||
|
assertEquals(52, deck.count { it.rank.standard })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the whole deck is dealt out over the rounds`() {
|
||||||
|
assertEquals(20, WizardOptions(playerCount = 3).totalRounds())
|
||||||
|
assertEquals(15, WizardOptions(playerCount = 4).totalRounds())
|
||||||
|
assertEquals(12, WizardOptions(playerCount = 5).totalRounds())
|
||||||
|
assertEquals(10, WizardOptions(playerCount = 6).totalRounds())
|
||||||
|
// A short game stops early rather than inventing extra rounds.
|
||||||
|
assertEquals(5, WizardOptions(playerCount = 4, rounds = 5).totalRounds())
|
||||||
|
assertEquals(15, WizardOptions(playerCount = 4, rounds = 99).totalRounds())
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Dealing and trump --------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `round one deals a single card each and turns one for trump`() {
|
||||||
|
val state = WizardRules.initialState(config())
|
||||||
|
assertEquals(WizardPhase.BIDDING, state.phase)
|
||||||
|
state.hands.forEach { assertEquals(1, it.size) }
|
||||||
|
assertEquals(1, state.cardsThisRound)
|
||||||
|
assertEquals("something was turned", true, state.trumpCard != null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the last round turns nothing and is played without trump`() {
|
||||||
|
// Four players, fifteen rounds: the last one uses every card in the deck.
|
||||||
|
var state = WizardRules.initialState(config())
|
||||||
|
var guard = 0
|
||||||
|
while (state.round < 14 && guard++ < 40) {
|
||||||
|
state = playRoundOut(state)
|
||||||
|
if (state.phase == WizardPhase.ROUND_OVER) state = WizardRules.nextRound(state)
|
||||||
|
}
|
||||||
|
assertEquals(14, state.round)
|
||||||
|
assertEquals(15, state.hands[0].size)
|
||||||
|
assertNull("nothing left to turn", state.trumpCard)
|
||||||
|
assertNull("so there is no trump", state.trump)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a turned jester means no trump`() {
|
||||||
|
// Search seeds until one turns a jester rather than asserting on luck.
|
||||||
|
val seed = (1L..400L).firstOrNull { s ->
|
||||||
|
val state = WizardRules.initialState(config(seed = s))
|
||||||
|
state.trumpCard?.let { isJester(it) } == true
|
||||||
|
}
|
||||||
|
requireNotNull(seed) { "no seed in range turned a jester" }
|
||||||
|
val state = WizardRules.initialState(config(seed = seed))
|
||||||
|
assertNull(state.trump)
|
||||||
|
assertTrue("bidding starts left of the dealer", state.turn == (state.dealer + 1) % 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a turned wizard hands the dealer the choice of trump`() {
|
||||||
|
val seed = (1L..400L).firstOrNull { s ->
|
||||||
|
val state = WizardRules.initialState(config(seed = s))
|
||||||
|
state.trumpCard?.let { isWizardCard(it) } == true
|
||||||
|
}
|
||||||
|
requireNotNull(seed) { "no seed in range turned a wizard" }
|
||||||
|
var state = WizardRules.initialState(config(seed = seed))
|
||||||
|
|
||||||
|
assertNull("trump is not set yet", state.trump)
|
||||||
|
assertEquals("the dealer is on turn", state.dealer, state.turn)
|
||||||
|
val choices = WizardRules.legalMoves(state, state.dealer)
|
||||||
|
assertEquals(Suit.entries.map { ChooseTrump(it) }, choices)
|
||||||
|
|
||||||
|
state = WizardRules.applyMove(state, state.dealer, ChooseTrump(Suit.HEARTS))
|
||||||
|
assertEquals(Suit.HEARTS, state.trump)
|
||||||
|
assertEquals("then bidding opens on the left", (state.dealer + 1) % 4, state.turn)
|
||||||
|
assertTrue(WizardRules.legalMoves(state, state.turn).all { it is MakeBid })
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Bidding ------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `you may bid anything from zero up to the tricks available`() {
|
||||||
|
val state = WizardRules.initialState(config())
|
||||||
|
val bids = WizardRules.legalMoves(state, state.turn).filterIsInstance<MakeBid>()
|
||||||
|
assertEquals(listOf(0, 1), bids.map { it.tricks })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `screw the dealer forbids the bid that would balance the book`() {
|
||||||
|
var state = WizardRules.initialState(
|
||||||
|
config(WizardOptions(screwTheDealer = true, rounds = 4), seed = 3L)
|
||||||
|
)
|
||||||
|
// Get to a round with room to bid, then walk round to the dealer.
|
||||||
|
while (state.cardsThisRound < 3) {
|
||||||
|
state = playRoundOut(state)
|
||||||
|
state = WizardRules.nextRound(state)
|
||||||
|
}
|
||||||
|
if (state.trumpCard?.let { isWizardCard(it) } == true) {
|
||||||
|
state = WizardRules.applyMove(state, state.dealer, ChooseTrump(Suit.SPADES))
|
||||||
|
}
|
||||||
|
val available = state.cardsThisRound
|
||||||
|
var bidSoFar = 0
|
||||||
|
while (state.turn != state.dealer) {
|
||||||
|
val seat = state.turn
|
||||||
|
state = WizardRules.applyMove(state, seat, MakeBid(1))
|
||||||
|
bidSoFar += 1
|
||||||
|
}
|
||||||
|
val dealerBids = WizardRules.legalMoves(state, state.dealer)
|
||||||
|
.filterIsInstance<MakeBid>().map { it.tricks }
|
||||||
|
assertFalse("the balancing bid is off the table", dealerBids.contains(available - bidSoFar))
|
||||||
|
assertEquals(available, dealerBids.size) // one fewer than the 0..available range
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `play starts once everyone has bid`() {
|
||||||
|
var state = WizardRules.initialState(config())
|
||||||
|
if (state.trumpCard?.let { isWizardCard(it) } == true) {
|
||||||
|
state = WizardRules.applyMove(state, state.dealer, ChooseTrump(Suit.SPADES))
|
||||||
|
}
|
||||||
|
repeat(4) { state = WizardRules.applyMove(state, state.turn, MakeBid(0)) }
|
||||||
|
assertEquals(WizardPhase.PLAYING, state.phase)
|
||||||
|
assertEquals("the player left of the dealer leads", (state.dealer + 1) % 4, state.turn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Trick ranking ------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a wizard beats everything including trump`() {
|
||||||
|
val trump = Suit.SPADES
|
||||||
|
assertTrue(
|
||||||
|
trickStrength(wizardOf(Suit.CLUBS), 1, trump, Suit.HEARTS) >
|
||||||
|
trickStrength(Card(Rank.ACE, trump), 0, trump, Suit.HEARTS)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the first wizard down takes the trick`() {
|
||||||
|
assertTrue(
|
||||||
|
trickStrength(wizardOf(Suit.CLUBS), 0, Suit.SPADES, null) >
|
||||||
|
trickStrength(wizardOf(Suit.HEARTS), 3, Suit.SPADES, null)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a jester loses to everything, even an off-suit discard`() {
|
||||||
|
val trump = Suit.SPADES
|
||||||
|
val jester = trickStrength(jesterOf(Suit.CLUBS), 1, trump, Suit.HEARTS)
|
||||||
|
val offSuit = trickStrength(Card(Rank.TWO, Suit.DIAMONDS), 2, trump, Suit.HEARTS)
|
||||||
|
assertTrue(offSuit > jester)
|
||||||
|
assertTrue(trickStrength(Card(Rank.TWO, Suit.HEARTS), 2, trump, Suit.HEARTS) > offSuit)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a trick of nothing but jesters goes to the first one played`() {
|
||||||
|
val hands = List(4) { seat -> listOf(jesterOf(Suit.entries[seat])) }
|
||||||
|
var state = playingState(hands, trump = Suit.SPADES, bids = listOf(0, 0, 0, 0))
|
||||||
|
repeat(4) {
|
||||||
|
val seat = WizardRules.currentSeat(state)!!
|
||||||
|
state = WizardRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
|
||||||
|
}
|
||||||
|
assertEquals(0, state.lastTrickWinner)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a led jester leaves the suit to the next ordinary card`() {
|
||||||
|
val trick = listOf(
|
||||||
|
PlayedCard(0, jesterOf(Suit.CLUBS)),
|
||||||
|
PlayedCard(1, Card(Rank.NINE, Suit.HEARTS)),
|
||||||
|
)
|
||||||
|
assertEquals(Suit.HEARTS, ledSuitOf(trick))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a wizard before any suit is established means anything may be played`() {
|
||||||
|
assertNull(ledSuitOf(listOf(PlayedCard(0, wizardOf(Suit.CLUBS)))))
|
||||||
|
assertNull(
|
||||||
|
ledSuitOf(
|
||||||
|
listOf(
|
||||||
|
PlayedCard(0, jesterOf(Suit.CLUBS)),
|
||||||
|
PlayedCard(1, wizardOf(Suit.HEARTS)),
|
||||||
|
PlayedCard(2, Card(Rank.NINE, Suit.SPADES)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Following suit -----------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `you must follow suit but may always throw a wizard or a jester`() {
|
||||||
|
val hand = listOf(
|
||||||
|
Card(Rank.NINE, Suit.HEARTS),
|
||||||
|
Card(Rank.ACE, Suit.CLUBS),
|
||||||
|
wizardOf(Suit.SPADES),
|
||||||
|
jesterOf(Suit.DIAMONDS),
|
||||||
|
)
|
||||||
|
val state = playingState(
|
||||||
|
hands = listOf(emptyList(), hand, emptyList(), emptyList()),
|
||||||
|
trump = Suit.SPADES,
|
||||||
|
bids = listOf(0, 1, 0, 0),
|
||||||
|
turn = 1,
|
||||||
|
).copy(trick = listOf(PlayedCard(0, Card(Rank.KING, Suit.HEARTS))))
|
||||||
|
|
||||||
|
val playable = WizardRules.playableCards(state, 1)
|
||||||
|
assertTrue(playable.contains(Card(Rank.NINE, Suit.HEARTS)))
|
||||||
|
assertTrue(playable.contains(wizardOf(Suit.SPADES)))
|
||||||
|
assertTrue(playable.contains(jesterOf(Suit.DIAMONDS)))
|
||||||
|
assertFalse("the ace of clubs is not a heart", playable.contains(Card(Rank.ACE, Suit.CLUBS)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `with none of the led suit you may play anything`() {
|
||||||
|
val hand = listOf(Card(Rank.ACE, Suit.CLUBS), Card(Rank.TWO, Suit.SPADES))
|
||||||
|
val state = playingState(
|
||||||
|
hands = listOf(emptyList(), hand, emptyList(), emptyList()),
|
||||||
|
trump = Suit.SPADES,
|
||||||
|
bids = listOf(0, 1, 0, 0),
|
||||||
|
turn = 1,
|
||||||
|
).copy(trick = listOf(PlayedCard(0, Card(Rank.KING, Suit.HEARTS))))
|
||||||
|
assertEquals(hand, WizardRules.playableCards(state, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a finished trick is held on the table`() {
|
||||||
|
val hands = listOf(
|
||||||
|
listOf(Card(Rank.ACE, Suit.CLUBS), Card(Rank.KING, Suit.CLUBS)),
|
||||||
|
listOf(Card(Rank.EIGHT, Suit.CLUBS), Card(Rank.NINE, Suit.CLUBS)),
|
||||||
|
listOf(Card(Rank.TEN, Suit.CLUBS), Card(Rank.QUEEN, Suit.CLUBS)),
|
||||||
|
listOf(Card(Rank.JACK, Suit.CLUBS), Card(Rank.TWO, Suit.HEARTS)),
|
||||||
|
)
|
||||||
|
var state = playingState(hands, trump = Suit.SPADES, bids = listOf(1, 0, 0, 1))
|
||||||
|
repeat(4) {
|
||||||
|
val seat = WizardRules.currentSeat(state)!!
|
||||||
|
state = WizardRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
|
||||||
|
}
|
||||||
|
assertTrue(state.trick.isEmpty())
|
||||||
|
assertEquals("all four held for display", 4, state.completedTrick.size)
|
||||||
|
assertEquals("seat 0 took it with the ace", 0, state.lastTrickWinner)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Scoring ------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an exact bid pays twenty plus ten a trick`() {
|
||||||
|
assertEquals(20, scoreRound(bid = 0, won = 0))
|
||||||
|
assertEquals(30, scoreRound(bid = 1, won = 1))
|
||||||
|
assertEquals(50, scoreRound(bid = 3, won = 3))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing costs ten for every trick out either way`() {
|
||||||
|
assertEquals(-10, scoreRound(bid = 0, won = 1))
|
||||||
|
assertEquals(-30, scoreRound(bid = 3, won = 0))
|
||||||
|
assertEquals(-20, scoreRound(bid = 1, won = 3))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the round is scored when the last trick is taken`() {
|
||||||
|
val hands = listOf(
|
||||||
|
listOf(Card(Rank.ACE, Suit.CLUBS)),
|
||||||
|
listOf(Card(Rank.EIGHT, Suit.CLUBS)),
|
||||||
|
listOf(Card(Rank.NINE, Suit.CLUBS)),
|
||||||
|
listOf(Card(Rank.TEN, Suit.CLUBS)),
|
||||||
|
)
|
||||||
|
var state = playingState(hands, trump = Suit.SPADES, bids = listOf(1, 0, 0, 1))
|
||||||
|
repeat(4) {
|
||||||
|
val seat = WizardRules.currentSeat(state)!!
|
||||||
|
state = WizardRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
|
||||||
|
}
|
||||||
|
assertEquals(WizardPhase.ROUND_OVER, state.phase)
|
||||||
|
assertEquals("bid one and took it", 30, state.roundScores[0])
|
||||||
|
assertEquals("bid none and took none", 20, state.roundScores[1])
|
||||||
|
assertEquals("bid one and took none", -10, state.roundScores[3])
|
||||||
|
assertEquals(state.roundScores, state.scores)
|
||||||
|
assertNull("no seat is on turn between rounds", WizardRules.currentSeat(state))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the next round deals one more card and moves the deal on`() {
|
||||||
|
var state = WizardRules.initialState(config())
|
||||||
|
val dealer = state.dealer
|
||||||
|
state = playRoundOut(state)
|
||||||
|
assertEquals(WizardPhase.ROUND_OVER, state.phase)
|
||||||
|
|
||||||
|
val next = WizardRules.nextRound(state)
|
||||||
|
assertEquals(1, next.round)
|
||||||
|
assertEquals(2, next.cardsThisRound)
|
||||||
|
next.hands.forEach { assertEquals(2, it.size) }
|
||||||
|
assertEquals((dealer + 1) % 4, next.dealer)
|
||||||
|
assertEquals("scores carry over", state.scores, next.scores)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Full games ---------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `random legal play always reaches the end of the game`() {
|
||||||
|
for (players in 3..6) {
|
||||||
|
repeat(3) { iteration ->
|
||||||
|
val random = Random(iteration.toLong())
|
||||||
|
val options = WizardOptions(playerCount = players)
|
||||||
|
var state = WizardRules.initialState(
|
||||||
|
config(options, seed = 100L * players + iteration)
|
||||||
|
)
|
||||||
|
var guard = 0
|
||||||
|
while (!WizardRules.isFinished(state) && guard++ < 20_000) {
|
||||||
|
val seat = WizardRules.currentSeat(state)
|
||||||
|
if (seat == null) {
|
||||||
|
state = WizardRules.nextRound(state)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val legal = WizardRules.legalMoves(state, seat)
|
||||||
|
assertTrue("seat $seat had no move in ${state.phase}", legal.isNotEmpty())
|
||||||
|
state = WizardRules.applyMove(state, seat, legal[random.nextInt(legal.size)])
|
||||||
|
}
|
||||||
|
assertTrue("$players-handed game $iteration did not finish",
|
||||||
|
WizardRules.isFinished(state))
|
||||||
|
assertEquals(options.totalRounds() - 1, state.round)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ai play always reaches the end of the game`() {
|
||||||
|
val ai = WizardAi()
|
||||||
|
repeat(4) { iteration ->
|
||||||
|
var state = WizardRules.initialState(config(seed = 700L + iteration))
|
||||||
|
var guard = 0
|
||||||
|
while (!WizardRules.isFinished(state) && guard++ < 20_000) {
|
||||||
|
val seat = WizardRules.currentSeat(state)
|
||||||
|
if (seat == null) {
|
||||||
|
state = WizardRules.nextRound(state)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val legal = WizardRules.legalMoves(state, seat)
|
||||||
|
val move = ai.chooseMove(state, seat, legal)
|
||||||
|
assertTrue("ai returned an illegal move $move", legal.contains(move))
|
||||||
|
state = WizardRules.applyMove(state, seat, move)
|
||||||
|
}
|
||||||
|
assertTrue("ai game did not finish", WizardRules.isFinished(state))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the ai hits its bid more often than chance would`() {
|
||||||
|
val ai = WizardAi()
|
||||||
|
var exact = 0
|
||||||
|
var rounds = 0
|
||||||
|
repeat(6) { iteration ->
|
||||||
|
var state = WizardRules.initialState(config(seed = 900L + iteration))
|
||||||
|
var guard = 0
|
||||||
|
while (!WizardRules.isFinished(state) && guard++ < 20_000) {
|
||||||
|
val seat = WizardRules.currentSeat(state)
|
||||||
|
if (seat == null) {
|
||||||
|
exact += state.roundScores.count { it > 0 }
|
||||||
|
rounds += state.roundScores.size
|
||||||
|
state = WizardRules.nextRound(state)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val legal = WizardRules.legalMoves(state, seat)
|
||||||
|
state = WizardRules.applyMove(state, seat, ai.chooseMove(state, seat, legal))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Blind guessing over a fifteen-round game lands well under a fifth of
|
||||||
|
// the time; anything paying attention should clear a quarter.
|
||||||
|
assertTrue("only $exact of $rounds bids were exact", exact * 4 >= rounds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Redaction and wire format -----------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a seat view hides every other hand`() {
|
||||||
|
val state = WizardRules.initialState(config(WizardOptions(playerCount = 4)))
|
||||||
|
val view = WizardRules.viewFor(state, seat = 2)
|
||||||
|
assertEquals(state.hands[2], view.hands[2])
|
||||||
|
assertTrue(view.hands.filterIndexed { i, _ -> i != 2 }.all { it.isEmpty() })
|
||||||
|
assertEquals(listOf(1, 1, 1, 1), view.handCounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `state and moves survive a round trip`() {
|
||||||
|
val state = WizardRules.initialState(config())
|
||||||
|
assertEquals(state, WizardRules.decodeState(WizardRules.encodeState(state)))
|
||||||
|
listOf(
|
||||||
|
MakeBid(0),
|
||||||
|
MakeBid(3),
|
||||||
|
ChooseTrump(Suit.DIAMONDS),
|
||||||
|
PlayCard(wizardOf(Suit.SPADES)),
|
||||||
|
PlayCard(jesterOf(Suit.HEARTS)),
|
||||||
|
).forEach { assertEquals(it, WizardRules.decodeMove(WizardRules.encodeMove(it))) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Helpers ------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun playingState(
|
||||||
|
hands: List<List<Card>>,
|
||||||
|
trump: Suit?,
|
||||||
|
bids: List<Int?>,
|
||||||
|
turn: Int = 0,
|
||||||
|
) = WizardState(
|
||||||
|
options = WizardOptions(playerCount = hands.size),
|
||||||
|
seed = 1L,
|
||||||
|
round = hands.first().size - 1,
|
||||||
|
dealer = hands.size - 1,
|
||||||
|
phase = WizardPhase.PLAYING,
|
||||||
|
hands = hands,
|
||||||
|
handCounts = hands.map { it.size },
|
||||||
|
trumpCard = null,
|
||||||
|
trump = trump,
|
||||||
|
bids = bids,
|
||||||
|
tricksWon = List(hands.size) { 0 },
|
||||||
|
turn = turn,
|
||||||
|
trick = emptyList(),
|
||||||
|
completedTrick = emptyList(),
|
||||||
|
leader = turn,
|
||||||
|
lastTrickWinner = null,
|
||||||
|
scores = List(hands.size) { 0 },
|
||||||
|
roundScores = List(hands.size) { 0 },
|
||||||
|
log = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Bids zero all round and plays the first legal card until the round ends. */
|
||||||
|
private fun playRoundOut(start: WizardState): WizardState {
|
||||||
|
var state = start
|
||||||
|
var guard = 0
|
||||||
|
while (state.phase == WizardPhase.BIDDING || state.phase == WizardPhase.PLAYING) {
|
||||||
|
if (guard++ > 5000) break
|
||||||
|
val seat = WizardRules.currentSeat(state)!!
|
||||||
|
val legal = WizardRules.legalMoves(state, seat)
|
||||||
|
state = WizardRules.applyMove(state, seat, legal.first())
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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