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
@@ -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")
}
},
)
}