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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user