Add cribbage for two, three or four players
The whole game: the crib, the cut, the play and the show, pegging on a board drawn with both pegs so the last score is visible as well as the total. Three tables rather than one. Two play head to head with six cards and lay two away; three play singly with five and lay one, with a fourth card dealt straight into the crib because three hands cannot fill it; four play in partnerships pegging on one score, where your partner's crib is your crib. Every table keeps four cards and every crib holds four, which is what lets one engine serve all three. The rule that shapes the code is that a game ends on the point that reaches the target, mid-count if that is where it falls. Every award goes through one function that stops the game there and refuses everything after it, so a non-dealer who counts out wins before the dealer's hand and crib are counted at all — the show reports those as never counted rather than adding them on. A go is called by the engine rather than tapped. The count only rises within a series, so a hand that cannot play now cannot play later in it, and there is nothing to decide; the log says who went past. The opponent works out its lay-away rather than guessing: every way of keeping four is scored against all forty-six cards that could still be cut, and what goes to the crib is valued separately and added or subtracted depending on whose crib it is. It takes 114 games out of 120 against random legal play, from either seat. teamForSeat now takes the seat count, because cribbage is only a partnership game at four — reading seat % 2 at a table of three would have put the first and third players on the same side. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01DCuRESmSZeJoJond43itw2
This commit is contained in:
@@ -70,6 +70,7 @@ class CatalogTest {
|
||||
GameCatalog.GOLF,
|
||||
GameCatalog.KAISER,
|
||||
GameCatalog.CRAZY_EIGHTS,
|
||||
GameCatalog.CRIBBAGE,
|
||||
GameCatalog.WIZARD,
|
||||
GameCatalog.CHESS,
|
||||
GameCatalog.TAYU,
|
||||
|
||||
@@ -0,0 +1,500 @@
|
||||
package org.prolibertate.games.game.cribbage
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.prolibertate.games.game.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 CribbageRulesTest {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private fun config(
|
||||
options: CribbageOptions = CribbageOptions(),
|
||||
seed: Long = 21L,
|
||||
) = TableConfig(
|
||||
gameId = "cribbage",
|
||||
seats = (0 until options.playerCount).map {
|
||||
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = options.teamOf(it))
|
||||
},
|
||||
optionsJson = json.encodeToString(options),
|
||||
seed = seed,
|
||||
)
|
||||
|
||||
private fun c(rank: Rank, suit: Suit) = Card(rank, suit)
|
||||
|
||||
/** A table part-way through the play, built rather than dealt. */
|
||||
private fun playing(
|
||||
hands: List<List<Card>>,
|
||||
options: CribbageOptions = CribbageOptions(playerCount = hands.size),
|
||||
dealer: Int = hands.size - 1,
|
||||
turn: Int = 0,
|
||||
starter: Card = c(Rank.NINE, Suit.CLUBS),
|
||||
played: List<PeggedCard> = emptyList(),
|
||||
series: List<PeggedCard> = played,
|
||||
crib: List<Card> = emptyList(),
|
||||
scores: List<Int> = List(options.teamCount) { 0 },
|
||||
) = CribbageState(
|
||||
options = options,
|
||||
seed = 1L,
|
||||
handNumber = 0,
|
||||
dealer = dealer,
|
||||
hands = hands,
|
||||
handCounts = hands.indices.map { seat ->
|
||||
hands[seat].size - played.count { it.seat == seat }
|
||||
},
|
||||
crib = crib,
|
||||
cribCount = crib.size,
|
||||
deck = emptyList(),
|
||||
starter = starter,
|
||||
played = played,
|
||||
series = series,
|
||||
saidGo = emptySet(),
|
||||
turn = turn,
|
||||
lastToPlay = played.lastOrNull()?.seat,
|
||||
scores = scores,
|
||||
previousScores = scores,
|
||||
show = emptyList(),
|
||||
phase = CribbagePhase.PLAY,
|
||||
winner = null,
|
||||
log = emptyList(),
|
||||
)
|
||||
|
||||
/** Lays away for every seat in turn, leaving the table cut and ready to play. */
|
||||
private fun dealAndLayAway(state: CribbageState): CribbageState {
|
||||
var next = state
|
||||
while (next.phase == CribbagePhase.DISCARD) {
|
||||
val seat = CribbageRules.currentSeat(next)!!
|
||||
next = CribbageRules.applyMove(next, seat, CribbageRules.legalMoves(next, seat).first())
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// -- The deal ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `two players get six cards each and three or four get five`() {
|
||||
assertEquals(6, CribbageOptions(playerCount = 2).dealSize)
|
||||
assertEquals(5, CribbageOptions(playerCount = 3).dealSize)
|
||||
assertEquals(5, CribbageOptions(playerCount = 4).dealSize)
|
||||
assertEquals(2, CribbageOptions(playerCount = 2).layAwaySize)
|
||||
assertEquals(1, CribbageOptions(playerCount = 4).layAwaySize)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the crib is four cards at every table size`() {
|
||||
listOf(2, 3, 4).forEach { players ->
|
||||
val state = dealAndLayAway(
|
||||
CribbageRules.initialState(config(CribbageOptions(playerCount = players)))
|
||||
)
|
||||
assertEquals("$players players", CRIBBAGE_HAND_SIZE, state.crib.size)
|
||||
assertTrue(
|
||||
"$players players kept the wrong number",
|
||||
state.hands.all { it.size == CRIBBAGE_HAND_SIZE },
|
||||
)
|
||||
// No card is in two places at once.
|
||||
val all = state.hands.flatten() + state.crib + state.starter!!
|
||||
assertEquals(all.size, all.distinct().size)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `three-handed deals the odd card straight into the crib`() {
|
||||
val state = CribbageRules.initialState(config(CribbageOptions(playerCount = 3)))
|
||||
assertEquals(1, state.crib.size)
|
||||
assertEquals(1, state.cribCount)
|
||||
assertEquals(0, CribbageRules.initialState(config(CribbageOptions(playerCount = 2))).crib.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the dealer's left lays away first and the dealer last`() {
|
||||
val state = CribbageRules.initialState(config(CribbageOptions(playerCount = 3)))
|
||||
assertEquals(0, state.dealer)
|
||||
assertEquals(1, CribbageRules.currentSeat(state))
|
||||
val afterOne = CribbageRules.applyMove(
|
||||
state, 1, CribbageRules.legalMoves(state, 1).first()
|
||||
)
|
||||
assertEquals(2, CribbageRules.currentSeat(afterOne))
|
||||
val afterTwo = CribbageRules.applyMove(
|
||||
afterOne, 2, CribbageRules.legalMoves(afterOne, 2).first()
|
||||
)
|
||||
assertEquals(0, CribbageRules.currentSeat(afterTwo))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a lay-away means the same cards whichever order they were picked in`() {
|
||||
val one = c(Rank.FIVE, Suit.SPADES)
|
||||
val two = c(Rank.KING, Suit.HEARTS)
|
||||
assertEquals(LayAway.of(listOf(one, two)), LayAway.of(listOf(two, one)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `heads-up offers every way of laying two away`() {
|
||||
val state = CribbageRules.initialState(config())
|
||||
// Fifteen ways of choosing two cards out of six.
|
||||
assertEquals(15, CribbageRules.legalMoves(state, 1).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the starter is cut once everybody has laid away`() {
|
||||
val dealt = CribbageRules.initialState(config())
|
||||
assertNull(dealt.starter)
|
||||
val ready = dealAndLayAway(dealt)
|
||||
assertNotNull(ready.starter)
|
||||
assertEquals(CribbagePhase.PLAY, ready.phase)
|
||||
// The non-dealer leads.
|
||||
assertEquals(1, ready.turn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a jack cut is two for his heels and they are the dealer's`() {
|
||||
var found = 0
|
||||
for (seed in 0L until 200L) {
|
||||
val ready = dealAndLayAway(CribbageRules.initialState(config(seed = seed)))
|
||||
if (ready.starter?.rank != Rank.JACK) {
|
||||
assertEquals("seed $seed scored something for a plain cut", 0, ready.scores.sum())
|
||||
continue
|
||||
}
|
||||
found++
|
||||
assertEquals("seed $seed", 2, ready.scores[ready.options.teamOf(ready.dealer)])
|
||||
assertEquals(0, ready.scores[1 - ready.options.teamOf(ready.dealer)])
|
||||
}
|
||||
assertTrue("no jack was ever cut in two hundred deals", found > 0)
|
||||
}
|
||||
|
||||
// -- The play ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `you may not take the count past thirty-one`() {
|
||||
val state = playing(
|
||||
hands = listOf(
|
||||
listOf(
|
||||
c(Rank.KING, Suit.SPADES), c(Rank.NINE, Suit.DIAMONDS),
|
||||
c(Rank.TWO, Suit.HEARTS), c(Rank.SEVEN, Suit.CLUBS),
|
||||
),
|
||||
listOf(
|
||||
c(Rank.QUEEN, Suit.CLUBS), c(Rank.JACK, Suit.SPADES),
|
||||
c(Rank.TEN, Suit.DIAMONDS), c(Rank.THREE, Suit.HEARTS),
|
||||
),
|
||||
),
|
||||
played = listOf(
|
||||
PeggedCard(0, c(Rank.KING, Suit.SPADES)),
|
||||
PeggedCard(1, c(Rank.QUEEN, Suit.CLUBS)),
|
||||
PeggedCard(0, c(Rank.NINE, Suit.DIAMONDS)),
|
||||
),
|
||||
turn = 0,
|
||||
)
|
||||
// Twenty-nine on the table: of the two cards left, only the two fits.
|
||||
assertEquals(29, state.count)
|
||||
assertEquals(
|
||||
listOf<CribbageMove>(PegCard(c(Rank.TWO, Suit.HEARTS))),
|
||||
CribbageRules.legalMoves(state, 0),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the last card is worth one and the next series starts beyond it`() {
|
||||
val state = playing(
|
||||
hands = listOf(
|
||||
listOf(
|
||||
c(Rank.KING, Suit.SPADES), c(Rank.TEN, Suit.HEARTS),
|
||||
c(Rank.NINE, Suit.DIAMONDS), c(Rank.EIGHT, Suit.CLUBS),
|
||||
),
|
||||
listOf(
|
||||
c(Rank.QUEEN, Suit.CLUBS), c(Rank.JACK, Suit.SPADES),
|
||||
c(Rank.TEN, Suit.DIAMONDS), c(Rank.NINE, Suit.HEARTS),
|
||||
),
|
||||
),
|
||||
played = listOf(
|
||||
PeggedCard(0, c(Rank.KING, Suit.SPADES)),
|
||||
PeggedCard(1, c(Rank.QUEEN, Suit.CLUBS)),
|
||||
),
|
||||
turn = 0,
|
||||
)
|
||||
assertEquals(20, state.count)
|
||||
|
||||
val after = CribbageRules.applyMove(state, 0, PegCard(c(Rank.TEN, Suit.HEARTS)))
|
||||
|
||||
// Thirty on the table and nobody holds a card small enough, so seat 0
|
||||
// takes one for the last card and the count is cleared.
|
||||
assertEquals(1, after.scores[0])
|
||||
assertEquals(0, after.scores[1])
|
||||
assertEquals(0, after.count)
|
||||
assertTrue(after.series.isEmpty())
|
||||
assertTrue(after.saidGo.isEmpty())
|
||||
assertEquals("the seat after the last card leads", 1, after.turn)
|
||||
assertTrue(after.log.any { it.contains("says go") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `thirty-one pays two and nothing more for the go`() {
|
||||
val state = playing(
|
||||
hands = listOf(
|
||||
listOf(
|
||||
c(Rank.FIVE, Suit.SPADES), c(Rank.TWO, Suit.HEARTS),
|
||||
c(Rank.THREE, Suit.DIAMONDS), c(Rank.FOUR, Suit.CLUBS),
|
||||
),
|
||||
listOf(
|
||||
c(Rank.KING, Suit.CLUBS), c(Rank.QUEEN, Suit.SPADES),
|
||||
c(Rank.SIX, Suit.HEARTS), c(Rank.SEVEN, Suit.DIAMONDS),
|
||||
),
|
||||
),
|
||||
played = listOf(
|
||||
PeggedCard(0, c(Rank.FOUR, Suit.CLUBS)),
|
||||
PeggedCard(1, c(Rank.KING, Suit.CLUBS)),
|
||||
PeggedCard(0, c(Rank.TWO, Suit.HEARTS)),
|
||||
PeggedCard(1, c(Rank.QUEEN, Suit.SPADES)),
|
||||
),
|
||||
turn = 0,
|
||||
)
|
||||
assertEquals(26, state.count)
|
||||
|
||||
val after = CribbageRules.applyMove(state, 0, PegCard(c(Rank.FIVE, Suit.SPADES)))
|
||||
|
||||
assertEquals("two for thirty-one, and no point for the go on top", 2, after.scores[0])
|
||||
assertEquals(0, after.count)
|
||||
assertEquals(1, after.turn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pegging out ends the game in the middle of the play`() {
|
||||
val state = playing(
|
||||
hands = listOf(
|
||||
listOf(
|
||||
c(Rank.SEVEN, Suit.SPADES), c(Rank.SEVEN, Suit.HEARTS),
|
||||
c(Rank.NINE, Suit.DIAMONDS), c(Rank.EIGHT, Suit.CLUBS),
|
||||
),
|
||||
listOf(
|
||||
c(Rank.SEVEN, Suit.CLUBS), c(Rank.QUEEN, Suit.SPADES),
|
||||
c(Rank.TEN, Suit.DIAMONDS), c(Rank.NINE, Suit.HEARTS),
|
||||
),
|
||||
),
|
||||
played = listOf(
|
||||
PeggedCard(0, c(Rank.SEVEN, Suit.SPADES)),
|
||||
PeggedCard(1, c(Rank.SEVEN, Suit.CLUBS)),
|
||||
),
|
||||
scores = listOf(119, 60),
|
||||
turn = 0,
|
||||
)
|
||||
val after = CribbageRules.applyMove(state, 0, PegCard(c(Rank.SEVEN, Suit.HEARTS)))
|
||||
|
||||
assertEquals("pair royal", 125, after.scores[0])
|
||||
assertEquals(0, after.winner)
|
||||
assertTrue(CribbageRules.isFinished(after))
|
||||
assertNull(CribbageRules.currentSeat(after))
|
||||
assertTrue("the hands were never counted", after.show.isEmpty())
|
||||
}
|
||||
|
||||
// -- The show ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the hands are counted from the dealer's left with the crib last`() {
|
||||
var state = dealAndLayAway(
|
||||
CribbageRules.initialState(config(CribbageOptions(playerCount = 3), seed = 4L))
|
||||
)
|
||||
while (state.phase == CribbagePhase.PLAY) {
|
||||
val seat = CribbageRules.currentSeat(state)!!
|
||||
state = CribbageRules.applyMove(state, seat, CribbageRules.legalMoves(state, seat).first())
|
||||
}
|
||||
|
||||
assertEquals(CribbagePhase.SHOW, state.phase)
|
||||
assertEquals(4, state.show.size)
|
||||
assertEquals(listOf(1, 2, 0, 0), state.show.map { it.seat })
|
||||
assertEquals(listOf(false, false, false, true), state.show.map { it.isCrib })
|
||||
assertTrue(state.show.all { it.counted })
|
||||
assertEquals(state.crib, state.show.last().cards)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a hand counted out wins before the dealer ever counts`() {
|
||||
// Seat 0 is holding the twenty-nine hand and counts first.
|
||||
val state = playing(
|
||||
hands = listOf(
|
||||
listOf(
|
||||
c(Rank.FIVE, Suit.SPADES), c(Rank.FIVE, Suit.HEARTS),
|
||||
c(Rank.FIVE, Suit.DIAMONDS), c(Rank.JACK, Suit.CLUBS),
|
||||
),
|
||||
listOf(
|
||||
c(Rank.TWO, Suit.SPADES), c(Rank.THREE, Suit.HEARTS),
|
||||
c(Rank.FOUR, Suit.DIAMONDS), c(Rank.SIX, Suit.CLUBS),
|
||||
),
|
||||
),
|
||||
dealer = 1,
|
||||
starter = c(Rank.FIVE, Suit.CLUBS),
|
||||
crib = listOf(
|
||||
c(Rank.KING, Suit.SPADES), c(Rank.QUEEN, Suit.HEARTS),
|
||||
c(Rank.NINE, Suit.DIAMONDS), c(Rank.EIGHT, Suit.SPADES),
|
||||
),
|
||||
played = listOf(
|
||||
PeggedCard(0, c(Rank.FIVE, Suit.SPADES)),
|
||||
PeggedCard(1, c(Rank.TWO, Suit.SPADES)),
|
||||
PeggedCard(0, c(Rank.FIVE, Suit.HEARTS)),
|
||||
PeggedCard(1, c(Rank.THREE, Suit.HEARTS)),
|
||||
PeggedCard(0, c(Rank.FIVE, Suit.DIAMONDS)),
|
||||
PeggedCard(1, c(Rank.FOUR, Suit.DIAMONDS)),
|
||||
PeggedCard(1, c(Rank.SIX, Suit.CLUBS)),
|
||||
),
|
||||
scores = listOf(100, 100),
|
||||
turn = 0,
|
||||
).let { it.copy(series = listOf(PeggedCard(1, c(Rank.SIX, Suit.CLUBS)))) }
|
||||
|
||||
// Seat 0's last card ends the play and takes the table to the show.
|
||||
val after = CribbageRules.applyMove(state, 0, PegCard(c(Rank.JACK, Suit.CLUBS)))
|
||||
|
||||
assertEquals(CribbagePhase.GAME_OVER, after.phase)
|
||||
assertEquals(0, after.winner)
|
||||
assertEquals(3, after.show.size)
|
||||
assertTrue("the non-dealer counted", after.show[0].counted)
|
||||
assertFalse("the dealer never got to count", after.show[1].counted)
|
||||
assertFalse("nor did the crib", after.show[2].counted)
|
||||
assertEquals(29, after.show[0].total)
|
||||
}
|
||||
|
||||
// -- Whole games ---------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `random legal play always finishes, at every table size`() {
|
||||
listOf(2, 3, 4).forEach { players ->
|
||||
repeat(6) { iteration ->
|
||||
val random = Random(iteration.toLong())
|
||||
var state = CribbageRules.initialState(
|
||||
config(
|
||||
CribbageOptions(playerCount = players, pointsToWin = 61),
|
||||
seed = 700L + iteration,
|
||||
)
|
||||
)
|
||||
var guard = 0
|
||||
while (!CribbageRules.isFinished(state) && guard++ < 20000) {
|
||||
val seat = CribbageRules.currentSeat(state)
|
||||
if (seat == null) {
|
||||
state = CribbageRules.nextHand(state)
|
||||
continue
|
||||
}
|
||||
val legal = CribbageRules.legalMoves(state, seat)
|
||||
assertTrue("$players players: seat $seat had no legal move", legal.isNotEmpty())
|
||||
state = CribbageRules.applyMove(state, seat, legal[random.nextInt(legal.size)])
|
||||
}
|
||||
assertTrue("$players players, game $iteration hung", CribbageRules.isFinished(state))
|
||||
assertNotNull(state.winner)
|
||||
assertTrue(state.scores.max() >= 61)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nobody is ever carried past the target`() {
|
||||
// The game stops on the point that reaches it, so the winner's score is
|
||||
// the only one that may sit on or above the line.
|
||||
var state = CribbageRules.initialState(config(CribbageOptions(pointsToWin = 61), seed = 9L))
|
||||
val random = Random(9L)
|
||||
var guard = 0
|
||||
while (!CribbageRules.isFinished(state) && guard++ < 20000) {
|
||||
val seat = CribbageRules.currentSeat(state)
|
||||
if (seat == null) {
|
||||
state = CribbageRules.nextHand(state)
|
||||
continue
|
||||
}
|
||||
val legal = CribbageRules.legalMoves(state, seat)
|
||||
state = CribbageRules.applyMove(state, seat, legal[random.nextInt(legal.size)])
|
||||
}
|
||||
val winner = state.winner!!
|
||||
state.scores.forEachIndexed { team, score ->
|
||||
if (team != winner) assertTrue("team $team reached $score", score < 61)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the computer beats random play head to head`() {
|
||||
val ai = CribbageAi()
|
||||
var aiWins = 0
|
||||
var randomWins = 0
|
||||
|
||||
repeat(10) { iteration ->
|
||||
val random = Random(4000L + iteration)
|
||||
var state = CribbageRules.initialState(
|
||||
config(CribbageOptions(pointsToWin = 61), seed = 200L + iteration)
|
||||
)
|
||||
var guard = 0
|
||||
while (!CribbageRules.isFinished(state) && guard++ < 20000) {
|
||||
val seat = CribbageRules.currentSeat(state)
|
||||
if (seat == null) {
|
||||
state = CribbageRules.nextHand(state)
|
||||
continue
|
||||
}
|
||||
val legal = CribbageRules.legalMoves(state, seat)
|
||||
val move = if (seat == 0) {
|
||||
ai.chooseMove(state, seat, legal)
|
||||
} else {
|
||||
legal[random.nextInt(legal.size)]
|
||||
}
|
||||
assertTrue("the ai returned an illegal move $move", legal.contains(move))
|
||||
state = CribbageRules.applyMove(state, seat, move)
|
||||
}
|
||||
if (state.winner == 0) aiWins++ else randomWins++
|
||||
}
|
||||
|
||||
assertTrue("ai won $aiWins of ten against random play", aiWins > randomWins)
|
||||
}
|
||||
|
||||
// -- Redaction and wire format ------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a seat view hides the other hands, the crib and the pack`() {
|
||||
val state = dealAndLayAway(CribbageRules.initialState(config(CribbageOptions(playerCount = 4))))
|
||||
val view = CribbageRules.viewFor(state, seat = 2)
|
||||
|
||||
assertEquals(state.hands[2], view.hands[2])
|
||||
assertTrue(view.hands.filterIndexed { seat, _ -> seat != 2 }.all { it.isEmpty() })
|
||||
assertTrue("the crib is nobody's business until the show", view.crib.isEmpty())
|
||||
assertTrue("the cut must not be knowable in advance", view.deck.isEmpty())
|
||||
assertEquals(CRIBBAGE_HAND_SIZE, view.cribCount)
|
||||
assertEquals(state.handCounts, view.handCounts)
|
||||
assertEquals(state.starter, view.starter)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `everything is turned face up at the show`() {
|
||||
var state = dealAndLayAway(CribbageRules.initialState(config(seed = 12L)))
|
||||
while (state.phase == CribbagePhase.PLAY) {
|
||||
val seat = CribbageRules.currentSeat(state)!!
|
||||
state = CribbageRules.applyMove(state, seat, CribbageRules.legalMoves(state, seat).first())
|
||||
}
|
||||
val view = CribbageRules.viewFor(state, seat = 0)
|
||||
assertEquals(state.hands, view.hands)
|
||||
assertEquals(state.crib, view.crib)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state and moves survive a round trip`() {
|
||||
val state = CribbageRules.initialState(config())
|
||||
assertEquals(state, CribbageRules.decodeState(CribbageRules.encodeState(state)))
|
||||
listOf(
|
||||
PegCard(c(Rank.KING, Suit.HEARTS)),
|
||||
LayAway.of(listOf(c(Rank.FIVE, Suit.SPADES), c(Rank.SIX, Suit.CLUBS))),
|
||||
).forEach {
|
||||
assertEquals(it, CribbageRules.decodeMove(CribbageRules.encodeMove(it)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `four play in partnerships and two or three play for themselves`() {
|
||||
val four = CribbageOptions(playerCount = 4)
|
||||
assertEquals(2, four.teamCount)
|
||||
assertEquals(listOf(0, 1, 0, 1), (0..3).map { four.teamOf(it) })
|
||||
|
||||
val three = CribbageOptions(playerCount = 3)
|
||||
assertEquals(3, three.teamCount)
|
||||
assertEquals(listOf(0, 1, 2), (0..2).map { three.teamOf(it) })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package org.prolibertate.games.game.cribbage
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
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
|
||||
|
||||
class CribbageScoringTest {
|
||||
|
||||
private fun c(rank: Rank, suit: Suit) = Card(rank, suit)
|
||||
|
||||
private fun showTotal(hand: List<Card>, starter: Card, isCrib: Boolean = false): Int =
|
||||
CribbageScoring.show(hand, starter, isCrib).sumOf { it.points }
|
||||
|
||||
private fun pegTotal(vararg cards: Card): Int =
|
||||
CribbageScoring.peg(cards.toList()).sumOf { it.points }
|
||||
|
||||
// -- What a card is worth ------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `aces are one and every court card is ten`() {
|
||||
assertEquals(1, pipValue(c(Rank.ACE, Suit.SPADES)))
|
||||
assertEquals(10, pipValue(c(Rank.TEN, Suit.SPADES)))
|
||||
assertEquals(10, pipValue(c(Rank.JACK, Suit.SPADES)))
|
||||
assertEquals(10, pipValue(c(Rank.QUEEN, Suit.SPADES)))
|
||||
assertEquals(10, pipValue(c(Rank.KING, Suit.SPADES)))
|
||||
assertEquals(7, pipValue(c(Rank.SEVEN, Suit.SPADES)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the ace is low in a run and does not follow the king`() {
|
||||
assertEquals(1, runOrder(c(Rank.ACE, Suit.SPADES)))
|
||||
assertEquals(13, runOrder(c(Rank.KING, Suit.SPADES)))
|
||||
// A-2-3 is a run of three.
|
||||
val low = CribbageScoring.show(
|
||||
hand = listOf(
|
||||
c(Rank.ACE, Suit.SPADES),
|
||||
c(Rank.TWO, Suit.HEARTS),
|
||||
c(Rank.THREE, Suit.DIAMONDS),
|
||||
c(Rank.NINE, Suit.CLUBS),
|
||||
),
|
||||
starter = c(Rank.SEVEN, Suit.CLUBS),
|
||||
isCrib = false,
|
||||
)
|
||||
assertEquals(3, low.first { it.label == "Run of 3" }.points)
|
||||
|
||||
// Q-K-A is not.
|
||||
val high = CribbageScoring.show(
|
||||
hand = listOf(
|
||||
c(Rank.QUEEN, Suit.SPADES),
|
||||
c(Rank.KING, Suit.HEARTS),
|
||||
c(Rank.ACE, Suit.DIAMONDS),
|
||||
c(Rank.TWO, Suit.CLUBS),
|
||||
),
|
||||
starter = c(Rank.SEVEN, Suit.CLUBS),
|
||||
isCrib = false,
|
||||
)
|
||||
assertTrue(high.none { it.label.startsWith("Run") })
|
||||
}
|
||||
|
||||
// -- The show ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the best hand in cribbage is twenty-nine`() {
|
||||
// Three fives and the jack of the suit that is cut.
|
||||
val hand = listOf(
|
||||
c(Rank.FIVE, Suit.SPADES),
|
||||
c(Rank.FIVE, Suit.HEARTS),
|
||||
c(Rank.FIVE, Suit.DIAMONDS),
|
||||
c(Rank.JACK, Suit.CLUBS),
|
||||
)
|
||||
val lines = CribbageScoring.show(hand, c(Rank.FIVE, Suit.CLUBS), isCrib = false)
|
||||
assertEquals(29, lines.sumOf { it.points })
|
||||
assertEquals(16, lines.first { it.label.endsWith("fifteens") }.points)
|
||||
assertEquals(12, lines.first { it.label.startsWith("Double pair royal") }.points)
|
||||
assertEquals(1, lines.first { it.label == "His nob" }.points)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nineteen is no hand at all`() {
|
||||
val hand = listOf(
|
||||
c(Rank.KING, Suit.SPADES),
|
||||
c(Rank.QUEEN, Suit.HEARTS),
|
||||
c(Rank.EIGHT, Suit.DIAMONDS),
|
||||
c(Rank.SIX, Suit.CLUBS),
|
||||
)
|
||||
assertTrue(CribbageScoring.show(hand, c(Rank.TWO, Suit.CLUBS), false).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a doubled run counts twice over rather than lengthening`() {
|
||||
// 4-5-5-6 is two runs of three, not a run of four.
|
||||
val hand = listOf(
|
||||
c(Rank.FOUR, Suit.SPADES),
|
||||
c(Rank.FIVE, Suit.SPADES),
|
||||
c(Rank.FIVE, Suit.HEARTS),
|
||||
c(Rank.SIX, Suit.DIAMONDS),
|
||||
)
|
||||
val lines = CribbageScoring.show(hand, c(Rank.KING, Suit.CLUBS), isCrib = false)
|
||||
val run = lines.first { it.label.contains("runs of") }
|
||||
assertEquals("2 runs of 3", run.label)
|
||||
assertEquals(6, run.points)
|
||||
// Two 4-5-6 fifteens, two 5-king fifteens, and the pair of fives.
|
||||
assertEquals(16, lines.sumOf { it.points })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a hand takes four for its own suit and five with the starter`() {
|
||||
val flush = listOf(
|
||||
c(Rank.TWO, Suit.HEARTS),
|
||||
c(Rank.FOUR, Suit.HEARTS),
|
||||
c(Rank.NINE, Suit.HEARTS),
|
||||
c(Rank.KING, Suit.HEARTS),
|
||||
)
|
||||
assertEquals(
|
||||
ScoreLine("Flush of 4", 4),
|
||||
CribbageScoring.show(flush, c(Rank.SEVEN, Suit.SPADES), isCrib = false)
|
||||
.first { it.label.startsWith("Flush") },
|
||||
)
|
||||
assertEquals(
|
||||
ScoreLine("Flush of 5", 5),
|
||||
CribbageScoring.show(flush, c(Rank.SEVEN, Suit.HEARTS), isCrib = false)
|
||||
.first { it.label.startsWith("Flush") },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a crib takes nothing for four of a suit`() {
|
||||
val flush = listOf(
|
||||
c(Rank.TWO, Suit.HEARTS),
|
||||
c(Rank.FOUR, Suit.HEARTS),
|
||||
c(Rank.NINE, Suit.HEARTS),
|
||||
c(Rank.KING, Suit.HEARTS),
|
||||
)
|
||||
assertNull(
|
||||
CribbageScoring.show(flush, c(Rank.SEVEN, Suit.SPADES), isCrib = true)
|
||||
.firstOrNull { it.label.startsWith("Flush") }
|
||||
)
|
||||
assertEquals(
|
||||
ScoreLine("Flush of 5", 5),
|
||||
CribbageScoring.show(flush, c(Rank.SEVEN, Suit.HEARTS), isCrib = true)
|
||||
.first { it.label.startsWith("Flush") },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `his nob wants the jack in the hand and the suit that was cut`() {
|
||||
val withJack = listOf(
|
||||
c(Rank.JACK, Suit.HEARTS),
|
||||
c(Rank.TWO, Suit.SPADES),
|
||||
c(Rank.SEVEN, Suit.CLUBS),
|
||||
c(Rank.NINE, Suit.DIAMONDS),
|
||||
)
|
||||
assertEquals(
|
||||
ScoreLine("His nob", 1),
|
||||
CribbageScoring.show(withJack, c(Rank.THREE, Suit.HEARTS), isCrib = false)
|
||||
.first { it.label == "His nob" },
|
||||
)
|
||||
assertNull(
|
||||
CribbageScoring.show(withJack, c(Rank.THREE, Suit.SPADES), isCrib = false)
|
||||
.firstOrNull { it.label == "His nob" }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fifteens count every combination and not just pairs of cards`() {
|
||||
// 7-8 makes fifteen, and so does 2-3-4-6.
|
||||
val hand = listOf(
|
||||
c(Rank.SEVEN, Suit.SPADES),
|
||||
c(Rank.EIGHT, Suit.HEARTS),
|
||||
c(Rank.TWO, Suit.DIAMONDS),
|
||||
c(Rank.THREE, Suit.CLUBS),
|
||||
)
|
||||
val lines = CribbageScoring.show(hand, c(Rank.FOUR, Suit.CLUBS), isCrib = false)
|
||||
assertEquals(4, lines.first { it.label == "2 fifteens" }.points)
|
||||
assertEquals(3, lines.first { it.label == "Run of 3" }.points)
|
||||
assertEquals(7, lines.sumOf { it.points })
|
||||
}
|
||||
|
||||
// -- The play ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `fifteen and thirty-one are worth two apiece during the play`() {
|
||||
assertEquals(2, pegTotal(c(Rank.SEVEN, Suit.SPADES), c(Rank.EIGHT, Suit.HEARTS)))
|
||||
assertEquals(
|
||||
2,
|
||||
pegTotal(
|
||||
c(Rank.KING, Suit.SPADES),
|
||||
c(Rank.QUEEN, Suit.HEARTS),
|
||||
c(Rank.SIX, Suit.DIAMONDS),
|
||||
c(Rank.FIVE, Suit.CLUBS),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pairs peg off the end of the run and not through another card`() {
|
||||
assertEquals(2, pegTotal(c(Rank.SEVEN, Suit.SPADES), c(Rank.SEVEN, Suit.HEARTS)))
|
||||
assertEquals(
|
||||
6,
|
||||
pegTotal(
|
||||
c(Rank.SEVEN, Suit.SPADES),
|
||||
c(Rank.SEVEN, Suit.HEARTS),
|
||||
c(Rank.SEVEN, Suit.CLUBS),
|
||||
),
|
||||
)
|
||||
// Broken up by a three, so the sevens are no longer a pair.
|
||||
assertEquals(
|
||||
0,
|
||||
pegTotal(
|
||||
c(Rank.SEVEN, Suit.SPADES),
|
||||
c(Rank.THREE, Suit.HEARTS),
|
||||
c(Rank.SEVEN, Suit.CLUBS),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a run pegs in any order and lengthens as it is extended`() {
|
||||
assertEquals(
|
||||
3,
|
||||
pegTotal(
|
||||
c(Rank.FIVE, Suit.SPADES),
|
||||
c(Rank.THREE, Suit.HEARTS),
|
||||
c(Rank.FOUR, Suit.CLUBS),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
4,
|
||||
pegTotal(
|
||||
c(Rank.FIVE, Suit.SPADES),
|
||||
c(Rank.THREE, Suit.HEARTS),
|
||||
c(Rank.FOUR, Suit.CLUBS),
|
||||
c(Rank.SIX, Suit.DIAMONDS),
|
||||
),
|
||||
)
|
||||
// A repeated rank breaks the run: 5-3-4-4 is no run at all.
|
||||
assertEquals(
|
||||
2,
|
||||
pegTotal(
|
||||
c(Rank.FIVE, Suit.SPADES),
|
||||
c(Rank.THREE, Suit.HEARTS),
|
||||
c(Rank.FOUR, Suit.CLUBS),
|
||||
c(Rank.FOUR, Suit.DIAMONDS),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `twenty-nine really is the ceiling`() {
|
||||
// Exhaustive over a shortened pack — aces to fives and the jacks, which
|
||||
// is where every big hand lives. Nothing may beat 29, and 29 has to be
|
||||
// reachable, or the scoring has quietly stopped counting something.
|
||||
val pack = Decks.standard52().filter { runOrder(it) <= 5 || it.rank == Rank.JACK }
|
||||
var best = 0
|
||||
for (a in pack.indices) {
|
||||
for (b in a + 1 until pack.size) {
|
||||
for (d in b + 1 until pack.size) {
|
||||
for (e in d + 1 until pack.size) {
|
||||
val hand = listOf(pack[a], pack[b], pack[d], pack[e])
|
||||
for (starter in pack) {
|
||||
if (starter in hand) continue
|
||||
best = maxOf(best, showTotal(hand, starter))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(29, best)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user