Add Kaiser, Crazy 8s and Wizard

Three more card games, engine to screen.

Kaiser: 32-card prairie trick-taking with bidding, the five of hearts
worth five and the three of spades costing three. Bidding is capped at
ten because ten is all a hand can be worth, and a losing score gives the
game a floor — without one a table that keeps bidding and going down
never reaches a result.

Crazy 8s: eights are wild and name the suit in force, which is why the
table states that suit in words rather than leaving it to be read off
the pile. Blocked rounds are scored rather than hanging.

Wizard: the 60-card deck, one more card dealt each round until the pack
runs out, bid your tricks exactly. The shared card model gains WIZARD
and JESTER ranks, so every ordinary deck now builds from Rank.standard
rather than walking the whole enum.

Also fixes a lobby bug this would have hit: hosting seated the
catalogue minimum rather than the table the host had just set up, so a
six-handed game would have been rejected by its own rules engine.

164 engine tests pass locally.
This commit is contained in:
Claude
2026-08-06 10:21:23 +00:00
parent f5a2626196
commit fb70396d09
26 changed files with 4504 additions and 24 deletions
+9 -3
View File
@@ -5,12 +5,12 @@ or against other people over Wi-Fi (LAN) or Bluetooth.
## What works today
The app shell is complete and four games are playable end to end:
The app shell is complete and seven games are playable end to end:
| | |
| --- | --- |
| **Playable** | Euchre, Sequence, President, Golf |
| **Listed, not yet implemented** | Kaiser, Wizard, Crazy 8s, Mastermind, Backgammon, Chess, Checkers |
| **Playable** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s, Sequence |
| **Listed, not yet implemented** | Mastermind, Backgammon, Chess, Checkers |
Unimplemented games appear in the menu marked *Coming soon* **on dev builds
only** — a production release lists just the games that can actually be played,
@@ -148,8 +148,11 @@ game:
- [Euchre](docs/RULES-euchre.md)
- [Sequence](docs/RULES-sequence.md) — **note the caveat about the board layout**
- [Kaiser](docs/RULES-kaiser.md)
- [President](docs/RULES-president.md)
- [Golf](docs/RULES-golf.md)
- [Wizard](docs/RULES-wizard.md)
- [Crazy 8s](docs/RULES-crazy8s.md)
## Layout
@@ -158,9 +161,12 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested
cards/ deck, suits, ranks
engine/ GameRules / GameAi contracts, seats, table config
euchre/ Euchre model, rules, AI
kaiser/ Kaiser model, bidding, rules, AI
sequence/ Sequence model, board, rules, AI
president/ President model, rules, AI
golf/ Golf model, scoring, rules, AI
wizard/ Wizard model, rules, AI
crazyeights/ Crazy 8s model, rules, AI
net/ wire protocol, LAN and Bluetooth transports, lobby, match driver
settings/ DataStore-backed preferences
update/ GitHub Releases OTA updater
@@ -58,7 +58,7 @@ object GameCatalog {
maxPlayers = 4,
teamBased = true,
blurb = "Prairie trick-taking with bidding. The 5 of hearts and 3 of spades decide it.",
available = false,
available = true,
),
GameDescriptor(
id = PRESIDENT,
@@ -88,7 +88,7 @@ object GameCatalog {
maxPlayers = 6,
teamBased = false,
blurb = "Bid your tricks exactly. Wizards always win, jesters always lose.",
available = false,
available = true,
),
GameDescriptor(
id = CRAZY_EIGHTS,
@@ -98,7 +98,7 @@ object GameCatalog {
maxPlayers = 6,
teamBased = false,
blurb = "Match rank or suit, and eights change everything.",
available = false,
available = true,
),
GameDescriptor(
id = SEQUENCE,
@@ -19,8 +19,14 @@ enum class Suit(val symbol: String, val isRed: Boolean) {
}
}
/**
* [standard] marks the fifty-two cards every ordinary deck holds. The two
* extras belong to Wizard alone, so anything building a normal deck must filter
* on this rather than walking the whole enum — otherwise every other game
* quietly gains eight cards it has no rules for.
*/
@Serializable
enum class Rank(val short: String, val order: Int) {
enum class Rank(val short: String, val order: Int, val standard: Boolean = true) {
TWO("2", 2),
THREE("3", 3),
FOUR("4", 4),
@@ -34,6 +40,18 @@ enum class Rank(val short: String, val order: Int) {
QUEEN("Q", 12),
KING("K", 13),
ACE("A", 14),
/** Wizard only: beats everything, including trump. */
WIZARD("Wz", 100, standard = false),
/** Wizard only: loses to everything, and leads no suit. */
JESTER("Je", 0, standard = false),
;
companion object {
/** The thirteen ranks of an ordinary deck. */
val standard: List<Rank> get() = entries.filter { it.standard }
}
}
@Serializable
@@ -61,7 +79,7 @@ object Decks {
/** Standard 52-card deck. */
fun standard52(): List<Card> =
Suit.entries.flatMap { suit -> Rank.entries.map { rank -> Card(rank, suit) } }
Suit.entries.flatMap { suit -> Rank.standard.map { rank -> Card(rank, suit) } }
/**
* Euchre decks. 24 cards (9 through ace) is the common game; 32 cards
@@ -74,12 +92,38 @@ object Decks {
else -> throw IllegalArgumentException("Unsupported Euchre deck size: $size")
}
return Suit.entries.flatMap { suit ->
Rank.entries.filter { it.order >= lowest.order }.map { rank -> Card(rank, suit) }
Rank.standard.filter { it.order >= lowest.order }.map { rank -> Card(rank, suit) }
}
}
/**
* The 60-card Wizard deck: an ordinary pack plus four wizards and four
* jesters, one of each per suit so they fit the same card model.
*/
fun wizard(): List<Card> = standard52() +
Suit.entries.map { Card(Rank.WIZARD, it) } +
Suit.entries.map { Card(Rank.JESTER, it) }
/** Two full decks shuffled together, as Sequence uses. */
fun double52(): List<Card> = standard52() + standard52()
/**
* The 32-card Kaiser deck: eight to ace in every suit, plus the seven of
* clubs and the seven of diamonds — and, in place of the other two sevens,
* the five of hearts and the three of spades, which are what the whole game
* is played for.
*/
fun kaiser(): List<Card> {
val high = Suit.entries.flatMap { suit ->
Rank.standard.filter { it.order >= Rank.EIGHT.order }.map { Card(it, suit) }
}
return high + listOf(
Card(Rank.SEVEN, Suit.CLUBS),
Card(Rank.SEVEN, Suit.DIAMONDS),
Card(Rank.FIVE, Suit.HEARTS),
Card(Rank.THREE, Suit.SPADES),
)
}
}
/**
@@ -0,0 +1,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)
}
@@ -30,12 +30,18 @@ import org.prolibertate.games.game.GameDescriptor
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
import org.prolibertate.games.game.crazyeights.CrazyEightsOptions
import org.prolibertate.games.game.euchre.EuchreOptions
import org.prolibertate.games.game.golf.GolfOptions
import org.prolibertate.games.game.kaiser.KaiserOptions
import org.prolibertate.games.game.president.PresidentOptions
import org.prolibertate.games.game.sequence.SequenceOptions
import org.prolibertate.games.game.wizard.WizardOptions
private val setupJson = Json { encodeDefaults = true }
private val setupJson = Json {
encodeDefaults = true
ignoreUnknownKeys = true
}
/**
* Choose who is playing and which house rules apply.
@@ -55,21 +61,21 @@ fun GameSetupScreen(
var sequence by remember { mutableStateOf(SequenceOptions()) }
var president by remember { mutableStateOf(PresidentOptions()) }
var golf by remember { mutableStateOf(GolfOptions()) }
var kaiser by remember { mutableStateOf(KaiserOptions()) }
var crazyEights by remember { mutableStateOf(CrazyEightsOptions()) }
var wizard by remember { mutableStateOf(WizardOptions()) }
val optionsJson = when (descriptor.id) {
GameCatalog.EUCHRE -> setupJson.encodeToString(euchre)
GameCatalog.SEQUENCE -> setupJson.encodeToString(sequence)
GameCatalog.PRESIDENT -> setupJson.encodeToString(president)
GameCatalog.GOLF -> setupJson.encodeToString(golf)
GameCatalog.KAISER -> setupJson.encodeToString(kaiser)
GameCatalog.CRAZY_EIGHTS -> setupJson.encodeToString(crazyEights)
GameCatalog.WIZARD -> setupJson.encodeToString(wizard)
else -> "{}"
}
val seatCount = when (descriptor.id) {
GameCatalog.EUCHRE -> 4
GameCatalog.SEQUENCE -> sequence.playerCount
GameCatalog.PRESIDENT -> president.playerCount
GameCatalog.GOLF -> golf.playerCount
else -> descriptor.minPlayers
}
val seatCount = seatCountFor(descriptor.id, optionsJson)
ScreenScaffold(title = descriptor.title, onBack = onBack) { modifier ->
Column(
@@ -86,6 +92,12 @@ fun GameSetupScreen(
GameCatalog.SEQUENCE -> SequenceOptionsEditor(sequence) { sequence = it }
GameCatalog.PRESIDENT -> PresidentOptionsEditor(president) { president = it }
GameCatalog.GOLF -> GolfOptionsEditor(golf) { golf = it }
GameCatalog.KAISER -> KaiserOptionsEditor(kaiser) { kaiser = it }
GameCatalog.CRAZY_EIGHTS -> CrazyEightsOptionsEditor(crazyEights) {
crazyEights = it
}
GameCatalog.WIZARD -> WizardOptionsEditor(wizard) { wizard = it }
else -> Text("No options yet for this game.")
}
@@ -131,9 +143,41 @@ private fun offlineConfig(
seed = System.currentTimeMillis(),
)
/**
* How many seats a table needs, read back out of the options the host chose.
*
* The lobby only carries the options as JSON, so this is the one place that
* knows a six-handed Wizard game needs six seats rather than the catalogue's
* minimum — seat the wrong number and the rules engine rejects the table.
*/
fun seatCountFor(gameId: String, optionsJson: String): Int {
val fallback = GameCatalog.byId(gameId)?.minPlayers ?: 2
return runCatching {
when (gameId) {
GameCatalog.EUCHRE, GameCatalog.KAISER -> 4
GameCatalog.SEQUENCE ->
setupJson.decodeFromString<SequenceOptions>(optionsJson).playerCount
GameCatalog.PRESIDENT ->
setupJson.decodeFromString<PresidentOptions>(optionsJson).playerCount
GameCatalog.GOLF ->
setupJson.decodeFromString<GolfOptions>(optionsJson).playerCount
GameCatalog.CRAZY_EIGHTS ->
setupJson.decodeFromString<CrazyEightsOptions>(optionsJson).playerCount
GameCatalog.WIZARD ->
setupJson.decodeFromString<WizardOptions>(optionsJson).playerCount
else -> fallback
}
}.getOrDefault(fallback)
}
fun teamForSeat(gameId: String, seat: Int): Int = when (gameId) {
// Partners sit opposite each other.
GameCatalog.EUCHRE, GameCatalog.SEQUENCE -> seat % 2
GameCatalog.EUCHRE, GameCatalog.KAISER, GameCatalog.SEQUENCE -> seat % 2
// Everyone else plays for themselves.
else -> seat
}
@@ -305,6 +349,113 @@ 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,
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun <T> ChipRow(
@@ -21,7 +21,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.TableConfig
import org.prolibertate.games.net.DiscoveredHost
@@ -46,10 +45,9 @@ fun LobbyScreen(
LaunchedEffect(route) {
env.requestBluetoothPermissions()
if (route.hosting) {
val descriptor = GameCatalog.byId(route.gameId)
val seatCount = descriptor?.let {
if (it.id == GameCatalog.EUCHRE) 4 else it.minPlayers
} ?: 4
// Seat the table the host actually set up, not the catalogue
// minimum: the rules engine rejects a table of the wrong size.
val seatCount = seatCountFor(route.gameId, route.optionsJson)
env.lobby.startHosting(
gameId = route.gameId,
optionsJson = route.optionsJson,
@@ -7,6 +7,11 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.crazyeights.CrazyEightsAi
import org.prolibertate.games.game.crazyeights.CrazyEightsMove
import org.prolibertate.games.game.crazyeights.CrazyEightsPhase
import org.prolibertate.games.game.crazyeights.CrazyEightsRules
import org.prolibertate.games.game.crazyeights.CrazyEightsState
import org.prolibertate.games.game.euchre.EuchreAi
import org.prolibertate.games.game.euchre.EuchreMove
import org.prolibertate.games.game.euchre.EuchrePhase
@@ -17,6 +22,11 @@ import org.prolibertate.games.game.golf.GolfMove
import org.prolibertate.games.game.golf.GolfPhase
import org.prolibertate.games.game.golf.GolfRules
import org.prolibertate.games.game.golf.GolfState
import org.prolibertate.games.game.kaiser.KaiserAi
import org.prolibertate.games.game.kaiser.KaiserMove
import org.prolibertate.games.game.kaiser.KaiserPhase
import org.prolibertate.games.game.kaiser.KaiserRules
import org.prolibertate.games.game.kaiser.KaiserState
import org.prolibertate.games.game.president.PresidentAi
import org.prolibertate.games.game.president.PresidentMove
import org.prolibertate.games.game.president.PresidentPhase
@@ -26,13 +36,21 @@ import org.prolibertate.games.game.sequence.SequenceAi
import org.prolibertate.games.game.sequence.SequenceMove
import org.prolibertate.games.game.sequence.SequenceRules
import org.prolibertate.games.game.sequence.SequenceState
import org.prolibertate.games.game.wizard.WizardAi
import org.prolibertate.games.game.wizard.WizardMove
import org.prolibertate.games.game.wizard.WizardPhase
import org.prolibertate.games.game.wizard.WizardRules
import org.prolibertate.games.game.wizard.WizardState
import org.prolibertate.games.net.MatchController
import org.prolibertate.games.settings.Settings
import org.prolibertate.games.ui.game.CrazyEightsScreen
import org.prolibertate.games.ui.game.EuchreScreen
import org.prolibertate.games.ui.game.GolfScreen
import org.prolibertate.games.ui.game.KaiserScreen
import org.prolibertate.games.ui.game.PresidentScreen
import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS
import org.prolibertate.games.ui.game.SequenceScreen
import org.prolibertate.games.ui.game.WizardScreen
/**
* Builds the match for the chosen game and hands it to that game's screen.
@@ -154,6 +172,107 @@ fun PlayScreen(
GolfScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
}
GameCatalog.KAISER -> {
val controller = remember(route) {
MatchController<KaiserState, KaiserMove>(
rules = KaiserRules,
ai = KaiserAi(),
config = route.config,
scope = scope,
role = role,
localSeats = setOf(route.localSeat),
primarySeat = route.localSeat,
advanceIdle = { state ->
if (state.phase == KaiserPhase.HAND_OVER) {
KaiserRules.nextHand(state)
} else {
null
}
},
aiThinkingMillis = { settings.scaled(700L) },
holdBeforeNextMove = { state ->
if (state.completedTrick.isNotEmpty()) {
settings.scaled(TRICK_HOLD_MILLIS)
} else {
0L
}
},
)
}
StartMatch(env, controller, route)
KaiserScreen(
controller = controller,
localSeat = route.localSeat,
trickHoldMillis = settings.scaled(TRICK_HOLD_MILLIS),
onExit = onExit,
)
}
GameCatalog.WIZARD -> {
val controller = remember(route) {
MatchController<WizardState, WizardMove>(
rules = WizardRules,
ai = WizardAi(),
config = route.config,
scope = scope,
role = role,
localSeats = setOf(route.localSeat),
primarySeat = route.localSeat,
advanceIdle = { state ->
if (state.phase == WizardPhase.ROUND_OVER) {
WizardRules.nextRound(state)
} else {
null
}
},
// Bids against tricks taken are read before the next deal.
awaitsConfirmation = { state -> state.phase == WizardPhase.ROUND_OVER },
aiThinkingMillis = { settings.scaled(700L) },
holdBeforeNextMove = { state ->
if (state.completedTrick.isNotEmpty()) {
settings.scaled(TRICK_HOLD_MILLIS)
} else {
0L
}
},
)
}
StartMatch(env, controller, route)
WizardScreen(
controller = controller,
localSeat = route.localSeat,
trickHoldMillis = settings.scaled(TRICK_HOLD_MILLIS),
onExit = onExit,
)
}
GameCatalog.CRAZY_EIGHTS -> {
val controller = remember(route) {
MatchController<CrazyEightsState, CrazyEightsMove>(
rules = CrazyEightsRules,
ai = CrazyEightsAi(),
config = route.config,
scope = scope,
role = role,
localSeats = setOf(route.localSeat),
primarySeat = route.localSeat,
advanceIdle = { state ->
if (state.phase == CrazyEightsPhase.ROUND_OVER) {
CrazyEightsRules.nextRound(state)
} else {
null
}
},
awaitsConfirmation = { state ->
state.phase == CrazyEightsPhase.ROUND_OVER
},
aiThinkingMillis = { settings.scaled(700L) },
)
}
StartMatch(env, controller, route)
CrazyEightsScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
}
else -> Text("That game isn't playable yet.")
}
}
@@ -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,9 @@ class CatalogTest {
GameCatalog.SEQUENCE,
GameCatalog.PRESIDENT,
GameCatalog.GOLF,
GameCatalog.KAISER,
GameCatalog.CRAZY_EIGHTS,
GameCatalog.WIZARD,
),
GameCatalog.playable.map { it.id }.toSet(),
)
@@ -0,0 +1,337 @@
package org.prolibertate.games.game.crazyeights
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
import kotlin.random.Random
class CrazyEightsRulesTest {
private val json = Json { encodeDefaults = true }
private fun config(
options: CrazyEightsOptions = CrazyEightsOptions(),
seed: Long = 21L,
) = TableConfig(
gameId = "crazy8s",
seats = (0 until options.playerCount).map {
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
},
optionsJson = json.encodeToString(options),
seed = seed,
)
private fun c(rank: Rank, suit: Suit) = Card(rank, suit)
private fun playingState(
hands: List<List<Card>>,
top: Card,
suitInForce: Suit = top.suit,
stock: List<Card> = emptyList(),
turn: Int = 0,
options: CrazyEightsOptions = CrazyEightsOptions(playerCount = hands.size),
drawnThisTurn: Int = 0,
) = CrazyEightsState(
options = options,
seed = 1L,
roundNumber = 0,
hands = hands,
handCounts = hands.map { it.size },
stock = stock,
discard = listOf(top),
suitInForce = suitInForce,
turn = turn,
drawnThisTurn = drawnThisTurn,
scores = List(hands.size) { 0 },
roundWinner = null,
phase = CrazyEightsPhase.PLAYING,
log = emptyList(),
)
// -- Matching -----------------------------------------------------------
@Test
fun `a card matches on suit or on rank`() {
val top = c(Rank.SEVEN, Suit.HEARTS)
assertTrue("same suit", canPlay(c(Rank.KING, Suit.HEARTS), top, Suit.HEARTS))
assertTrue("same rank", canPlay(c(Rank.SEVEN, Suit.CLUBS), top, Suit.HEARTS))
assertFalse("neither", canPlay(c(Rank.KING, Suit.CLUBS), top, Suit.HEARTS))
}
@Test
fun `an eight always plays`() {
val top = c(Rank.SEVEN, Suit.HEARTS)
Suit.entries.forEach { suit ->
assertTrue(canPlay(c(Rank.EIGHT, suit), top, Suit.HEARTS))
}
}
@Test
fun `the suit in force overrides the top card`() {
// An eight was played on a heart and clubs were called.
val top = c(Rank.EIGHT, Suit.HEARTS)
assertTrue(canPlay(c(Rank.TWO, Suit.CLUBS), top, Suit.CLUBS))
assertFalse("hearts no longer run", canPlay(c(Rank.TWO, Suit.HEARTS), top, Suit.CLUBS))
}
// -- Legal moves --------------------------------------------------------
@Test
fun `playing an eight offers a choice of suit`() {
val hand = listOf(c(Rank.EIGHT, Suit.SPADES))
val state = playingState(listOf(hand, emptyList()), top = c(Rank.THREE, Suit.HEARTS))
val moves = CrazyEightsRules.legalMoves(state, 0).filterIsInstance<PlayCard>()
assertEquals("one move per suit", 4, moves.size)
assertEquals(Suit.entries.toSet(), moves.mapNotNull { it.nominatedSuit }.toSet())
}
@Test
fun `you must play when you can`() {
val hand = listOf(c(Rank.KING, Suit.HEARTS), c(Rank.TWO, Suit.CLUBS))
val state = playingState(
hands = listOf(hand, emptyList()),
top = c(Rank.THREE, Suit.HEARTS),
stock = listOf(c(Rank.NINE, Suit.SPADES)),
)
val moves = CrazyEightsRules.legalMoves(state, 0)
assertTrue("no drawing your way out of a playable hand", moves.none { it is DrawCard })
assertEquals(1, moves.size)
}
@Test
fun `with nothing playable you draw`() {
val hand = listOf(c(Rank.KING, Suit.CLUBS))
val state = playingState(
hands = listOf(hand, emptyList()),
top = c(Rank.THREE, Suit.HEARTS),
stock = listOf(c(Rank.NINE, Suit.SPADES)),
)
assertEquals(listOf(DrawCard), CrazyEightsRules.legalMoves(state, 0))
}
@Test
fun `with an empty stock and nothing playable you pass`() {
val hand = listOf(c(Rank.KING, Suit.CLUBS))
val state = playingState(
hands = listOf(hand, emptyList()),
top = c(Rank.THREE, Suit.HEARTS),
stock = emptyList(),
)
assertEquals(listOf(PassTurn), CrazyEightsRules.legalMoves(state, 0))
}
@Test
fun `draw one and pass stops after a single card`() {
val options = CrazyEightsOptions(playerCount = 2, drawUntilPlayable = false)
val hand = listOf(c(Rank.KING, Suit.CLUBS))
val state = playingState(
hands = listOf(hand, emptyList()),
top = c(Rank.THREE, Suit.HEARTS),
stock = listOf(c(Rank.NINE, Suit.SPADES), c(Rank.TEN, Suit.SPADES)),
options = options,
drawnThisTurn = 1,
)
assertEquals("already drawn, so pass", listOf(PassTurn), CrazyEightsRules.legalMoves(state, 0))
}
// -- Play ---------------------------------------------------------------
@Test
fun `an eight sets the suit in force`() {
val hand = listOf(c(Rank.EIGHT, Suit.SPADES), c(Rank.TWO, Suit.CLUBS))
var state = playingState(listOf(hand, listOf(c(Rank.FIVE, Suit.CLUBS))),
top = c(Rank.THREE, Suit.HEARTS))
state = CrazyEightsRules.applyMove(
state, 0, PlayCard(c(Rank.EIGHT, Suit.SPADES), Suit.DIAMONDS)
)
assertEquals(Suit.DIAMONDS, state.suitInForce)
assertEquals(c(Rank.EIGHT, Suit.SPADES), state.topCard)
assertEquals(1, state.turn)
}
@Test
fun `drawing keeps the turn`() {
val state = playingState(
hands = listOf(listOf(c(Rank.KING, Suit.CLUBS)), emptyList()),
top = c(Rank.THREE, Suit.HEARTS),
stock = listOf(c(Rank.NINE, Suit.SPADES)),
)
val after = CrazyEightsRules.applyMove(state, 0, DrawCard)
assertEquals("still your turn", 0, after.turn)
assertEquals(2, after.hands[0].size)
assertEquals(1, after.drawnThisTurn)
}
@Test
fun `an exhausted stock is refilled from the discards`() {
val state = playingState(
hands = listOf(listOf(c(Rank.KING, Suit.CLUBS)), emptyList()),
top = c(Rank.THREE, Suit.HEARTS),
stock = emptyList(),
).copy(discard = listOf(c(Rank.FOUR, Suit.SPADES), c(Rank.THREE, Suit.HEARTS)))
val after = CrazyEightsRules.applyMove(state, 0, DrawCard)
assertEquals("the card in play stays put", c(Rank.THREE, Suit.HEARTS), after.topCard)
assertEquals("and the rest became stock", 2, after.hands[0].size)
}
// -- Scoring ------------------------------------------------------------
@Test
fun `penalties charge eights the most`() {
assertEquals(50, crazyEightsPenalty(c(Rank.EIGHT, Suit.CLUBS)))
assertEquals(10, crazyEightsPenalty(c(Rank.KING, Suit.CLUBS)))
assertEquals(10, crazyEightsPenalty(c(Rank.TEN, Suit.CLUBS)))
assertEquals(1, crazyEightsPenalty(c(Rank.ACE, Suit.CLUBS)))
assertEquals(5, crazyEightsPenalty(c(Rank.FIVE, Suit.CLUBS)))
}
@Test
fun `going out charges everyone else for what they hold`() {
val options = CrazyEightsOptions(playerCount = 3, roundsToPlay = 2)
var state = playingState(
hands = listOf(
listOf(c(Rank.THREE, Suit.HEARTS)),
listOf(c(Rank.EIGHT, Suit.CLUBS), c(Rank.ACE, Suit.SPADES)),
listOf(c(Rank.KING, Suit.SPADES)),
),
top = c(Rank.FOUR, Suit.HEARTS),
options = options,
)
state = CrazyEightsRules.applyMove(state, 0, PlayCard(c(Rank.THREE, Suit.HEARTS)))
assertEquals(CrazyEightsPhase.ROUND_OVER, state.phase)
assertEquals(0, state.roundWinner)
assertEquals("the winner is charged nothing", 0, state.scores[0])
assertEquals("an eight and an ace", 51, state.scores[1])
assertEquals(10, state.scores[2])
}
@Test
fun `the last round ends the game`() {
val options = CrazyEightsOptions(playerCount = 2, roundsToPlay = 1)
var state = playingState(
hands = listOf(listOf(c(Rank.THREE, Suit.HEARTS)), listOf(c(Rank.KING, Suit.SPADES))),
top = c(Rank.FOUR, Suit.HEARTS),
options = options,
)
state = CrazyEightsRules.applyMove(state, 0, PlayCard(c(Rank.THREE, Suit.HEARTS)))
assertTrue(CrazyEightsRules.isFinished(state))
}
// -- Dealing ------------------------------------------------------------
@Test
fun `two players get seven cards and everyone else five`() {
assertEquals(7, CrazyEightsOptions(playerCount = 2).handSize())
assertEquals(5, CrazyEightsOptions(playerCount = 4).handSize())
assertEquals(9, CrazyEightsOptions(playerCount = 4, startingHand = 9).handSize())
}
@Test
fun `the opening card is never an eight`() {
// An eight on top would leave the suit in force unnamed.
repeat(30) { seed ->
val state = CrazyEightsRules.initialState(config(seed = seed.toLong()))
assertFalse("seed $seed dealt a wild upcard", isWild(state.topCard!!))
assertEquals(state.topCard!!.suit, state.suitInForce)
}
}
// -- Full games ---------------------------------------------------------
@Test
fun `random legal play always finishes`() {
repeat(20) { iteration ->
val random = Random(iteration.toLong())
var state = CrazyEightsRules.initialState(
config(CrazyEightsOptions(playerCount = 4, roundsToPlay = 2), iteration.toLong())
)
var guard = 0
while (!CrazyEightsRules.isFinished(state) && guard++ < 8000) {
val seat = CrazyEightsRules.currentSeat(state)
if (seat == null) {
state = CrazyEightsRules.nextRound(state)
continue
}
val legal = CrazyEightsRules.legalMoves(state, seat)
assertTrue("seat $seat had no legal move", legal.isNotEmpty())
state = CrazyEightsRules.applyMove(state, seat, legal[random.nextInt(legal.size)])
}
assertTrue("game $iteration did not finish", CrazyEightsRules.isFinished(state))
}
}
@Test
fun `ai play always finishes and beats random play`() {
val ai = CrazyEightsAi()
var aiTotal = 0
var randomTotal = 0
repeat(12) { iteration ->
val random = Random(3000L + iteration)
var state = CrazyEightsRules.initialState(
config(CrazyEightsOptions(playerCount = 2, roundsToPlay = 3), 800L + iteration)
)
var guard = 0
while (!CrazyEightsRules.isFinished(state) && guard++ < 8000) {
val seat = CrazyEightsRules.currentSeat(state)
if (seat == null) {
state = CrazyEightsRules.nextRound(state)
continue
}
val legal = CrazyEightsRules.legalMoves(state, seat)
val move = if (seat == 0) {
ai.chooseMove(state, seat, legal)
} else {
legal[random.nextInt(legal.size)]
}
assertTrue("ai returned an illegal move $move", legal.contains(move))
state = CrazyEightsRules.applyMove(state, seat, move)
}
assertTrue(CrazyEightsRules.isFinished(state))
aiTotal += state.scores[0]
randomTotal += state.scores[1]
}
// Penalty points, so lower is better.
assertTrue(
"ai took $aiTotal against random's $randomTotal — no better than flailing",
aiTotal < randomTotal,
)
}
// -- Redaction and wire format -----------------------------------------
@Test
fun `a seat view hides other hands and the stock`() {
val state = CrazyEightsRules.initialState(config())
val view = CrazyEightsRules.viewFor(state, seat = 2)
assertEquals(state.hands[2], view.hands[2])
assertTrue(view.hands.filterIndexed { i, _ -> i != 2 }.all { it.isEmpty() })
assertTrue(view.stock.isEmpty())
assertEquals(state.handCounts, view.handCounts)
}
@Test
fun `state and moves survive a round trip`() {
val state = CrazyEightsRules.initialState(config())
assertEquals(state, CrazyEightsRules.decodeState(CrazyEightsRules.encodeState(state)))
listOf(
DrawCard,
PassTurn,
PlayCard(c(Rank.KING, Suit.HEARTS)),
PlayCard(c(Rank.EIGHT, Suit.CLUBS), Suit.DIAMONDS),
).forEach {
assertEquals(it, CrazyEightsRules.decodeMove(CrazyEightsRules.encodeMove(it)))
}
}
}
@@ -0,0 +1,369 @@
package org.prolibertate.games.game.kaiser
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Decks
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
import kotlin.random.Random
class KaiserRulesTest {
private val json = Json { encodeDefaults = true }
private fun config(options: KaiserOptions = KaiserOptions(), seed: Long = 5L) = TableConfig(
gameId = "kaiser",
seats = (0 until 4).map {
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it % 2)
},
optionsJson = json.encodeToString(options),
seed = seed,
)
// -- The deck -----------------------------------------------------------
@Test
fun `the deck is thirty-two cards with the two counters in it`() {
val deck = Decks.kaiser()
assertEquals(32, deck.size)
assertEquals("no duplicates", 32, deck.distinct().size)
assertTrue("five of hearts", deck.contains(FIVE_OF_HEARTS))
assertTrue("three of spades", deck.contains(THREE_OF_SPADES))
assertTrue("seven of clubs stays", deck.contains(Card(Rank.SEVEN, Suit.CLUBS)))
assertTrue("seven of diamonds stays", deck.contains(Card(Rank.SEVEN, Suit.DIAMONDS)))
assertFalse("but not the seven of hearts", deck.contains(Card(Rank.SEVEN, Suit.HEARTS)))
assertFalse("nor the seven of spades", deck.contains(Card(Rank.SEVEN, Suit.SPADES)))
assertTrue("nothing below the counters", deck.none {
it.rank.order < Rank.SEVEN.order && it != FIVE_OF_HEARTS && it != THREE_OF_SPADES
})
}
@Test
fun `a hand is worth ten points in total`() {
// Eight tricks at one each, plus five, less three.
val deck = Decks.kaiser()
val allTricks = deck.chunked(4)
assertEquals(8, allTricks.size)
assertEquals(10, allTricks.sumOf { trickValue(it) })
}
@Test
fun `the counters move a tricks value`() {
val plain = listOf(Card(Rank.ACE, Suit.CLUBS), Card(Rank.KING, Suit.CLUBS))
assertEquals(1, trickValue(plain))
assertEquals(6, trickValue(plain + FIVE_OF_HEARTS))
assertEquals(-2, trickValue(plain + THREE_OF_SPADES))
assertEquals(3, trickValue(plain + FIVE_OF_HEARTS + THREE_OF_SPADES))
}
// -- Bidding ------------------------------------------------------------
@Test
fun `everyone gets eight cards and bidding opens left of the dealer`() {
val state = KaiserRules.initialState(config())
assertEquals(KaiserPhase.BIDDING, state.phase)
state.hands.forEach { assertEquals(8, it.size) }
assertEquals((state.dealer + 1) % 4, state.turn)
}
@Test
fun `a bid must beat the one before it`() {
var state = KaiserRules.initialState(config(KaiserOptions(minimumBid = 6)))
val opener = state.turn
state = KaiserRules.applyMove(state, opener, MakeBid(Bid(7, noTrump = false)))
val moves = KaiserRules.legalMoves(state, state.turn).filterIsInstance<MakeBid>()
assertTrue("nothing at or below seven in suit",
moves.none { !it.bid.noTrump && it.bid.points <= 7 })
assertTrue("but seven no trump beats seven",
moves.any { it.bid.noTrump && it.bid.points == 7 })
}
@Test
fun `no trump outranks the same number`() {
assertTrue(Bid(7, noTrump = true).beats(Bid(7, noTrump = false)))
assertFalse(Bid(7, noTrump = false).beats(Bid(7, noTrump = true)))
assertTrue(Bid(8, noTrump = false).beats(Bid(7, noTrump = true)))
}
@Test
fun `the bidding floor is respected`() {
val state = KaiserRules.initialState(config(KaiserOptions(minimumBid = 8)))
val moves = KaiserRules.legalMoves(state, state.turn).filterIsInstance<MakeBid>()
assertTrue(moves.all { it.bid.points >= 8 })
}
@Test
fun `the dealer is stuck when everyone passes`() {
var state = KaiserRules.initialState(config())
repeat(3) { state = KaiserRules.applyMove(state, state.turn, PassBid) }
assertEquals(state.dealer, state.turn)
val moves = KaiserRules.legalMoves(state, state.dealer)
assertFalse("the dealer has to bid", moves.contains(PassBid))
assertTrue(moves.all { it is MakeBid })
}
@Test
fun `the winning bidder names trump and leads`() {
var state = KaiserRules.initialState(config())
val bidder = state.turn
state = KaiserRules.applyMove(state, bidder, MakeBid(Bid(6, noTrump = false)))
repeat(3) { state = KaiserRules.applyMove(state, state.turn, PassBid) }
assertEquals("back to the bidder", bidder, state.turn)
val naming = KaiserRules.legalMoves(state, bidder).filterIsInstance<NameTrump>()
assertEquals("a choice of four suits", 4, naming.size)
state = KaiserRules.applyMove(state, bidder, NameTrump(Suit.SPADES))
assertEquals(KaiserPhase.PLAYING, state.phase)
assertEquals(Suit.SPADES, state.trump)
assertEquals("and leads", bidder, state.turn)
}
@Test
fun `a no trump bid commits to no trump`() {
var state = KaiserRules.initialState(config())
val bidder = state.turn
state = KaiserRules.applyMove(state, bidder, MakeBid(Bid(6, noTrump = true)))
repeat(3) { state = KaiserRules.applyMove(state, state.turn, PassBid) }
val naming = KaiserRules.legalMoves(state, bidder).filterIsInstance<NameTrump>()
assertEquals(listOf(NameTrump(null)), naming)
state = KaiserRules.applyMove(state, bidder, NameTrump(null))
assertEquals(null, state.trump)
assertEquals(KaiserPhase.PLAYING, state.phase)
}
// -- Play ---------------------------------------------------------------
private fun playingState(
hands: List<List<Card>>,
trump: Suit?,
bid: Bid = Bid(6, noTrump = false),
bidder: Int = 0,
turn: Int = 0,
) = KaiserState(
options = KaiserOptions(),
seed = 1L,
handNumber = 0,
dealer = 3,
phase = KaiserPhase.PLAYING,
hands = hands,
handCounts = hands.map { it.size },
turn = turn,
passes = 3,
highBid = bid,
highBidder = bidder,
trump = trump,
trick = emptyList(),
completedTrick = emptyList(),
leader = turn,
lastTrickWinner = null,
handPoints = listOf(0, 0),
scores = listOf(0, 0),
log = emptyList(),
)
@Test
fun `you must follow suit`() {
val hand = listOf(Card(Rank.NINE, Suit.HEARTS), Card(Rank.ACE, Suit.CLUBS))
val state = playingState(
hands = listOf(emptyList(), hand, emptyList(), emptyList()),
trump = Suit.SPADES,
turn = 1,
).copy(trick = listOf(PlayedCard(0, Card(Rank.KING, Suit.HEARTS))))
assertEquals(listOf(Card(Rank.NINE, Suit.HEARTS)), KaiserRules.playableCards(state, 1))
}
@Test
fun `trump beats the led suit and there are no bowers`() {
val trump = Suit.SPADES
assertTrue(
trickStrength(Card(Rank.EIGHT, trump), trump, Suit.HEARTS) >
trickStrength(Card(Rank.ACE, Suit.HEARTS), trump, Suit.HEARTS)
)
// The jack is just a jack here.
assertTrue(
trickStrength(Card(Rank.ACE, trump), trump, trump) >
trickStrength(Card(Rank.JACK, trump), trump, trump)
)
}
@Test
fun `taking the five of hearts is worth five`() {
val hands = listOf(
listOf(Card(Rank.ACE, Suit.CLUBS)),
listOf(FIVE_OF_HEARTS),
listOf(Card(Rank.EIGHT, Suit.CLUBS)),
listOf(Card(Rank.NINE, Suit.CLUBS)),
)
var state = playingState(hands, trump = Suit.SPADES)
state = KaiserRules.applyMove(state, 0, PlayCard(Card(Rank.ACE, Suit.CLUBS)))
state = KaiserRules.applyMove(state, 1, PlayCard(FIVE_OF_HEARTS))
state = KaiserRules.applyMove(state, 2, PlayCard(Card(Rank.EIGHT, Suit.CLUBS)))
state = KaiserRules.applyMove(state, 3, PlayCard(Card(Rank.NINE, Suit.CLUBS)))
assertEquals("seat 0 took it", 0, state.lastTrickWinner)
assertEquals("one for the trick plus five", 6, state.handPoints[0])
}
@Test
fun `taking the three of spades costs three`() {
val hands = listOf(
listOf(Card(Rank.ACE, Suit.CLUBS)),
listOf(THREE_OF_SPADES),
listOf(Card(Rank.EIGHT, Suit.CLUBS)),
listOf(Card(Rank.NINE, Suit.CLUBS)),
)
var state = playingState(hands, trump = Suit.HEARTS)
state = KaiserRules.applyMove(state, 0, PlayCard(Card(Rank.ACE, Suit.CLUBS)))
state = KaiserRules.applyMove(state, 1, PlayCard(THREE_OF_SPADES))
state = KaiserRules.applyMove(state, 2, PlayCard(Card(Rank.EIGHT, Suit.CLUBS)))
state = KaiserRules.applyMove(state, 3, PlayCard(Card(Rank.NINE, Suit.CLUBS)))
assertEquals(0, state.lastTrickWinner)
assertEquals("one for the trick less three", -2, state.handPoints[0])
}
@Test
fun `a finished trick is held on the table`() {
val hands = listOf(
listOf(Card(Rank.ACE, Suit.CLUBS), Card(Rank.KING, Suit.CLUBS)),
listOf(Card(Rank.EIGHT, Suit.CLUBS), Card(Rank.NINE, Suit.CLUBS)),
listOf(Card(Rank.TEN, Suit.CLUBS), Card(Rank.QUEEN, Suit.CLUBS)),
listOf(Card(Rank.JACK, Suit.CLUBS), Card(Rank.EIGHT, Suit.HEARTS)),
)
var state = playingState(hands, trump = Suit.SPADES)
repeat(4) {
val seat = KaiserRules.currentSeat(state)!!
state = KaiserRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
}
assertTrue(state.trick.isEmpty())
assertEquals("all four held for display", 4, state.completedTrick.size)
}
// -- Scoring ------------------------------------------------------------
@Test
fun `making the bid scores what was taken and going down costs the bid`() {
val made = scoreOneHand(bid = Bid(6, noTrump = false), bidderTakesAll = true)
assertTrue("made contract scores", made.scores[0] > 0)
val down = scoreOneHand(bid = Bid(10, noTrump = false), bidderTakesAll = false)
assertEquals("going down costs the bid", -10, down.scores[0])
}
/** Plays a whole hand where one team either wins every trick or none. */
private fun scoreOneHand(bid: Bid, bidderTakesAll: Boolean): KaiserState {
val strong = Suit.SPADES
// Team 0 holds every spade; team 1 holds none.
val hands = listOf(
(0 until 8).map { Card(Rank.entries.filter { r -> r.order >= Rank.EIGHT.order }[it % 7], strong) },
List(8) { Card(Rank.EIGHT, Suit.HEARTS) },
List(8) { Card(Rank.NINE, Suit.HEARTS) },
List(8) { Card(Rank.TEN, Suit.HEARTS) },
)
var state = playingState(hands, trump = strong, bid = bid, bidder = if (bidderTakesAll) 0 else 0)
if (!bidderTakesAll) {
// Give the spades to the other side instead.
state = state.copy(
hands = listOf(hands[1], hands[0], hands[2], hands[3]),
handCounts = hands.map { it.size },
)
}
var guard = 0
while (state.phase == KaiserPhase.PLAYING && guard++ < 100) {
val seat = KaiserRules.currentSeat(state)!!
val legal = KaiserRules.legalMoves(state, seat)
state = KaiserRules.applyMove(state, seat, legal.first())
}
return state
}
@Test
fun `defenders keep what they take`() {
var state = KaiserRules.initialState(config())
var guard = 0
while (state.phase != KaiserPhase.HAND_OVER && !KaiserRules.isFinished(state) && guard++ < 500) {
val seat = KaiserRules.currentSeat(state) ?: break
val legal = KaiserRules.legalMoves(state, seat)
state = KaiserRules.applyMove(state, seat, legal.first())
}
assertTrue("a hand was scored", state.scores.any { it != 0 } || state.handPoints.any { it != 0 })
}
// -- Full games ---------------------------------------------------------
@Test
fun `random legal play always reaches a winner`() {
repeat(15) { iteration ->
val random = Random(iteration.toLong())
var state = KaiserRules.initialState(config(seed = iteration.toLong()))
var guard = 0
while (!KaiserRules.isFinished(state) && guard++ < 8000) {
val seat = KaiserRules.currentSeat(state)
if (seat == null) {
state = KaiserRules.nextHand(state)
continue
}
val legal = KaiserRules.legalMoves(state, seat)
assertTrue("seat $seat had no legal move in ${state.phase}", legal.isNotEmpty())
state = KaiserRules.applyMove(state, seat, legal[random.nextInt(legal.size)])
}
assertTrue("game $iteration did not finish", KaiserRules.isFinished(state))
}
}
@Test
fun `ai play always reaches a winner`() {
val ai = KaiserAi()
repeat(8) { iteration ->
var state = KaiserRules.initialState(config(seed = 600L + iteration))
var guard = 0
while (!KaiserRules.isFinished(state) && guard++ < 8000) {
val seat = KaiserRules.currentSeat(state)
if (seat == null) {
state = KaiserRules.nextHand(state)
continue
}
val legal = KaiserRules.legalMoves(state, seat)
val move = ai.chooseMove(state, seat, legal)
assertTrue("ai returned an illegal move $move", legal.contains(move))
state = KaiserRules.applyMove(state, seat, move)
}
assertTrue("ai game did not finish", KaiserRules.isFinished(state))
}
}
// -- Redaction and wire format -----------------------------------------
@Test
fun `a seat view hides every other hand`() {
val state = KaiserRules.initialState(config())
val view = KaiserRules.viewFor(state, seat = 3)
assertEquals(state.hands[3], view.hands[3])
assertTrue(view.hands.filterIndexed { i, _ -> i != 3 }.all { it.isEmpty() })
assertEquals(listOf(8, 8, 8, 8), view.handCounts)
}
@Test
fun `state and moves survive a round trip`() {
val state = KaiserRules.initialState(config())
assertEquals(state, KaiserRules.decodeState(KaiserRules.encodeState(state)))
listOf(
PassBid,
MakeBid(Bid(7, noTrump = true)),
NameTrump(Suit.HEARTS),
NameTrump(null),
PlayCard(FIVE_OF_HEARTS),
).forEach { assertEquals(it, KaiserRules.decodeMove(KaiserRules.encodeMove(it))) }
}
}
@@ -64,7 +64,7 @@ class SequenceRulesTest {
@Test
fun `every non-jack card maps to exactly two squares`() {
val nonJacks = Suit.entries.flatMap { suit ->
Rank.entries.filter { it != Rank.JACK }.map { Card(it, suit) }
Rank.standard.filter { it != Rank.JACK }.map { Card(it, suit) }
}
nonJacks.forEach { card ->
assertEquals("$card", 2, SequenceBoard.squaresByCard[card]?.size)
@@ -0,0 +1,471 @@
package org.prolibertate.games.game.wizard
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Decks
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
import kotlin.random.Random
class WizardRulesTest {
private val json = Json { encodeDefaults = true }
private fun config(options: WizardOptions = WizardOptions(), seed: Long = 11L) = TableConfig(
gameId = "wizard",
seats = (0 until options.playerCount).map {
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
},
optionsJson = json.encodeToString(options),
seed = seed,
)
private fun wizardOf(suit: Suit) = Card(Rank.WIZARD, suit)
private fun jesterOf(suit: Suit) = Card(Rank.JESTER, suit)
// -- The deck -----------------------------------------------------------
@Test
fun `the deck is sixty cards with four wizards and four jesters`() {
val deck = Decks.wizard()
assertEquals(60, deck.size)
assertEquals("no duplicates", 60, deck.distinct().size)
assertEquals(4, deck.count { isWizardCard(it) })
assertEquals(4, deck.count { isJester(it) })
assertEquals(52, deck.count { it.rank.standard })
}
@Test
fun `the whole deck is dealt out over the rounds`() {
assertEquals(20, WizardOptions(playerCount = 3).totalRounds())
assertEquals(15, WizardOptions(playerCount = 4).totalRounds())
assertEquals(12, WizardOptions(playerCount = 5).totalRounds())
assertEquals(10, WizardOptions(playerCount = 6).totalRounds())
// A short game stops early rather than inventing extra rounds.
assertEquals(5, WizardOptions(playerCount = 4, rounds = 5).totalRounds())
assertEquals(15, WizardOptions(playerCount = 4, rounds = 99).totalRounds())
}
// -- Dealing and trump --------------------------------------------------
@Test
fun `round one deals a single card each and turns one for trump`() {
val state = WizardRules.initialState(config())
assertEquals(WizardPhase.BIDDING, state.phase)
state.hands.forEach { assertEquals(1, it.size) }
assertEquals(1, state.cardsThisRound)
assertEquals("something was turned", true, state.trumpCard != null)
}
@Test
fun `the last round turns nothing and is played without trump`() {
// Four players, fifteen rounds: the last one uses every card in the deck.
var state = WizardRules.initialState(config())
var guard = 0
while (state.round < 14 && guard++ < 40) {
state = playRoundOut(state)
if (state.phase == WizardPhase.ROUND_OVER) state = WizardRules.nextRound(state)
}
assertEquals(14, state.round)
assertEquals(15, state.hands[0].size)
assertNull("nothing left to turn", state.trumpCard)
assertNull("so there is no trump", state.trump)
}
@Test
fun `a turned jester means no trump`() {
// Search seeds until one turns a jester rather than asserting on luck.
val seed = (1L..400L).firstOrNull { s ->
val state = WizardRules.initialState(config(seed = s))
state.trumpCard?.let { isJester(it) } == true
}
requireNotNull(seed) { "no seed in range turned a jester" }
val state = WizardRules.initialState(config(seed = seed))
assertNull(state.trump)
assertTrue("bidding starts left of the dealer", state.turn == (state.dealer + 1) % 4)
}
@Test
fun `a turned wizard hands the dealer the choice of trump`() {
val seed = (1L..400L).firstOrNull { s ->
val state = WizardRules.initialState(config(seed = s))
state.trumpCard?.let { isWizardCard(it) } == true
}
requireNotNull(seed) { "no seed in range turned a wizard" }
var state = WizardRules.initialState(config(seed = seed))
assertNull("trump is not set yet", state.trump)
assertEquals("the dealer is on turn", state.dealer, state.turn)
val choices = WizardRules.legalMoves(state, state.dealer)
assertEquals(Suit.entries.map { ChooseTrump(it) }, choices)
state = WizardRules.applyMove(state, state.dealer, ChooseTrump(Suit.HEARTS))
assertEquals(Suit.HEARTS, state.trump)
assertEquals("then bidding opens on the left", (state.dealer + 1) % 4, state.turn)
assertTrue(WizardRules.legalMoves(state, state.turn).all { it is MakeBid })
}
// -- Bidding ------------------------------------------------------------
@Test
fun `you may bid anything from zero up to the tricks available`() {
val state = WizardRules.initialState(config())
val bids = WizardRules.legalMoves(state, state.turn).filterIsInstance<MakeBid>()
assertEquals(listOf(0, 1), bids.map { it.tricks })
}
@Test
fun `screw the dealer forbids the bid that would balance the book`() {
var state = WizardRules.initialState(
config(WizardOptions(screwTheDealer = true, rounds = 4), seed = 3L)
)
// Get to a round with room to bid, then walk round to the dealer.
while (state.cardsThisRound < 3) {
state = playRoundOut(state)
state = WizardRules.nextRound(state)
}
if (state.trumpCard?.let { isWizardCard(it) } == true) {
state = WizardRules.applyMove(state, state.dealer, ChooseTrump(Suit.SPADES))
}
val available = state.cardsThisRound
var bidSoFar = 0
while (state.turn != state.dealer) {
val seat = state.turn
state = WizardRules.applyMove(state, seat, MakeBid(1))
bidSoFar += 1
}
val dealerBids = WizardRules.legalMoves(state, state.dealer)
.filterIsInstance<MakeBid>().map { it.tricks }
assertFalse("the balancing bid is off the table", dealerBids.contains(available - bidSoFar))
assertEquals(available, dealerBids.size) // one fewer than the 0..available range
}
@Test
fun `play starts once everyone has bid`() {
var state = WizardRules.initialState(config())
if (state.trumpCard?.let { isWizardCard(it) } == true) {
state = WizardRules.applyMove(state, state.dealer, ChooseTrump(Suit.SPADES))
}
repeat(4) { state = WizardRules.applyMove(state, state.turn, MakeBid(0)) }
assertEquals(WizardPhase.PLAYING, state.phase)
assertEquals("the player left of the dealer leads", (state.dealer + 1) % 4, state.turn)
}
// -- Trick ranking ------------------------------------------------------
@Test
fun `a wizard beats everything including trump`() {
val trump = Suit.SPADES
assertTrue(
trickStrength(wizardOf(Suit.CLUBS), 1, trump, Suit.HEARTS) >
trickStrength(Card(Rank.ACE, trump), 0, trump, Suit.HEARTS)
)
}
@Test
fun `the first wizard down takes the trick`() {
assertTrue(
trickStrength(wizardOf(Suit.CLUBS), 0, Suit.SPADES, null) >
trickStrength(wizardOf(Suit.HEARTS), 3, Suit.SPADES, null)
)
}
@Test
fun `a jester loses to everything, even an off-suit discard`() {
val trump = Suit.SPADES
val jester = trickStrength(jesterOf(Suit.CLUBS), 1, trump, Suit.HEARTS)
val offSuit = trickStrength(Card(Rank.TWO, Suit.DIAMONDS), 2, trump, Suit.HEARTS)
assertTrue(offSuit > jester)
assertTrue(trickStrength(Card(Rank.TWO, Suit.HEARTS), 2, trump, Suit.HEARTS) > offSuit)
}
@Test
fun `a trick of nothing but jesters goes to the first one played`() {
val hands = List(4) { seat -> listOf(jesterOf(Suit.entries[seat])) }
var state = playingState(hands, trump = Suit.SPADES, bids = listOf(0, 0, 0, 0))
repeat(4) {
val seat = WizardRules.currentSeat(state)!!
state = WizardRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
}
assertEquals(0, state.lastTrickWinner)
}
@Test
fun `a led jester leaves the suit to the next ordinary card`() {
val trick = listOf(
PlayedCard(0, jesterOf(Suit.CLUBS)),
PlayedCard(1, Card(Rank.NINE, Suit.HEARTS)),
)
assertEquals(Suit.HEARTS, ledSuitOf(trick))
}
@Test
fun `a wizard before any suit is established means anything may be played`() {
assertNull(ledSuitOf(listOf(PlayedCard(0, wizardOf(Suit.CLUBS)))))
assertNull(
ledSuitOf(
listOf(
PlayedCard(0, jesterOf(Suit.CLUBS)),
PlayedCard(1, wizardOf(Suit.HEARTS)),
PlayedCard(2, Card(Rank.NINE, Suit.SPADES)),
)
)
)
}
// -- Following suit -----------------------------------------------------
@Test
fun `you must follow suit but may always throw a wizard or a jester`() {
val hand = listOf(
Card(Rank.NINE, Suit.HEARTS),
Card(Rank.ACE, Suit.CLUBS),
wizardOf(Suit.SPADES),
jesterOf(Suit.DIAMONDS),
)
val state = playingState(
hands = listOf(emptyList(), hand, emptyList(), emptyList()),
trump = Suit.SPADES,
bids = listOf(0, 1, 0, 0),
turn = 1,
).copy(trick = listOf(PlayedCard(0, Card(Rank.KING, Suit.HEARTS))))
val playable = WizardRules.playableCards(state, 1)
assertTrue(playable.contains(Card(Rank.NINE, Suit.HEARTS)))
assertTrue(playable.contains(wizardOf(Suit.SPADES)))
assertTrue(playable.contains(jesterOf(Suit.DIAMONDS)))
assertFalse("the ace of clubs is not a heart", playable.contains(Card(Rank.ACE, Suit.CLUBS)))
}
@Test
fun `with none of the led suit you may play anything`() {
val hand = listOf(Card(Rank.ACE, Suit.CLUBS), Card(Rank.TWO, Suit.SPADES))
val state = playingState(
hands = listOf(emptyList(), hand, emptyList(), emptyList()),
trump = Suit.SPADES,
bids = listOf(0, 1, 0, 0),
turn = 1,
).copy(trick = listOf(PlayedCard(0, Card(Rank.KING, Suit.HEARTS))))
assertEquals(hand, WizardRules.playableCards(state, 1))
}
@Test
fun `a finished trick is held on the table`() {
val hands = listOf(
listOf(Card(Rank.ACE, Suit.CLUBS), Card(Rank.KING, Suit.CLUBS)),
listOf(Card(Rank.EIGHT, Suit.CLUBS), Card(Rank.NINE, Suit.CLUBS)),
listOf(Card(Rank.TEN, Suit.CLUBS), Card(Rank.QUEEN, Suit.CLUBS)),
listOf(Card(Rank.JACK, Suit.CLUBS), Card(Rank.TWO, Suit.HEARTS)),
)
var state = playingState(hands, trump = Suit.SPADES, bids = listOf(1, 0, 0, 1))
repeat(4) {
val seat = WizardRules.currentSeat(state)!!
state = WizardRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
}
assertTrue(state.trick.isEmpty())
assertEquals("all four held for display", 4, state.completedTrick.size)
assertEquals("seat 0 took it with the ace", 0, state.lastTrickWinner)
}
// -- Scoring ------------------------------------------------------------
@Test
fun `an exact bid pays twenty plus ten a trick`() {
assertEquals(20, scoreRound(bid = 0, won = 0))
assertEquals(30, scoreRound(bid = 1, won = 1))
assertEquals(50, scoreRound(bid = 3, won = 3))
}
@Test
fun `missing costs ten for every trick out either way`() {
assertEquals(-10, scoreRound(bid = 0, won = 1))
assertEquals(-30, scoreRound(bid = 3, won = 0))
assertEquals(-20, scoreRound(bid = 1, won = 3))
}
@Test
fun `the round is scored when the last trick is taken`() {
val hands = listOf(
listOf(Card(Rank.ACE, Suit.CLUBS)),
listOf(Card(Rank.EIGHT, Suit.CLUBS)),
listOf(Card(Rank.NINE, Suit.CLUBS)),
listOf(Card(Rank.TEN, Suit.CLUBS)),
)
var state = playingState(hands, trump = Suit.SPADES, bids = listOf(1, 0, 0, 1))
repeat(4) {
val seat = WizardRules.currentSeat(state)!!
state = WizardRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
}
assertEquals(WizardPhase.ROUND_OVER, state.phase)
assertEquals("bid one and took it", 30, state.roundScores[0])
assertEquals("bid none and took none", 20, state.roundScores[1])
assertEquals("bid one and took none", -10, state.roundScores[3])
assertEquals(state.roundScores, state.scores)
assertNull("no seat is on turn between rounds", WizardRules.currentSeat(state))
}
@Test
fun `the next round deals one more card and moves the deal on`() {
var state = WizardRules.initialState(config())
val dealer = state.dealer
state = playRoundOut(state)
assertEquals(WizardPhase.ROUND_OVER, state.phase)
val next = WizardRules.nextRound(state)
assertEquals(1, next.round)
assertEquals(2, next.cardsThisRound)
next.hands.forEach { assertEquals(2, it.size) }
assertEquals((dealer + 1) % 4, next.dealer)
assertEquals("scores carry over", state.scores, next.scores)
}
// -- Full games ---------------------------------------------------------
@Test
fun `random legal play always reaches the end of the game`() {
for (players in 3..6) {
repeat(3) { iteration ->
val random = Random(iteration.toLong())
val options = WizardOptions(playerCount = players)
var state = WizardRules.initialState(
config(options, seed = 100L * players + iteration)
)
var guard = 0
while (!WizardRules.isFinished(state) && guard++ < 20_000) {
val seat = WizardRules.currentSeat(state)
if (seat == null) {
state = WizardRules.nextRound(state)
continue
}
val legal = WizardRules.legalMoves(state, seat)
assertTrue("seat $seat had no move in ${state.phase}", legal.isNotEmpty())
state = WizardRules.applyMove(state, seat, legal[random.nextInt(legal.size)])
}
assertTrue("$players-handed game $iteration did not finish",
WizardRules.isFinished(state))
assertEquals(options.totalRounds() - 1, state.round)
}
}
}
@Test
fun `ai play always reaches the end of the game`() {
val ai = WizardAi()
repeat(4) { iteration ->
var state = WizardRules.initialState(config(seed = 700L + iteration))
var guard = 0
while (!WizardRules.isFinished(state) && guard++ < 20_000) {
val seat = WizardRules.currentSeat(state)
if (seat == null) {
state = WizardRules.nextRound(state)
continue
}
val legal = WizardRules.legalMoves(state, seat)
val move = ai.chooseMove(state, seat, legal)
assertTrue("ai returned an illegal move $move", legal.contains(move))
state = WizardRules.applyMove(state, seat, move)
}
assertTrue("ai game did not finish", WizardRules.isFinished(state))
}
}
@Test
fun `the ai hits its bid more often than chance would`() {
val ai = WizardAi()
var exact = 0
var rounds = 0
repeat(6) { iteration ->
var state = WizardRules.initialState(config(seed = 900L + iteration))
var guard = 0
while (!WizardRules.isFinished(state) && guard++ < 20_000) {
val seat = WizardRules.currentSeat(state)
if (seat == null) {
exact += state.roundScores.count { it > 0 }
rounds += state.roundScores.size
state = WizardRules.nextRound(state)
continue
}
val legal = WizardRules.legalMoves(state, seat)
state = WizardRules.applyMove(state, seat, ai.chooseMove(state, seat, legal))
}
}
// Blind guessing over a fifteen-round game lands well under a fifth of
// the time; anything paying attention should clear a quarter.
assertTrue("only $exact of $rounds bids were exact", exact * 4 >= rounds)
}
// -- Redaction and wire format -----------------------------------------
@Test
fun `a seat view hides every other hand`() {
val state = WizardRules.initialState(config(WizardOptions(playerCount = 4)))
val view = WizardRules.viewFor(state, seat = 2)
assertEquals(state.hands[2], view.hands[2])
assertTrue(view.hands.filterIndexed { i, _ -> i != 2 }.all { it.isEmpty() })
assertEquals(listOf(1, 1, 1, 1), view.handCounts)
}
@Test
fun `state and moves survive a round trip`() {
val state = WizardRules.initialState(config())
assertEquals(state, WizardRules.decodeState(WizardRules.encodeState(state)))
listOf(
MakeBid(0),
MakeBid(3),
ChooseTrump(Suit.DIAMONDS),
PlayCard(wizardOf(Suit.SPADES)),
PlayCard(jesterOf(Suit.HEARTS)),
).forEach { assertEquals(it, WizardRules.decodeMove(WizardRules.encodeMove(it))) }
}
// -- Helpers ------------------------------------------------------------
private fun playingState(
hands: List<List<Card>>,
trump: Suit?,
bids: List<Int?>,
turn: Int = 0,
) = WizardState(
options = WizardOptions(playerCount = hands.size),
seed = 1L,
round = hands.first().size - 1,
dealer = hands.size - 1,
phase = WizardPhase.PLAYING,
hands = hands,
handCounts = hands.map { it.size },
trumpCard = null,
trump = trump,
bids = bids,
tricksWon = List(hands.size) { 0 },
turn = turn,
trick = emptyList(),
completedTrick = emptyList(),
leader = turn,
lastTrickWinner = null,
scores = List(hands.size) { 0 },
roundScores = List(hands.size) { 0 },
log = emptyList(),
)
/** Bids zero all round and plays the first legal card until the round ends. */
private fun playRoundOut(start: WizardState): WizardState {
var state = start
var guard = 0
while (state.phase == WizardPhase.BIDDING || state.phase == WizardPhase.PLAYING) {
if (guard++ > 5000) break
val seat = WizardRules.currentSeat(state)!!
val legal = WizardRules.legalMoves(state, seat)
state = WizardRules.applyMove(state, seat, legal.first())
}
return state
}
}
+59
View File
@@ -0,0 +1,59 @@
# Crazy 8s — implemented rules
This is the ruleset the engine enforces. Anything marked **toggle** is in the
game setup screen.
## Table
- 2 to 6 players — **toggle**. Everyone plays for themselves.
- One 52-card deck.
- Seven cards each heads-up, five otherwise — **toggle** to fix it at 5, 7 or 9
instead.
- The rest becomes the stock; one card is turned up to start the discard pile.
If that card is an eight, the suit it shows is the one in force.
## Play
On your turn, play one card that matches either
- the **suit in force**, or
- the **rank** of the card on top of the pile.
**Eights are wild.** An eight may be played on anything, and the player names
the suit that is then in force. This is why the suit in force is stated in words
on the table rather than left to be read off the pile — after an eight, the top
card tells you nothing.
If you cannot play:
- **Draw until you can — *toggle*, on by default.** Keep drawing until a
playable card turns up, or until there is nothing left to draw.
- With the toggle off, draw a single card; if it still does not play, the turn
passes.
- When the stock is empty it is rebuilt from the discards, leaving the top card
in place. If there is genuinely nothing to draw and nothing to play, you pass.
If every player passes in turn, the round is blocked and ends with nobody out.
## Scoring
The round ends when a player sheds their last card. Everyone else scores penalty
points for what they were caught holding:
| Card | Penalty |
| --- | --- |
| Eight | 50 |
| Ten, jack, queen, king | 10 |
| Ace | 1 |
| Everything else | Face value |
A blocked round is scored the same way — everyone still holding cards pays for
them.
Lowest total after the last round wins — **toggle** for 1, 3 or 5 rounds.
## What the computer opponent does
It plays the most expensive card it can get rid of, saves its eights until the
endgame or until it has nothing else, and when it does play one it names the
suit it holds most of.
+78
View File
@@ -0,0 +1,78 @@
# Kaiser — implemented rules
Kaiser is the prairie game, played hard in Saskatchewan and not much anywhere
else. This is the ruleset the engine enforces. Anything marked **toggle** is in
the game setup screen.
## Table
Four players in two partnerships, sitting opposite. Seats 0 and 2 are one team,
seats 1 and 3 the other.
## The deck — 32 cards
Eight through ace in every suit, plus:
- the **seven of clubs** and the **seven of diamonds**, which are ordinary cards
making up the count, and
- the **five of hearts** and the **three of spades**, which replace the other
two sevens and are what the whole game is played for.
Eight cards each, and the deck is exhausted.
## What a hand is worth
Ten points, always:
| Source | Points |
| --- | --- |
| Each of the eight tricks | 1 |
| Taking the five of hearts | +5 |
| Taking the three of spades | −3 |
So a trick containing the five of hearts is worth six, and one containing the
three of spades is worth minus two. Both in the same trick is worth three.
## Bidding
Bidding opens to the dealer's left and goes round. Each bid is a number of
points the bidder's side undertakes to take.
- The floor is **5, 6, 7 or 8** — **toggle**, six by default.
- The ceiling is ten, because ten is the most a hand can be worth. Bidding
above what exists is only a way to guarantee going down, so it is not offered.
- **No trump** — **toggle**, on by default. A no-trump bid outranks the same
number bid with a suit; it pays double and costs double.
- Each bid must beat the one before it.
- If the first three players pass, the dealer must bid — there is no throwing
the hand in from the dealer's seat. If all four pass (only possible when the
dealer's forced bid is somehow not reached), the hand is redealt.
The winning bidder then names trump, or is committed to no trump if that is what
they bid.
## Play
The bidder leads. Follow suit if you can; otherwise play anything.
There are no bowers — Kaiser ranks straight down from the ace, so the jack is
just a jack. Trump beats every other suit; otherwise only the led suit can win.
## Scoring
- **Bidding side made it** (took at least the bid): they score every point they
took, doubled if the contract was no trump.
- **Bidding side went down**: they lose the amount of the bid, doubled if it was
no trump.
- **Defenders always keep what they took**, made or not.
First side to the target wins — **toggle** for 32, 52 or 62 points. A side that
falls to the matching negative score loses outright; without that floor a table
that keeps bidding and keeps going down never reaches a result at all.
## What the computer opponent does
It rates each suit by trump length and top cards, adds for holding the five of
hearts and subtracts for holding the three of spades, and bids the floor rather
than reaching. In play it chases the five of hearts, ducks when the three of
spades is on the table, and lets its partner have a trick it is already winning.
+81
View File
@@ -0,0 +1,81 @@
# Wizard — implemented rules
This is the ruleset the engine enforces. Anything marked **toggle** is in the
game setup screen.
## Table
- 3 to 6 players — **toggle**. Everyone plays for themselves.
## The deck — 60 cards
An ordinary 52-card pack plus **four wizards** and **four jesters**.
## Rounds
Round 1 deals one card each, round 2 deals two, and so on until the pack runs
out. That is 20 rounds with three players, 15 with four, 12 with five and 10
with six. **Toggle** to stop after 5 or 10 rounds instead of playing the lot.
After the deal, the next card off the pack is turned for trump:
- an ordinary card — its suit is trump;
- a **jester** — the round is played with no trump;
- a **wizard** — the dealer names trump before anyone bids.
In the last round the whole pack has been dealt, so there is no card to turn and
no trump.
## Bidding
Starting to the dealer's left, each player says exactly how many tricks they
will take, from none up to the number of cards in hand. Every bid is heard, so
the later you bid the more you know.
**Screw the dealer — *toggle*, off by default.** The dealer may not name the
number that would make the bids add up to the tricks available, so at least one
player at the table is always going to be wrong.
## Play
The player to the dealer's left leads. Follow the led suit if you hold it —
except that a **wizard or a jester may always be played**, whatever you hold.
Playing one is how you get out of following.
**What suit is led:**
- an ordinary card leads its suit;
- a **jester** leads nothing — the suit is set by the next ordinary card;
- a **wizard** reaching the front means no suit is led at all, and everyone may
play whatever they like.
**Who takes the trick:**
1. The **first wizard** played takes it, whatever else is down.
2. Otherwise the highest trump.
3. Otherwise the highest card of the led suit.
4. A **jester** never wins — except a trick of nothing but jesters, which goes
to the first one played.
## Scoring
| Result | Score |
| --- | --- |
| Bid made exactly | 20, plus 10 for each trick taken |
| Bid missed | −10 for every trick over or under |
So bidding none and taking none is worth 20; bidding three and taking three is
worth 50; bidding three and taking one costs 20.
Highest total after the last round wins.
## What the computer opponent does
It counts its expected tricks — a wizard is one, a jester is none, and
everything else is a fraction depending on how high it is and whether it is
trump — and bids the nearest number still on offer. In play it chases tricks
while it is short of its bid, taking them as cheaply as it can, and ducks once
it has them, leading a jester when it has one to spare.
Over long runs of self-play it lands its bid exactly around half the time,
against about one in six for random legal play.