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
@@ -55,6 +55,9 @@ class CatalogTest {
GameCatalog.SEQUENCE,
GameCatalog.PRESIDENT,
GameCatalog.GOLF,
GameCatalog.KAISER,
GameCatalog.CRAZY_EIGHTS,
GameCatalog.WIZARD,
),
GameCatalog.playable.map { it.id }.toSet(),
)
@@ -0,0 +1,337 @@
package org.prolibertate.games.game.crazyeights
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
import kotlin.random.Random
class CrazyEightsRulesTest {
private val json = Json { encodeDefaults = true }
private fun config(
options: CrazyEightsOptions = CrazyEightsOptions(),
seed: Long = 21L,
) = TableConfig(
gameId = "crazy8s",
seats = (0 until options.playerCount).map {
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
},
optionsJson = json.encodeToString(options),
seed = seed,
)
private fun c(rank: Rank, suit: Suit) = Card(rank, suit)
private fun playingState(
hands: List<List<Card>>,
top: Card,
suitInForce: Suit = top.suit,
stock: List<Card> = emptyList(),
turn: Int = 0,
options: CrazyEightsOptions = CrazyEightsOptions(playerCount = hands.size),
drawnThisTurn: Int = 0,
) = CrazyEightsState(
options = options,
seed = 1L,
roundNumber = 0,
hands = hands,
handCounts = hands.map { it.size },
stock = stock,
discard = listOf(top),
suitInForce = suitInForce,
turn = turn,
drawnThisTurn = drawnThisTurn,
scores = List(hands.size) { 0 },
roundWinner = null,
phase = CrazyEightsPhase.PLAYING,
log = emptyList(),
)
// -- Matching -----------------------------------------------------------
@Test
fun `a card matches on suit or on rank`() {
val top = c(Rank.SEVEN, Suit.HEARTS)
assertTrue("same suit", canPlay(c(Rank.KING, Suit.HEARTS), top, Suit.HEARTS))
assertTrue("same rank", canPlay(c(Rank.SEVEN, Suit.CLUBS), top, Suit.HEARTS))
assertFalse("neither", canPlay(c(Rank.KING, Suit.CLUBS), top, Suit.HEARTS))
}
@Test
fun `an eight always plays`() {
val top = c(Rank.SEVEN, Suit.HEARTS)
Suit.entries.forEach { suit ->
assertTrue(canPlay(c(Rank.EIGHT, suit), top, Suit.HEARTS))
}
}
@Test
fun `the suit in force overrides the top card`() {
// An eight was played on a heart and clubs were called.
val top = c(Rank.EIGHT, Suit.HEARTS)
assertTrue(canPlay(c(Rank.TWO, Suit.CLUBS), top, Suit.CLUBS))
assertFalse("hearts no longer run", canPlay(c(Rank.TWO, Suit.HEARTS), top, Suit.CLUBS))
}
// -- Legal moves --------------------------------------------------------
@Test
fun `playing an eight offers a choice of suit`() {
val hand = listOf(c(Rank.EIGHT, Suit.SPADES))
val state = playingState(listOf(hand, emptyList()), top = c(Rank.THREE, Suit.HEARTS))
val moves = CrazyEightsRules.legalMoves(state, 0).filterIsInstance<PlayCard>()
assertEquals("one move per suit", 4, moves.size)
assertEquals(Suit.entries.toSet(), moves.mapNotNull { it.nominatedSuit }.toSet())
}
@Test
fun `you must play when you can`() {
val hand = listOf(c(Rank.KING, Suit.HEARTS), c(Rank.TWO, Suit.CLUBS))
val state = playingState(
hands = listOf(hand, emptyList()),
top = c(Rank.THREE, Suit.HEARTS),
stock = listOf(c(Rank.NINE, Suit.SPADES)),
)
val moves = CrazyEightsRules.legalMoves(state, 0)
assertTrue("no drawing your way out of a playable hand", moves.none { it is DrawCard })
assertEquals(1, moves.size)
}
@Test
fun `with nothing playable you draw`() {
val hand = listOf(c(Rank.KING, Suit.CLUBS))
val state = playingState(
hands = listOf(hand, emptyList()),
top = c(Rank.THREE, Suit.HEARTS),
stock = listOf(c(Rank.NINE, Suit.SPADES)),
)
assertEquals(listOf(DrawCard), CrazyEightsRules.legalMoves(state, 0))
}
@Test
fun `with an empty stock and nothing playable you pass`() {
val hand = listOf(c(Rank.KING, Suit.CLUBS))
val state = playingState(
hands = listOf(hand, emptyList()),
top = c(Rank.THREE, Suit.HEARTS),
stock = emptyList(),
)
assertEquals(listOf(PassTurn), CrazyEightsRules.legalMoves(state, 0))
}
@Test
fun `draw one and pass stops after a single card`() {
val options = CrazyEightsOptions(playerCount = 2, drawUntilPlayable = false)
val hand = listOf(c(Rank.KING, Suit.CLUBS))
val state = playingState(
hands = listOf(hand, emptyList()),
top = c(Rank.THREE, Suit.HEARTS),
stock = listOf(c(Rank.NINE, Suit.SPADES), c(Rank.TEN, Suit.SPADES)),
options = options,
drawnThisTurn = 1,
)
assertEquals("already drawn, so pass", listOf(PassTurn), CrazyEightsRules.legalMoves(state, 0))
}
// -- Play ---------------------------------------------------------------
@Test
fun `an eight sets the suit in force`() {
val hand = listOf(c(Rank.EIGHT, Suit.SPADES), c(Rank.TWO, Suit.CLUBS))
var state = playingState(listOf(hand, listOf(c(Rank.FIVE, Suit.CLUBS))),
top = c(Rank.THREE, Suit.HEARTS))
state = CrazyEightsRules.applyMove(
state, 0, PlayCard(c(Rank.EIGHT, Suit.SPADES), Suit.DIAMONDS)
)
assertEquals(Suit.DIAMONDS, state.suitInForce)
assertEquals(c(Rank.EIGHT, Suit.SPADES), state.topCard)
assertEquals(1, state.turn)
}
@Test
fun `drawing keeps the turn`() {
val state = playingState(
hands = listOf(listOf(c(Rank.KING, Suit.CLUBS)), emptyList()),
top = c(Rank.THREE, Suit.HEARTS),
stock = listOf(c(Rank.NINE, Suit.SPADES)),
)
val after = CrazyEightsRules.applyMove(state, 0, DrawCard)
assertEquals("still your turn", 0, after.turn)
assertEquals(2, after.hands[0].size)
assertEquals(1, after.drawnThisTurn)
}
@Test
fun `an exhausted stock is refilled from the discards`() {
val state = playingState(
hands = listOf(listOf(c(Rank.KING, Suit.CLUBS)), emptyList()),
top = c(Rank.THREE, Suit.HEARTS),
stock = emptyList(),
).copy(discard = listOf(c(Rank.FOUR, Suit.SPADES), c(Rank.THREE, Suit.HEARTS)))
val after = CrazyEightsRules.applyMove(state, 0, DrawCard)
assertEquals("the card in play stays put", c(Rank.THREE, Suit.HEARTS), after.topCard)
assertEquals("and the rest became stock", 2, after.hands[0].size)
}
// -- Scoring ------------------------------------------------------------
@Test
fun `penalties charge eights the most`() {
assertEquals(50, crazyEightsPenalty(c(Rank.EIGHT, Suit.CLUBS)))
assertEquals(10, crazyEightsPenalty(c(Rank.KING, Suit.CLUBS)))
assertEquals(10, crazyEightsPenalty(c(Rank.TEN, Suit.CLUBS)))
assertEquals(1, crazyEightsPenalty(c(Rank.ACE, Suit.CLUBS)))
assertEquals(5, crazyEightsPenalty(c(Rank.FIVE, Suit.CLUBS)))
}
@Test
fun `going out charges everyone else for what they hold`() {
val options = CrazyEightsOptions(playerCount = 3, roundsToPlay = 2)
var state = playingState(
hands = listOf(
listOf(c(Rank.THREE, Suit.HEARTS)),
listOf(c(Rank.EIGHT, Suit.CLUBS), c(Rank.ACE, Suit.SPADES)),
listOf(c(Rank.KING, Suit.SPADES)),
),
top = c(Rank.FOUR, Suit.HEARTS),
options = options,
)
state = CrazyEightsRules.applyMove(state, 0, PlayCard(c(Rank.THREE, Suit.HEARTS)))
assertEquals(CrazyEightsPhase.ROUND_OVER, state.phase)
assertEquals(0, state.roundWinner)
assertEquals("the winner is charged nothing", 0, state.scores[0])
assertEquals("an eight and an ace", 51, state.scores[1])
assertEquals(10, state.scores[2])
}
@Test
fun `the last round ends the game`() {
val options = CrazyEightsOptions(playerCount = 2, roundsToPlay = 1)
var state = playingState(
hands = listOf(listOf(c(Rank.THREE, Suit.HEARTS)), listOf(c(Rank.KING, Suit.SPADES))),
top = c(Rank.FOUR, Suit.HEARTS),
options = options,
)
state = CrazyEightsRules.applyMove(state, 0, PlayCard(c(Rank.THREE, Suit.HEARTS)))
assertTrue(CrazyEightsRules.isFinished(state))
}
// -- Dealing ------------------------------------------------------------
@Test
fun `two players get seven cards and everyone else five`() {
assertEquals(7, CrazyEightsOptions(playerCount = 2).handSize())
assertEquals(5, CrazyEightsOptions(playerCount = 4).handSize())
assertEquals(9, CrazyEightsOptions(playerCount = 4, startingHand = 9).handSize())
}
@Test
fun `the opening card is never an eight`() {
// An eight on top would leave the suit in force unnamed.
repeat(30) { seed ->
val state = CrazyEightsRules.initialState(config(seed = seed.toLong()))
assertFalse("seed $seed dealt a wild upcard", isWild(state.topCard!!))
assertEquals(state.topCard!!.suit, state.suitInForce)
}
}
// -- Full games ---------------------------------------------------------
@Test
fun `random legal play always finishes`() {
repeat(20) { iteration ->
val random = Random(iteration.toLong())
var state = CrazyEightsRules.initialState(
config(CrazyEightsOptions(playerCount = 4, roundsToPlay = 2), iteration.toLong())
)
var guard = 0
while (!CrazyEightsRules.isFinished(state) && guard++ < 8000) {
val seat = CrazyEightsRules.currentSeat(state)
if (seat == null) {
state = CrazyEightsRules.nextRound(state)
continue
}
val legal = CrazyEightsRules.legalMoves(state, seat)
assertTrue("seat $seat had no legal move", legal.isNotEmpty())
state = CrazyEightsRules.applyMove(state, seat, legal[random.nextInt(legal.size)])
}
assertTrue("game $iteration did not finish", CrazyEightsRules.isFinished(state))
}
}
@Test
fun `ai play always finishes and beats random play`() {
val ai = CrazyEightsAi()
var aiTotal = 0
var randomTotal = 0
repeat(12) { iteration ->
val random = Random(3000L + iteration)
var state = CrazyEightsRules.initialState(
config(CrazyEightsOptions(playerCount = 2, roundsToPlay = 3), 800L + iteration)
)
var guard = 0
while (!CrazyEightsRules.isFinished(state) && guard++ < 8000) {
val seat = CrazyEightsRules.currentSeat(state)
if (seat == null) {
state = CrazyEightsRules.nextRound(state)
continue
}
val legal = CrazyEightsRules.legalMoves(state, seat)
val move = if (seat == 0) {
ai.chooseMove(state, seat, legal)
} else {
legal[random.nextInt(legal.size)]
}
assertTrue("ai returned an illegal move $move", legal.contains(move))
state = CrazyEightsRules.applyMove(state, seat, move)
}
assertTrue(CrazyEightsRules.isFinished(state))
aiTotal += state.scores[0]
randomTotal += state.scores[1]
}
// Penalty points, so lower is better.
assertTrue(
"ai took $aiTotal against random's $randomTotal — no better than flailing",
aiTotal < randomTotal,
)
}
// -- Redaction and wire format -----------------------------------------
@Test
fun `a seat view hides other hands and the stock`() {
val state = CrazyEightsRules.initialState(config())
val view = CrazyEightsRules.viewFor(state, seat = 2)
assertEquals(state.hands[2], view.hands[2])
assertTrue(view.hands.filterIndexed { i, _ -> i != 2 }.all { it.isEmpty() })
assertTrue(view.stock.isEmpty())
assertEquals(state.handCounts, view.handCounts)
}
@Test
fun `state and moves survive a round trip`() {
val state = CrazyEightsRules.initialState(config())
assertEquals(state, CrazyEightsRules.decodeState(CrazyEightsRules.encodeState(state)))
listOf(
DrawCard,
PassTurn,
PlayCard(c(Rank.KING, Suit.HEARTS)),
PlayCard(c(Rank.EIGHT, Suit.CLUBS), Suit.DIAMONDS),
).forEach {
assertEquals(it, CrazyEightsRules.decodeMove(CrazyEightsRules.encodeMove(it)))
}
}
}
@@ -0,0 +1,369 @@
package org.prolibertate.games.game.kaiser
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Decks
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
import kotlin.random.Random
class KaiserRulesTest {
private val json = Json { encodeDefaults = true }
private fun config(options: KaiserOptions = KaiserOptions(), seed: Long = 5L) = TableConfig(
gameId = "kaiser",
seats = (0 until 4).map {
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it % 2)
},
optionsJson = json.encodeToString(options),
seed = seed,
)
// -- The deck -----------------------------------------------------------
@Test
fun `the deck is thirty-two cards with the two counters in it`() {
val deck = Decks.kaiser()
assertEquals(32, deck.size)
assertEquals("no duplicates", 32, deck.distinct().size)
assertTrue("five of hearts", deck.contains(FIVE_OF_HEARTS))
assertTrue("three of spades", deck.contains(THREE_OF_SPADES))
assertTrue("seven of clubs stays", deck.contains(Card(Rank.SEVEN, Suit.CLUBS)))
assertTrue("seven of diamonds stays", deck.contains(Card(Rank.SEVEN, Suit.DIAMONDS)))
assertFalse("but not the seven of hearts", deck.contains(Card(Rank.SEVEN, Suit.HEARTS)))
assertFalse("nor the seven of spades", deck.contains(Card(Rank.SEVEN, Suit.SPADES)))
assertTrue("nothing below the counters", deck.none {
it.rank.order < Rank.SEVEN.order && it != FIVE_OF_HEARTS && it != THREE_OF_SPADES
})
}
@Test
fun `a hand is worth ten points in total`() {
// Eight tricks at one each, plus five, less three.
val deck = Decks.kaiser()
val allTricks = deck.chunked(4)
assertEquals(8, allTricks.size)
assertEquals(10, allTricks.sumOf { trickValue(it) })
}
@Test
fun `the counters move a tricks value`() {
val plain = listOf(Card(Rank.ACE, Suit.CLUBS), Card(Rank.KING, Suit.CLUBS))
assertEquals(1, trickValue(plain))
assertEquals(6, trickValue(plain + FIVE_OF_HEARTS))
assertEquals(-2, trickValue(plain + THREE_OF_SPADES))
assertEquals(3, trickValue(plain + FIVE_OF_HEARTS + THREE_OF_SPADES))
}
// -- Bidding ------------------------------------------------------------
@Test
fun `everyone gets eight cards and bidding opens left of the dealer`() {
val state = KaiserRules.initialState(config())
assertEquals(KaiserPhase.BIDDING, state.phase)
state.hands.forEach { assertEquals(8, it.size) }
assertEquals((state.dealer + 1) % 4, state.turn)
}
@Test
fun `a bid must beat the one before it`() {
var state = KaiserRules.initialState(config(KaiserOptions(minimumBid = 6)))
val opener = state.turn
state = KaiserRules.applyMove(state, opener, MakeBid(Bid(7, noTrump = false)))
val moves = KaiserRules.legalMoves(state, state.turn).filterIsInstance<MakeBid>()
assertTrue("nothing at or below seven in suit",
moves.none { !it.bid.noTrump && it.bid.points <= 7 })
assertTrue("but seven no trump beats seven",
moves.any { it.bid.noTrump && it.bid.points == 7 })
}
@Test
fun `no trump outranks the same number`() {
assertTrue(Bid(7, noTrump = true).beats(Bid(7, noTrump = false)))
assertFalse(Bid(7, noTrump = false).beats(Bid(7, noTrump = true)))
assertTrue(Bid(8, noTrump = false).beats(Bid(7, noTrump = true)))
}
@Test
fun `the bidding floor is respected`() {
val state = KaiserRules.initialState(config(KaiserOptions(minimumBid = 8)))
val moves = KaiserRules.legalMoves(state, state.turn).filterIsInstance<MakeBid>()
assertTrue(moves.all { it.bid.points >= 8 })
}
@Test
fun `the dealer is stuck when everyone passes`() {
var state = KaiserRules.initialState(config())
repeat(3) { state = KaiserRules.applyMove(state, state.turn, PassBid) }
assertEquals(state.dealer, state.turn)
val moves = KaiserRules.legalMoves(state, state.dealer)
assertFalse("the dealer has to bid", moves.contains(PassBid))
assertTrue(moves.all { it is MakeBid })
}
@Test
fun `the winning bidder names trump and leads`() {
var state = KaiserRules.initialState(config())
val bidder = state.turn
state = KaiserRules.applyMove(state, bidder, MakeBid(Bid(6, noTrump = false)))
repeat(3) { state = KaiserRules.applyMove(state, state.turn, PassBid) }
assertEquals("back to the bidder", bidder, state.turn)
val naming = KaiserRules.legalMoves(state, bidder).filterIsInstance<NameTrump>()
assertEquals("a choice of four suits", 4, naming.size)
state = KaiserRules.applyMove(state, bidder, NameTrump(Suit.SPADES))
assertEquals(KaiserPhase.PLAYING, state.phase)
assertEquals(Suit.SPADES, state.trump)
assertEquals("and leads", bidder, state.turn)
}
@Test
fun `a no trump bid commits to no trump`() {
var state = KaiserRules.initialState(config())
val bidder = state.turn
state = KaiserRules.applyMove(state, bidder, MakeBid(Bid(6, noTrump = true)))
repeat(3) { state = KaiserRules.applyMove(state, state.turn, PassBid) }
val naming = KaiserRules.legalMoves(state, bidder).filterIsInstance<NameTrump>()
assertEquals(listOf(NameTrump(null)), naming)
state = KaiserRules.applyMove(state, bidder, NameTrump(null))
assertEquals(null, state.trump)
assertEquals(KaiserPhase.PLAYING, state.phase)
}
// -- Play ---------------------------------------------------------------
private fun playingState(
hands: List<List<Card>>,
trump: Suit?,
bid: Bid = Bid(6, noTrump = false),
bidder: Int = 0,
turn: Int = 0,
) = KaiserState(
options = KaiserOptions(),
seed = 1L,
handNumber = 0,
dealer = 3,
phase = KaiserPhase.PLAYING,
hands = hands,
handCounts = hands.map { it.size },
turn = turn,
passes = 3,
highBid = bid,
highBidder = bidder,
trump = trump,
trick = emptyList(),
completedTrick = emptyList(),
leader = turn,
lastTrickWinner = null,
handPoints = listOf(0, 0),
scores = listOf(0, 0),
log = emptyList(),
)
@Test
fun `you must follow suit`() {
val hand = listOf(Card(Rank.NINE, Suit.HEARTS), Card(Rank.ACE, Suit.CLUBS))
val state = playingState(
hands = listOf(emptyList(), hand, emptyList(), emptyList()),
trump = Suit.SPADES,
turn = 1,
).copy(trick = listOf(PlayedCard(0, Card(Rank.KING, Suit.HEARTS))))
assertEquals(listOf(Card(Rank.NINE, Suit.HEARTS)), KaiserRules.playableCards(state, 1))
}
@Test
fun `trump beats the led suit and there are no bowers`() {
val trump = Suit.SPADES
assertTrue(
trickStrength(Card(Rank.EIGHT, trump), trump, Suit.HEARTS) >
trickStrength(Card(Rank.ACE, Suit.HEARTS), trump, Suit.HEARTS)
)
// The jack is just a jack here.
assertTrue(
trickStrength(Card(Rank.ACE, trump), trump, trump) >
trickStrength(Card(Rank.JACK, trump), trump, trump)
)
}
@Test
fun `taking the five of hearts is worth five`() {
val hands = listOf(
listOf(Card(Rank.ACE, Suit.CLUBS)),
listOf(FIVE_OF_HEARTS),
listOf(Card(Rank.EIGHT, Suit.CLUBS)),
listOf(Card(Rank.NINE, Suit.CLUBS)),
)
var state = playingState(hands, trump = Suit.SPADES)
state = KaiserRules.applyMove(state, 0, PlayCard(Card(Rank.ACE, Suit.CLUBS)))
state = KaiserRules.applyMove(state, 1, PlayCard(FIVE_OF_HEARTS))
state = KaiserRules.applyMove(state, 2, PlayCard(Card(Rank.EIGHT, Suit.CLUBS)))
state = KaiserRules.applyMove(state, 3, PlayCard(Card(Rank.NINE, Suit.CLUBS)))
assertEquals("seat 0 took it", 0, state.lastTrickWinner)
assertEquals("one for the trick plus five", 6, state.handPoints[0])
}
@Test
fun `taking the three of spades costs three`() {
val hands = listOf(
listOf(Card(Rank.ACE, Suit.CLUBS)),
listOf(THREE_OF_SPADES),
listOf(Card(Rank.EIGHT, Suit.CLUBS)),
listOf(Card(Rank.NINE, Suit.CLUBS)),
)
var state = playingState(hands, trump = Suit.HEARTS)
state = KaiserRules.applyMove(state, 0, PlayCard(Card(Rank.ACE, Suit.CLUBS)))
state = KaiserRules.applyMove(state, 1, PlayCard(THREE_OF_SPADES))
state = KaiserRules.applyMove(state, 2, PlayCard(Card(Rank.EIGHT, Suit.CLUBS)))
state = KaiserRules.applyMove(state, 3, PlayCard(Card(Rank.NINE, Suit.CLUBS)))
assertEquals(0, state.lastTrickWinner)
assertEquals("one for the trick less three", -2, state.handPoints[0])
}
@Test
fun `a finished trick is held on the table`() {
val hands = listOf(
listOf(Card(Rank.ACE, Suit.CLUBS), Card(Rank.KING, Suit.CLUBS)),
listOf(Card(Rank.EIGHT, Suit.CLUBS), Card(Rank.NINE, Suit.CLUBS)),
listOf(Card(Rank.TEN, Suit.CLUBS), Card(Rank.QUEEN, Suit.CLUBS)),
listOf(Card(Rank.JACK, Suit.CLUBS), Card(Rank.EIGHT, Suit.HEARTS)),
)
var state = playingState(hands, trump = Suit.SPADES)
repeat(4) {
val seat = KaiserRules.currentSeat(state)!!
state = KaiserRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
}
assertTrue(state.trick.isEmpty())
assertEquals("all four held for display", 4, state.completedTrick.size)
}
// -- Scoring ------------------------------------------------------------
@Test
fun `making the bid scores what was taken and going down costs the bid`() {
val made = scoreOneHand(bid = Bid(6, noTrump = false), bidderTakesAll = true)
assertTrue("made contract scores", made.scores[0] > 0)
val down = scoreOneHand(bid = Bid(10, noTrump = false), bidderTakesAll = false)
assertEquals("going down costs the bid", -10, down.scores[0])
}
/** Plays a whole hand where one team either wins every trick or none. */
private fun scoreOneHand(bid: Bid, bidderTakesAll: Boolean): KaiserState {
val strong = Suit.SPADES
// Team 0 holds every spade; team 1 holds none.
val hands = listOf(
(0 until 8).map { Card(Rank.entries.filter { r -> r.order >= Rank.EIGHT.order }[it % 7], strong) },
List(8) { Card(Rank.EIGHT, Suit.HEARTS) },
List(8) { Card(Rank.NINE, Suit.HEARTS) },
List(8) { Card(Rank.TEN, Suit.HEARTS) },
)
var state = playingState(hands, trump = strong, bid = bid, bidder = if (bidderTakesAll) 0 else 0)
if (!bidderTakesAll) {
// Give the spades to the other side instead.
state = state.copy(
hands = listOf(hands[1], hands[0], hands[2], hands[3]),
handCounts = hands.map { it.size },
)
}
var guard = 0
while (state.phase == KaiserPhase.PLAYING && guard++ < 100) {
val seat = KaiserRules.currentSeat(state)!!
val legal = KaiserRules.legalMoves(state, seat)
state = KaiserRules.applyMove(state, seat, legal.first())
}
return state
}
@Test
fun `defenders keep what they take`() {
var state = KaiserRules.initialState(config())
var guard = 0
while (state.phase != KaiserPhase.HAND_OVER && !KaiserRules.isFinished(state) && guard++ < 500) {
val seat = KaiserRules.currentSeat(state) ?: break
val legal = KaiserRules.legalMoves(state, seat)
state = KaiserRules.applyMove(state, seat, legal.first())
}
assertTrue("a hand was scored", state.scores.any { it != 0 } || state.handPoints.any { it != 0 })
}
// -- Full games ---------------------------------------------------------
@Test
fun `random legal play always reaches a winner`() {
repeat(15) { iteration ->
val random = Random(iteration.toLong())
var state = KaiserRules.initialState(config(seed = iteration.toLong()))
var guard = 0
while (!KaiserRules.isFinished(state) && guard++ < 8000) {
val seat = KaiserRules.currentSeat(state)
if (seat == null) {
state = KaiserRules.nextHand(state)
continue
}
val legal = KaiserRules.legalMoves(state, seat)
assertTrue("seat $seat had no legal move in ${state.phase}", legal.isNotEmpty())
state = KaiserRules.applyMove(state, seat, legal[random.nextInt(legal.size)])
}
assertTrue("game $iteration did not finish", KaiserRules.isFinished(state))
}
}
@Test
fun `ai play always reaches a winner`() {
val ai = KaiserAi()
repeat(8) { iteration ->
var state = KaiserRules.initialState(config(seed = 600L + iteration))
var guard = 0
while (!KaiserRules.isFinished(state) && guard++ < 8000) {
val seat = KaiserRules.currentSeat(state)
if (seat == null) {
state = KaiserRules.nextHand(state)
continue
}
val legal = KaiserRules.legalMoves(state, seat)
val move = ai.chooseMove(state, seat, legal)
assertTrue("ai returned an illegal move $move", legal.contains(move))
state = KaiserRules.applyMove(state, seat, move)
}
assertTrue("ai game did not finish", KaiserRules.isFinished(state))
}
}
// -- Redaction and wire format -----------------------------------------
@Test
fun `a seat view hides every other hand`() {
val state = KaiserRules.initialState(config())
val view = KaiserRules.viewFor(state, seat = 3)
assertEquals(state.hands[3], view.hands[3])
assertTrue(view.hands.filterIndexed { i, _ -> i != 3 }.all { it.isEmpty() })
assertEquals(listOf(8, 8, 8, 8), view.handCounts)
}
@Test
fun `state and moves survive a round trip`() {
val state = KaiserRules.initialState(config())
assertEquals(state, KaiserRules.decodeState(KaiserRules.encodeState(state)))
listOf(
PassBid,
MakeBid(Bid(7, noTrump = true)),
NameTrump(Suit.HEARTS),
NameTrump(null),
PlayCard(FIVE_OF_HEARTS),
).forEach { assertEquals(it, KaiserRules.decodeMove(KaiserRules.encodeMove(it))) }
}
}
@@ -64,7 +64,7 @@ class SequenceRulesTest {
@Test
fun `every non-jack card maps to exactly two squares`() {
val nonJacks = Suit.entries.flatMap { suit ->
Rank.entries.filter { it != Rank.JACK }.map { Card(it, suit) }
Rank.standard.filter { it != Rank.JACK }.map { Card(it, suit) }
}
nonJacks.forEach { card ->
assertEquals("$card", 2, SequenceBoard.squaresByCard[card]?.size)
@@ -0,0 +1,471 @@
package org.prolibertate.games.game.wizard
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Decks
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
import kotlin.random.Random
class WizardRulesTest {
private val json = Json { encodeDefaults = true }
private fun config(options: WizardOptions = WizardOptions(), seed: Long = 11L) = TableConfig(
gameId = "wizard",
seats = (0 until options.playerCount).map {
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
},
optionsJson = json.encodeToString(options),
seed = seed,
)
private fun wizardOf(suit: Suit) = Card(Rank.WIZARD, suit)
private fun jesterOf(suit: Suit) = Card(Rank.JESTER, suit)
// -- The deck -----------------------------------------------------------
@Test
fun `the deck is sixty cards with four wizards and four jesters`() {
val deck = Decks.wizard()
assertEquals(60, deck.size)
assertEquals("no duplicates", 60, deck.distinct().size)
assertEquals(4, deck.count { isWizardCard(it) })
assertEquals(4, deck.count { isJester(it) })
assertEquals(52, deck.count { it.rank.standard })
}
@Test
fun `the whole deck is dealt out over the rounds`() {
assertEquals(20, WizardOptions(playerCount = 3).totalRounds())
assertEquals(15, WizardOptions(playerCount = 4).totalRounds())
assertEquals(12, WizardOptions(playerCount = 5).totalRounds())
assertEquals(10, WizardOptions(playerCount = 6).totalRounds())
// A short game stops early rather than inventing extra rounds.
assertEquals(5, WizardOptions(playerCount = 4, rounds = 5).totalRounds())
assertEquals(15, WizardOptions(playerCount = 4, rounds = 99).totalRounds())
}
// -- Dealing and trump --------------------------------------------------
@Test
fun `round one deals a single card each and turns one for trump`() {
val state = WizardRules.initialState(config())
assertEquals(WizardPhase.BIDDING, state.phase)
state.hands.forEach { assertEquals(1, it.size) }
assertEquals(1, state.cardsThisRound)
assertEquals("something was turned", true, state.trumpCard != null)
}
@Test
fun `the last round turns nothing and is played without trump`() {
// Four players, fifteen rounds: the last one uses every card in the deck.
var state = WizardRules.initialState(config())
var guard = 0
while (state.round < 14 && guard++ < 40) {
state = playRoundOut(state)
if (state.phase == WizardPhase.ROUND_OVER) state = WizardRules.nextRound(state)
}
assertEquals(14, state.round)
assertEquals(15, state.hands[0].size)
assertNull("nothing left to turn", state.trumpCard)
assertNull("so there is no trump", state.trump)
}
@Test
fun `a turned jester means no trump`() {
// Search seeds until one turns a jester rather than asserting on luck.
val seed = (1L..400L).firstOrNull { s ->
val state = WizardRules.initialState(config(seed = s))
state.trumpCard?.let { isJester(it) } == true
}
requireNotNull(seed) { "no seed in range turned a jester" }
val state = WizardRules.initialState(config(seed = seed))
assertNull(state.trump)
assertTrue("bidding starts left of the dealer", state.turn == (state.dealer + 1) % 4)
}
@Test
fun `a turned wizard hands the dealer the choice of trump`() {
val seed = (1L..400L).firstOrNull { s ->
val state = WizardRules.initialState(config(seed = s))
state.trumpCard?.let { isWizardCard(it) } == true
}
requireNotNull(seed) { "no seed in range turned a wizard" }
var state = WizardRules.initialState(config(seed = seed))
assertNull("trump is not set yet", state.trump)
assertEquals("the dealer is on turn", state.dealer, state.turn)
val choices = WizardRules.legalMoves(state, state.dealer)
assertEquals(Suit.entries.map { ChooseTrump(it) }, choices)
state = WizardRules.applyMove(state, state.dealer, ChooseTrump(Suit.HEARTS))
assertEquals(Suit.HEARTS, state.trump)
assertEquals("then bidding opens on the left", (state.dealer + 1) % 4, state.turn)
assertTrue(WizardRules.legalMoves(state, state.turn).all { it is MakeBid })
}
// -- Bidding ------------------------------------------------------------
@Test
fun `you may bid anything from zero up to the tricks available`() {
val state = WizardRules.initialState(config())
val bids = WizardRules.legalMoves(state, state.turn).filterIsInstance<MakeBid>()
assertEquals(listOf(0, 1), bids.map { it.tricks })
}
@Test
fun `screw the dealer forbids the bid that would balance the book`() {
var state = WizardRules.initialState(
config(WizardOptions(screwTheDealer = true, rounds = 4), seed = 3L)
)
// Get to a round with room to bid, then walk round to the dealer.
while (state.cardsThisRound < 3) {
state = playRoundOut(state)
state = WizardRules.nextRound(state)
}
if (state.trumpCard?.let { isWizardCard(it) } == true) {
state = WizardRules.applyMove(state, state.dealer, ChooseTrump(Suit.SPADES))
}
val available = state.cardsThisRound
var bidSoFar = 0
while (state.turn != state.dealer) {
val seat = state.turn
state = WizardRules.applyMove(state, seat, MakeBid(1))
bidSoFar += 1
}
val dealerBids = WizardRules.legalMoves(state, state.dealer)
.filterIsInstance<MakeBid>().map { it.tricks }
assertFalse("the balancing bid is off the table", dealerBids.contains(available - bidSoFar))
assertEquals(available, dealerBids.size) // one fewer than the 0..available range
}
@Test
fun `play starts once everyone has bid`() {
var state = WizardRules.initialState(config())
if (state.trumpCard?.let { isWizardCard(it) } == true) {
state = WizardRules.applyMove(state, state.dealer, ChooseTrump(Suit.SPADES))
}
repeat(4) { state = WizardRules.applyMove(state, state.turn, MakeBid(0)) }
assertEquals(WizardPhase.PLAYING, state.phase)
assertEquals("the player left of the dealer leads", (state.dealer + 1) % 4, state.turn)
}
// -- Trick ranking ------------------------------------------------------
@Test
fun `a wizard beats everything including trump`() {
val trump = Suit.SPADES
assertTrue(
trickStrength(wizardOf(Suit.CLUBS), 1, trump, Suit.HEARTS) >
trickStrength(Card(Rank.ACE, trump), 0, trump, Suit.HEARTS)
)
}
@Test
fun `the first wizard down takes the trick`() {
assertTrue(
trickStrength(wizardOf(Suit.CLUBS), 0, Suit.SPADES, null) >
trickStrength(wizardOf(Suit.HEARTS), 3, Suit.SPADES, null)
)
}
@Test
fun `a jester loses to everything, even an off-suit discard`() {
val trump = Suit.SPADES
val jester = trickStrength(jesterOf(Suit.CLUBS), 1, trump, Suit.HEARTS)
val offSuit = trickStrength(Card(Rank.TWO, Suit.DIAMONDS), 2, trump, Suit.HEARTS)
assertTrue(offSuit > jester)
assertTrue(trickStrength(Card(Rank.TWO, Suit.HEARTS), 2, trump, Suit.HEARTS) > offSuit)
}
@Test
fun `a trick of nothing but jesters goes to the first one played`() {
val hands = List(4) { seat -> listOf(jesterOf(Suit.entries[seat])) }
var state = playingState(hands, trump = Suit.SPADES, bids = listOf(0, 0, 0, 0))
repeat(4) {
val seat = WizardRules.currentSeat(state)!!
state = WizardRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
}
assertEquals(0, state.lastTrickWinner)
}
@Test
fun `a led jester leaves the suit to the next ordinary card`() {
val trick = listOf(
PlayedCard(0, jesterOf(Suit.CLUBS)),
PlayedCard(1, Card(Rank.NINE, Suit.HEARTS)),
)
assertEquals(Suit.HEARTS, ledSuitOf(trick))
}
@Test
fun `a wizard before any suit is established means anything may be played`() {
assertNull(ledSuitOf(listOf(PlayedCard(0, wizardOf(Suit.CLUBS)))))
assertNull(
ledSuitOf(
listOf(
PlayedCard(0, jesterOf(Suit.CLUBS)),
PlayedCard(1, wizardOf(Suit.HEARTS)),
PlayedCard(2, Card(Rank.NINE, Suit.SPADES)),
)
)
)
}
// -- Following suit -----------------------------------------------------
@Test
fun `you must follow suit but may always throw a wizard or a jester`() {
val hand = listOf(
Card(Rank.NINE, Suit.HEARTS),
Card(Rank.ACE, Suit.CLUBS),
wizardOf(Suit.SPADES),
jesterOf(Suit.DIAMONDS),
)
val state = playingState(
hands = listOf(emptyList(), hand, emptyList(), emptyList()),
trump = Suit.SPADES,
bids = listOf(0, 1, 0, 0),
turn = 1,
).copy(trick = listOf(PlayedCard(0, Card(Rank.KING, Suit.HEARTS))))
val playable = WizardRules.playableCards(state, 1)
assertTrue(playable.contains(Card(Rank.NINE, Suit.HEARTS)))
assertTrue(playable.contains(wizardOf(Suit.SPADES)))
assertTrue(playable.contains(jesterOf(Suit.DIAMONDS)))
assertFalse("the ace of clubs is not a heart", playable.contains(Card(Rank.ACE, Suit.CLUBS)))
}
@Test
fun `with none of the led suit you may play anything`() {
val hand = listOf(Card(Rank.ACE, Suit.CLUBS), Card(Rank.TWO, Suit.SPADES))
val state = playingState(
hands = listOf(emptyList(), hand, emptyList(), emptyList()),
trump = Suit.SPADES,
bids = listOf(0, 1, 0, 0),
turn = 1,
).copy(trick = listOf(PlayedCard(0, Card(Rank.KING, Suit.HEARTS))))
assertEquals(hand, WizardRules.playableCards(state, 1))
}
@Test
fun `a finished trick is held on the table`() {
val hands = listOf(
listOf(Card(Rank.ACE, Suit.CLUBS), Card(Rank.KING, Suit.CLUBS)),
listOf(Card(Rank.EIGHT, Suit.CLUBS), Card(Rank.NINE, Suit.CLUBS)),
listOf(Card(Rank.TEN, Suit.CLUBS), Card(Rank.QUEEN, Suit.CLUBS)),
listOf(Card(Rank.JACK, Suit.CLUBS), Card(Rank.TWO, Suit.HEARTS)),
)
var state = playingState(hands, trump = Suit.SPADES, bids = listOf(1, 0, 0, 1))
repeat(4) {
val seat = WizardRules.currentSeat(state)!!
state = WizardRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
}
assertTrue(state.trick.isEmpty())
assertEquals("all four held for display", 4, state.completedTrick.size)
assertEquals("seat 0 took it with the ace", 0, state.lastTrickWinner)
}
// -- Scoring ------------------------------------------------------------
@Test
fun `an exact bid pays twenty plus ten a trick`() {
assertEquals(20, scoreRound(bid = 0, won = 0))
assertEquals(30, scoreRound(bid = 1, won = 1))
assertEquals(50, scoreRound(bid = 3, won = 3))
}
@Test
fun `missing costs ten for every trick out either way`() {
assertEquals(-10, scoreRound(bid = 0, won = 1))
assertEquals(-30, scoreRound(bid = 3, won = 0))
assertEquals(-20, scoreRound(bid = 1, won = 3))
}
@Test
fun `the round is scored when the last trick is taken`() {
val hands = listOf(
listOf(Card(Rank.ACE, Suit.CLUBS)),
listOf(Card(Rank.EIGHT, Suit.CLUBS)),
listOf(Card(Rank.NINE, Suit.CLUBS)),
listOf(Card(Rank.TEN, Suit.CLUBS)),
)
var state = playingState(hands, trump = Suit.SPADES, bids = listOf(1, 0, 0, 1))
repeat(4) {
val seat = WizardRules.currentSeat(state)!!
state = WizardRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
}
assertEquals(WizardPhase.ROUND_OVER, state.phase)
assertEquals("bid one and took it", 30, state.roundScores[0])
assertEquals("bid none and took none", 20, state.roundScores[1])
assertEquals("bid one and took none", -10, state.roundScores[3])
assertEquals(state.roundScores, state.scores)
assertNull("no seat is on turn between rounds", WizardRules.currentSeat(state))
}
@Test
fun `the next round deals one more card and moves the deal on`() {
var state = WizardRules.initialState(config())
val dealer = state.dealer
state = playRoundOut(state)
assertEquals(WizardPhase.ROUND_OVER, state.phase)
val next = WizardRules.nextRound(state)
assertEquals(1, next.round)
assertEquals(2, next.cardsThisRound)
next.hands.forEach { assertEquals(2, it.size) }
assertEquals((dealer + 1) % 4, next.dealer)
assertEquals("scores carry over", state.scores, next.scores)
}
// -- Full games ---------------------------------------------------------
@Test
fun `random legal play always reaches the end of the game`() {
for (players in 3..6) {
repeat(3) { iteration ->
val random = Random(iteration.toLong())
val options = WizardOptions(playerCount = players)
var state = WizardRules.initialState(
config(options, seed = 100L * players + iteration)
)
var guard = 0
while (!WizardRules.isFinished(state) && guard++ < 20_000) {
val seat = WizardRules.currentSeat(state)
if (seat == null) {
state = WizardRules.nextRound(state)
continue
}
val legal = WizardRules.legalMoves(state, seat)
assertTrue("seat $seat had no move in ${state.phase}", legal.isNotEmpty())
state = WizardRules.applyMove(state, seat, legal[random.nextInt(legal.size)])
}
assertTrue("$players-handed game $iteration did not finish",
WizardRules.isFinished(state))
assertEquals(options.totalRounds() - 1, state.round)
}
}
}
@Test
fun `ai play always reaches the end of the game`() {
val ai = WizardAi()
repeat(4) { iteration ->
var state = WizardRules.initialState(config(seed = 700L + iteration))
var guard = 0
while (!WizardRules.isFinished(state) && guard++ < 20_000) {
val seat = WizardRules.currentSeat(state)
if (seat == null) {
state = WizardRules.nextRound(state)
continue
}
val legal = WizardRules.legalMoves(state, seat)
val move = ai.chooseMove(state, seat, legal)
assertTrue("ai returned an illegal move $move", legal.contains(move))
state = WizardRules.applyMove(state, seat, move)
}
assertTrue("ai game did not finish", WizardRules.isFinished(state))
}
}
@Test
fun `the ai hits its bid more often than chance would`() {
val ai = WizardAi()
var exact = 0
var rounds = 0
repeat(6) { iteration ->
var state = WizardRules.initialState(config(seed = 900L + iteration))
var guard = 0
while (!WizardRules.isFinished(state) && guard++ < 20_000) {
val seat = WizardRules.currentSeat(state)
if (seat == null) {
exact += state.roundScores.count { it > 0 }
rounds += state.roundScores.size
state = WizardRules.nextRound(state)
continue
}
val legal = WizardRules.legalMoves(state, seat)
state = WizardRules.applyMove(state, seat, ai.chooseMove(state, seat, legal))
}
}
// Blind guessing over a fifteen-round game lands well under a fifth of
// the time; anything paying attention should clear a quarter.
assertTrue("only $exact of $rounds bids were exact", exact * 4 >= rounds)
}
// -- Redaction and wire format -----------------------------------------
@Test
fun `a seat view hides every other hand`() {
val state = WizardRules.initialState(config(WizardOptions(playerCount = 4)))
val view = WizardRules.viewFor(state, seat = 2)
assertEquals(state.hands[2], view.hands[2])
assertTrue(view.hands.filterIndexed { i, _ -> i != 2 }.all { it.isEmpty() })
assertEquals(listOf(1, 1, 1, 1), view.handCounts)
}
@Test
fun `state and moves survive a round trip`() {
val state = WizardRules.initialState(config())
assertEquals(state, WizardRules.decodeState(WizardRules.encodeState(state)))
listOf(
MakeBid(0),
MakeBid(3),
ChooseTrump(Suit.DIAMONDS),
PlayCard(wizardOf(Suit.SPADES)),
PlayCard(jesterOf(Suit.HEARTS)),
).forEach { assertEquals(it, WizardRules.decodeMove(WizardRules.encodeMove(it))) }
}
// -- Helpers ------------------------------------------------------------
private fun playingState(
hands: List<List<Card>>,
trump: Suit?,
bids: List<Int?>,
turn: Int = 0,
) = WizardState(
options = WizardOptions(playerCount = hands.size),
seed = 1L,
round = hands.first().size - 1,
dealer = hands.size - 1,
phase = WizardPhase.PLAYING,
hands = hands,
handCounts = hands.map { it.size },
trumpCard = null,
trump = trump,
bids = bids,
tricksWon = List(hands.size) { 0 },
turn = turn,
trick = emptyList(),
completedTrick = emptyList(),
leader = turn,
lastTrickWinner = null,
scores = List(hands.size) { 0 },
roundScores = List(hands.size) { 0 },
log = emptyList(),
)
/** Bids zero all round and plays the first legal card until the round ends. */
private fun playRoundOut(start: WizardState): WizardState {
var state = start
var guard = 0
while (state.phase == WizardPhase.BIDDING || state.phase == WizardPhase.PLAYING) {
if (guard++ > 5000) break
val seat = WizardRules.currentSeat(state)!!
val legal = WizardRules.legalMoves(state, seat)
state = WizardRules.applyMove(state, seat, legal.first())
}
return state
}
}