Merge pull request #13: cribbage for two, three or four players

Adds cribbage — the crib, the cut, the play and the show, for two head to
head, three playing singly, or four in partnerships — and fixes the hold
that left every end-of-hand scoreboard impossible to dismiss.
This commit is contained in:
adrianoftyriel
2026-08-07 16:05:37 -06:00
committed by GitHub
16 changed files with 2609 additions and 8 deletions
+5 -2
View File
@@ -9,7 +9,7 @@ The app shell is complete and every game in the catalogue is playable end to end
| | |
| --- | --- |
| **Cards** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s |
| **Cards** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s, Cribbage |
| **Board** | Sequence, Chess, Checkers, Backgammon, Mastermind, Nine Men's Morris, Pirates and Bulgars, Ta Yü |
Nothing is marked *Coming soon* at the moment, because nothing is waiting. The
@@ -47,7 +47,7 @@ them.
## Scorekeeper
**Scorekeeper** on the main menu is a pencil for games the app does not deal —
darts, cribbage, whatever is actually on the table. It is laid out as the paper
darts, rummy, whatever is actually on the table. It is laid out as the paper
it replaces: a column per player with their name at the head, a row per round
running down the page, and the running total ruled off at the foot.
@@ -292,6 +292,8 @@ game:
- [Golf](docs/RULES-golf.md)
- [Wizard](docs/RULES-wizard.md)
- [Crazy 8s](docs/RULES-crazy8s.md)
- [Cribbage](docs/RULES-cribbage.md) — two, three or four, and **note that four
play in partnerships pegging on one score**
- [Chess](docs/RULES-chess.md)
- [Checkers](docs/RULES-checkers.md) — **note the caveat about flying kings**
- [Backgammon](docs/RULES-backgammon.md) — **note that there is no doubling cube**
@@ -318,6 +320,7 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested
golf/ Golf model, scoring, rules, AI
wizard/ Wizard model, rules, AI
crazyeights/ Crazy 8s model, rules, AI
cribbage/ Cribbage the crib, the play, the show, and a counting AI
chess/ Chess model, FEN, move generation, search
morris/ Nine Men's Morris board geometry, mills, rules, search
checkers/ Checkers board, compulsory captures, crowning, search
@@ -33,6 +33,7 @@ object GameCatalog {
const val GOLF = "golf"
const val WIZARD = "wizard"
const val CRAZY_EIGHTS = "crazy8s"
const val CRIBBAGE = "cribbage"
const val SEQUENCE = "sequence"
const val MASTERMIND = "mastermind"
const val BACKGAMMON = "backgammon"
@@ -103,6 +104,18 @@ object GameCatalog {
blurb = "Match rank or suit, and eights change everything.",
available = true,
),
GameDescriptor(
id = CRIBBAGE,
title = "Cribbage",
category = GameCategory.CARD,
minPlayers = 2,
maxPlayers = 4,
// Only at four, where the two pairs share a board between them.
teamBased = true,
blurb = "Fifteen two, fifteen four, and one for his nob. Peg your way " +
"twice round the board to 121.",
available = true,
),
GameDescriptor(
id = SEQUENCE,
title = "Sequence",
@@ -0,0 +1,104 @@
package org.prolibertate.games.game.cribbage
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.engine.GameAi
import kotlin.math.abs
/**
* A cribbage opponent that counts rather than guesses.
*
* The lay-away is the decision that decides most hands, so it is the one part
* that is worked out exactly: every way of keeping four is scored against all
* forty-six cards that could still be cut, and the average is compared. What
* goes to the crib is then valued separately and added or subtracted depending
* on whose crib it is — the same two cards are a gift to your own crib and a
* present to the other side's.
*
* The play is a heuristic: take the points on offer, and try not to leave the
* count where a ten-card collects fifteen or thirty-one.
*/
class CribbageAi : GameAi<CribbageState, CribbageMove> {
override fun chooseMove(
state: CribbageState,
seat: Int,
legal: List<CribbageMove>,
): CribbageMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
val layAways = legal.filterIsInstance<LayAway>()
if (layAways.isNotEmpty()) return chooseLayAway(state, seat, layAways)
return choosePeg(state, legal.filterIsInstance<PegCard>())
}
private fun chooseLayAway(
state: CribbageState,
seat: Int,
choices: List<LayAway>,
): LayAway {
val hand = state.hands[seat]
val held = hand.toSet()
val unseen = Decks.standard52().filter { it !in held }
// A partner's crib is your crib. That is the whole of four-handed play.
val ownCrib = state.options.teamOf(seat) == state.options.teamOf(state.dealer)
return choices.maxByOrNull { choice ->
val given = choice.cards.toSet()
val keep = hand.filter { it !in given }
val expected = unseen.sumOf { starter ->
CribbageScoring.show(keep, starter, isCrib = false).sumOf { it.points }
}.toDouble() / unseen.size
expected + cribValue(choice.cards) * (if (ownCrib) 1.0 else -1.0)
} ?: choices.first()
}
/**
* Roughly what a lay-away is worth to whoever owns the crib.
*
* A five is the card of the game — every one of the sixteen ten-cards makes
* fifteen with it — so it is never given away cheaply, and a pair or a
* fifteen laid away together is two points the crib scores whatever is cut.
*/
private fun cribValue(cards: List<Card>): Double {
var value = cards.sumOf { card ->
when (card.rank) {
Rank.FIVE -> 2.0
Rank.JACK -> 0.6
Rank.TWO, Rank.THREE -> 0.3
Rank.ACE, Rank.QUEEN, Rank.KING -> 0.1
else -> 0.0
}
}
if (cards.size == 2) {
val first = cards[0]
val second = cards[1]
if (first.rank == second.rank) value += 2.0
if (pipValue(first) + pipValue(second) == 15) value += 2.0
when (abs(runOrder(first) - runOrder(second))) {
1 -> value += 1.0
2 -> value += 0.5
}
}
return value
}
private fun choosePeg(state: CribbageState, choices: List<PegCard>): PegCard {
val count = state.count
val onTheTable = state.series.map { it.card }
return choices.maxByOrNull { choice ->
val scored = CribbageScoring.peg(onTheTable + choice.card).sumOf { it.points }
val after = count + pipValue(choice.card)
var value = scored * 10.0
// Five and twenty-one are the two counts to avoid leaving: any of
// the sixteen ten-cards takes fifteen or thirty-one off them.
if (after == 5 || after == 21) value -= 3.0
// Leading a five is the same present, offered first.
if (count == 0 && choice.card.rank == Rank.FIVE) value -= 4.0
// Big cards early. Low ones are what let you keep playing at the
// awkward end of a series, where the last card is worth a point.
value + pipValue(choice.card) * 0.1
} ?: choices.first()
}
}
@@ -0,0 +1,172 @@
package org.prolibertate.games.game.cribbage
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
@Serializable
data class CribbageOptions(
val playerCount: Int = 2,
val pointsToWin: Int = 121,
/** Say so at the finish when the loser never got off the second street. */
val countSkunks: Boolean = true,
) {
init {
require(playerCount in 2..4) { "Cribbage seats two to four" }
require(pointsToWin == 61 || pointsToWin == 121) {
"Cribbage is played once round the board to 61 or twice to 121"
}
}
/** Six cards each head to head, five otherwise. */
val dealSize: Int get() = if (playerCount == 2) 6 else 5
/** Two players lay away two cards each; three or four lay away one. */
val layAwaySize: Int get() = if (playerCount == 2) 2 else 1
/**
* Three hands laying away one card apiece leave the crib a card short, so
* one is dealt straight into it off the top of the pack.
*/
val cribFromDeck: Int get() = if (playerCount == 3) 1 else 0
/** Four play in partnerships across the table; two or three play singly. */
val teamCount: Int get() = if (playerCount == 4) 2 else playerCount
fun teamOf(seat: Int): Int = if (playerCount == 4) seat % 2 else seat
}
/** Everyone holds four cards once the crib is laid away, whatever the table. */
const val CRIBBAGE_HAND_SIZE = 4
/** The count never passes this, and reaching it exactly is worth two. */
const val CRIBBAGE_LIMIT = 31
/**
* What a card is worth to the count and to a fifteen: aces are one, and every
* court card is ten.
*/
fun pipValue(card: Card): Int = when (card.rank) {
Rank.ACE -> 1
Rank.JACK, Rank.QUEEN, Rank.KING -> 10
else -> card.rank.order
}
/**
* Where a card sits in a run. The ace is low here — A-2-3 is a run and Q-K-A is
* not — which is why this cannot be [Rank.order], where the ace is high.
*/
fun runOrder(card: Card): Int = if (card.rank == Rank.ACE) 1 else card.rank.order
/** One card laid on the table during the play, and by whom. */
@Serializable
data class PeggedCard(val seat: Int, val card: Card)
/** One thing a hand scored for, in the words a player would use for it. */
@Serializable
data class ScoreLine(val label: String, val points: Int)
/**
* A hand or a crib as it was counted at the show.
*
* [counted] is false for a hand the game never got to: somebody pegged out
* ahead of it in the counting order, and the rest of the table shows what it
* would have been worth rather than pretending it was scored.
*/
@Serializable
data class ShowScore(
val seat: Int,
val isCrib: Boolean,
val cards: List<Card>,
val lines: List<ScoreLine>,
val counted: Boolean = true,
) {
val total: Int get() = lines.sumOf { it.points }
}
@Serializable
enum class CribbagePhase {
/** Everyone is choosing what to give the crib. */
DISCARD,
/** Pegging: cards on the table one at a time, count to thirty-one. */
PLAY,
/** The hands are counted and on screen, waiting to be read. */
SHOW,
GAME_OVER,
}
@Serializable
data class CribbageState(
val options: CribbageOptions,
val seed: Long,
val handNumber: Int,
val dealer: Int,
/**
* What each seat was dealt, less what it laid away. Cards stay here once
* they have been pegged — [remaining] is what is still in the hand, and the
* whole four are wanted again at the show.
*/
val hands: List<List<Card>>,
/** Cards still held per seat, which survives redaction where [hands] does not. */
val handCounts: List<Int>,
val crib: List<Card>,
val cribCount: Int,
/** What is left of the pack. Never sent to anyone. */
val deck: List<Card>,
val starter: Card?,
/** Everything pegged this hand, in order. */
val played: List<PeggedCard>,
/** The cards of the series being counted now, which is cleared at thirty-one or a go. */
val series: List<PeggedCard>,
val saidGo: Set<Int>,
val turn: Int,
val lastToPlay: Int?,
/** By team. Two or three play singly, so there a team is a seat. */
val scores: List<Int>,
/** Where the back peg stands: each side's score before its last award. */
val previousScores: List<Int>,
val show: List<ShowScore>,
val phase: CribbagePhase,
val winner: Int?,
val log: List<String>,
) {
/** The running count of the series in play. Never above [CRIBBAGE_LIMIT]. */
val count: Int get() = series.sumOf { pipValue(it.card) }
/** What [seat] has not pegged yet. Empty for a seat whose hand is redacted. */
fun remaining(seat: Int): List<Card> {
val gone = played.filter { it.seat == seat }.map { it.card }.toSet()
return hands[seat].filter { it !in gone }
}
}
@Serializable
sealed interface CribbageMove
/**
* Cards given to the crib. Two of them head to head, one otherwise.
*
* The cards are held in a fixed order rather than the order they were tapped
* in, because the host compares a move against the list it generated itself —
* a client that picked the same two cards the other way round is making the
* same move and must not be refused for it.
*/
@Serializable
@SerialName("layaway")
data class LayAway(val cards: List<Card>) : CribbageMove {
companion object {
fun of(cards: Collection<Card>): LayAway = LayAway(cards.sortedWith(cribbageCardOrder))
}
}
@Serializable
@SerialName("peg")
data class PegCard(val card: Card) : CribbageMove
/** An arbitrary but stable order, used only to make a lay-away comparable. */
val cribbageCardOrder: Comparator<Card> =
compareBy({ it.suit.ordinal }, { it.rank.ordinal })
@@ -0,0 +1,421 @@
package org.prolibertate.games.game.cribbage
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Decks
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.shuffledWith
import org.prolibertate.games.game.engine.GameRules
import org.prolibertate.games.game.engine.TableConfig
import kotlin.random.Random
/**
* Cribbage as a pure state machine, for two, three or four. See RULES-cribbage.md.
*
* One thing shapes the whole of this file: a game of cribbage is over the
* instant a peg reaches the target, in the middle of a count if that is where
* it happens. So every award goes through [award], which stops the game there
* and refuses everything after it, rather than the hand being scored in full
* and the total compared afterwards.
*/
object CribbageRules : GameRules<CribbageState, CribbageMove> {
override val gameId: String = GameCatalog.CRIBBAGE
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
override fun initialState(config: TableConfig): CribbageState {
val options = json.decodeFromString<CribbageOptions>(config.optionsJson)
require(config.seats.size == options.playerCount) {
"Seat count ${config.seats.size} does not match ${options.playerCount} players"
}
return deal(
options = options,
seed = config.seed,
handNumber = 0,
dealer = 0,
scores = List(options.teamCount) { 0 },
log = listOf("Hand 1. Seat 0 deals."),
)
}
private fun deal(
options: CribbageOptions,
seed: Long,
handNumber: Int,
dealer: Int,
scores: List<Int>,
log: List<String>,
): CribbageState {
val random = Random(seed + handNumber * 7919L)
var pack = Decks.standard52().shuffledWith(random)
val hands = MutableList(options.playerCount) { emptyList<Card>() }
// Dealt round the table from the dealer's left, as they would be.
for (step in 1..options.playerCount) {
val seat = (dealer + step) % options.playerCount
hands[seat] = pack.take(options.dealSize)
pack = pack.drop(options.dealSize)
}
val crib = pack.take(options.cribFromDeck)
pack = pack.drop(options.cribFromDeck)
return CribbageState(
options = options,
seed = seed,
handNumber = handNumber,
dealer = dealer,
hands = hands,
handCounts = hands.map { it.size },
crib = crib,
cribCount = crib.size,
deck = pack,
starter = null,
played = emptyList(),
series = emptyList(),
saidGo = emptySet(),
turn = (dealer + 1) % options.playerCount,
lastToPlay = null,
scores = scores,
previousScores = scores,
show = emptyList(),
phase = CribbagePhase.DISCARD,
winner = null,
log = log,
)
}
/** Advances from [CribbagePhase.SHOW] into the next deal, the deal moving on one seat. */
fun nextHand(state: CribbageState): CribbageState {
check(state.phase == CribbagePhase.SHOW) { "The hand is not over" }
val dealer = (state.dealer + 1) % state.options.playerCount
return deal(
options = state.options,
seed = state.seed,
handNumber = state.handNumber + 1,
dealer = dealer,
scores = state.scores,
log = state.log + "Hand ${state.handNumber + 2}. Seat $dealer deals.",
)
}
override fun currentSeat(state: CribbageState): Int? = when (state.phase) {
CribbagePhase.DISCARD, CribbagePhase.PLAY -> state.turn
CribbagePhase.SHOW, CribbagePhase.GAME_OVER -> null
}
override fun legalMoves(state: CribbageState, seat: Int): List<CribbageMove> {
if (currentSeat(state) != seat) return emptyList()
return when (state.phase) {
CribbagePhase.DISCARD ->
combinations(state.hands[seat], state.options.layAwaySize).map { LayAway.of(it) }
CribbagePhase.PLAY -> state.remaining(seat)
.filter { pipValue(it) + state.count <= CRIBBAGE_LIMIT }
.map { PegCard(it) }
else -> emptyList()
}
}
override fun applyMove(state: CribbageState, seat: Int, move: CribbageMove): CribbageState {
require(currentSeat(state) == seat) { "Not seat $seat's turn" }
require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" }
return when (move) {
is LayAway -> applyLayAway(state, seat, move)
is PegCard -> applyPeg(state, seat, move)
}
}
// -----------------------------------------------------------------------
// The crib
// -----------------------------------------------------------------------
private fun applyLayAway(
state: CribbageState,
seat: Int,
move: LayAway,
): CribbageState {
val hands = state.hands.toMutableList()
hands[seat] = hands[seat] - move.cards.toSet()
val crib = state.crib + move.cards
val next = state.copy(
hands = hands,
handCounts = hands.map { it.size },
crib = crib,
cribCount = crib.size,
turn = (seat + 1) % state.options.playerCount,
log = state.log + "Seat $seat lays ${move.cards.size} away to seat ${state.dealer}'s crib.",
)
val everyoneHasLaidAway = next.hands.all { it.size == CRIBBAGE_HAND_SIZE }
return if (everyoneHasLaidAway) cutTheStarter(next) else next
}
/**
* The cut, which is the top of what is left of a pack already shuffled — so
* cutting it anywhere gives the same distribution as cutting it here.
*/
private fun cutTheStarter(state: CribbageState): CribbageState {
val starter = state.deck.first()
val cut = state.copy(
starter = starter,
deck = state.deck.drop(1),
turn = (state.dealer + 1) % state.options.playerCount,
phase = CribbagePhase.PLAY,
log = state.log + "The starter is ${starter.label}.",
)
// Two for his heels, and it is the dealer's whoever cut it.
return if (starter.rank == Rank.JACK) {
award(cut, state.dealer, 2, "his heels")
} else {
cut
}
}
// -----------------------------------------------------------------------
// The play
// -----------------------------------------------------------------------
private fun applyPeg(state: CribbageState, seat: Int, move: PegCard): CribbageState {
val series = state.series + PeggedCard(seat, move.card)
var next = state.copy(
played = state.played + PeggedCard(seat, move.card),
series = series,
lastToPlay = seat,
)
next = next.copy(
handCounts = handCounts(next),
log = next.log + "Seat $seat plays ${move.card.label} for ${next.count}.",
)
CribbageScoring.peg(series.map { it.card }).forEach { line ->
next = award(next, seat, line.points, line.label.lowercase())
}
if (next.winner != null) return next
return advancePlay(next, seat)
}
/**
* Hands the play to the next seat that can actually lay a card.
*
* A seat that cannot play says go — the count only ever rises within a
* series, so a seat that cannot play now cannot play later in it either,
* and this walks past it rather than stopping the table on a move with one
* possible answer. When nobody at all can play, the series is closed.
*/
private fun advancePlay(state: CribbageState, justPlayed: Int): CribbageState {
if (state.count == CRIBBAGE_LIMIT) {
// Thirty-one has already paid two; there is no point for the go on top.
return closeSeries(state, justPlayed, payForTheGo = false)
}
var next = state
val seats = next.options.playerCount
for (step in 1..seats) {
val seat = (justPlayed + step) % seats
val held = next.remaining(seat)
if (held.isEmpty()) continue
if (held.any { pipValue(it) + next.count <= CRIBBAGE_LIMIT }) {
return next.copy(turn = seat)
}
if (seat !in next.saidGo) {
next = next.copy(
saidGo = next.saidGo + seat,
log = next.log + "Seat $seat says go.",
)
}
}
return closeSeries(next, justPlayed, payForTheGo = true)
}
/**
* Clears the count after a thirty-one or a go, and pays for the last card.
*
* The seat after whoever played that last card leads the next series, which
* is why a hand can turn on being left holding cards nobody can follow.
*/
private fun closeSeries(
state: CribbageState,
lastPlayer: Int,
payForTheGo: Boolean,
): CribbageState {
var next = state
if (payForTheGo) next = award(next, lastPlayer, 1, "the last card")
if (next.winner != null) return next
val dealt = next.options.playerCount * CRIBBAGE_HAND_SIZE
if (next.played.size == dealt) return theShow(next)
val seats = next.options.playerCount
val lead = (1..seats)
.map { (lastPlayer + it) % seats }
.first { next.remaining(it).isNotEmpty() }
return next.copy(series = emptyList(), saidGo = emptySet(), turn = lead)
}
// -----------------------------------------------------------------------
// The show
// -----------------------------------------------------------------------
/**
* Counts the hands in order — the dealer's left first, round to the dealer,
* and the crib last of all.
*
* The order is the game: a non-dealer who can count out gets there before
* the dealer's hand and crib are ever counted, and that is what makes being
* behind on the dealer's turn worse than the numbers alone suggest.
*/
private fun theShow(state: CribbageState): CribbageState {
val starter = requireNotNull(state.starter) { "The show without a starter" }
val seats = state.options.playerCount
val entries = (1..seats).map { step ->
val seat = (state.dealer + step) % seats
ShowScore(
seat = seat,
isCrib = false,
cards = state.hands[seat],
lines = CribbageScoring.show(state.hands[seat], starter, isCrib = false),
)
} + ShowScore(
seat = state.dealer,
isCrib = true,
cards = state.crib,
lines = CribbageScoring.show(state.crib, starter, isCrib = true),
)
var next = state
val counted = mutableListOf<ShowScore>()
for (entry in entries) {
if (next.winner != null) {
counted += entry.copy(counted = false)
continue
}
counted += entry
entry.lines.forEach { line ->
next = award(next, entry.seat, line.points, line.label.lowercase())
}
}
return next.copy(
show = counted,
phase = if (next.winner != null) CribbagePhase.GAME_OVER else CribbagePhase.SHOW,
)
}
// -----------------------------------------------------------------------
// Pegging points
// -----------------------------------------------------------------------
/**
* Moves [seat]'s side up the board, and ends the game there if that reaches
* the target.
*
* Everything scored anywhere in the game comes through here, so the game
* cannot be carried past its own finish by the rest of a count.
*/
private fun award(
state: CribbageState,
seat: Int,
points: Int,
reason: String,
): CribbageState {
if (points <= 0 || state.winner != null) return state
val team = state.options.teamOf(seat)
val scores = state.scores.toMutableList()
val previous = state.previousScores.toMutableList()
previous[team] = scores[team]
scores[team] = scores[team] + points
val won = scores[team] >= state.options.pointsToWin
return state.copy(
scores = scores,
previousScores = previous,
winner = if (won) team else null,
phase = if (won) CribbagePhase.GAME_OVER else state.phase,
log = state.log + "Seat $seat pegs $points for $reason.",
)
}
private fun handCounts(state: CribbageState): List<Int> =
(0 until state.options.playerCount).map { seat ->
state.hands[seat].size - state.played.count { it.seat == seat }
}
/** Every way of choosing [size] cards, in the order they are held. */
private fun combinations(cards: List<Card>, size: Int): List<List<Card>> {
if (size == 0) return listOf(emptyList())
if (cards.size < size) return emptyList()
val chosen = mutableListOf<List<Card>>()
fun take(from: Int, soFar: List<Card>) {
if (soFar.size == size) {
chosen += soFar
return
}
for (index in from until cards.size) take(index + 1, soFar + cards[index])
}
take(0, emptyList())
return chosen
}
// -----------------------------------------------------------------------
// Engine plumbing
// -----------------------------------------------------------------------
override fun isFinished(state: CribbageState): Boolean =
state.phase == CribbagePhase.GAME_OVER
override fun summary(state: CribbageState): String {
val board = state.scores.mapIndexed { team, score -> "T$team:$score" }.joinToString(" ")
val winner = state.winner ?: return board
val trailing = state.scores
.filterIndexed { team, _ -> team != winner }
.maxOrNull() ?: 0
return "Team $winner wins ($board)" + skunk(state.options, trailing)
}
/**
* A loser left on the second street is skunked, and one who never left the
* first is double skunked. In a game to 121 those lines are 91 and 61.
*/
private fun skunk(options: CribbageOptions, trailing: Int): String {
if (!options.countSkunks) return ""
return when {
trailing <= options.pointsToWin / 2 -> " — a double skunk"
trailing < options.pointsToWin - options.pointsToWin / 4 -> " — a skunk"
else -> ""
}
}
/**
* Hands and the crib are hidden until the show, and the rest of the pack
* always: the starter is cut from it, so a client that could see it would
* know the cut before it happened.
*/
override fun viewFor(state: CribbageState, seat: Int): CribbageState {
val revealed = state.phase == CribbagePhase.SHOW || state.phase == CribbagePhase.GAME_OVER
return state.copy(
hands = state.hands.mapIndexed { index, hand ->
if (revealed || index == seat) hand else emptyList()
},
crib = if (revealed) state.crib else emptyList(),
deck = emptyList(),
)
}
override fun encodeState(state: CribbageState): String = json.encodeToString(state)
override fun decodeState(json: String): CribbageState = this.json.decodeFromString(json)
override fun encodeMove(move: CribbageMove): String = json.encodeToString(move)
override fun decodeMove(json: String): CribbageMove = this.json.decodeFromString(json)
}
@@ -0,0 +1,149 @@
package org.prolibertate.games.game.cribbage
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
/**
* Everything cribbage counts, as pure arithmetic over cards.
*
* Both halves of the game are here: [show] counts a hand against the starter,
* and [peg] counts what the card just laid on the table scored. They are kept
* apart because they genuinely differ — a flush and his nob are worth nothing
* during the play, and the count reaching fifteen is worth nothing at the show.
*/
object CribbageScoring {
/**
* A hand of four counted with the [starter].
*
* [isCrib] tightens one rule only: a crib scores a flush only when all five
* cards share a suit, where a hand takes four for its own four.
*/
fun show(hand: List<Card>, starter: Card, isCrib: Boolean): List<ScoreLine> {
val all = hand + starter
val lines = mutableListOf<ScoreLine>()
val fifteens = countFifteens(all)
if (fifteens > 0) {
lines += ScoreLine(
label = if (fifteens == 1) "Fifteen" else "$fifteens fifteens",
points = fifteens * 2,
)
}
lines += pairs(all)
lines += runs(all)
flush(hand, starter, isCrib)?.let { lines += it }
if (hand.any { it.rank == Rank.JACK && it.suit == starter.suit }) {
lines += ScoreLine("His nob", 1)
}
return lines
}
/**
* What the last card of [series] scored as it landed.
*
* The series is the run of cards since the count was last cleared, so
* everything here is read off its tail: only cards still on the table
* between one thirty-one and the next can pair or make a run.
*/
fun peg(series: List<Card>): List<ScoreLine> {
if (series.isEmpty()) return emptyList()
val lines = mutableListOf<ScoreLine>()
val count = series.sumOf { pipValue(it) }
if (count == 15) lines += ScoreLine("Fifteen", 2)
if (count == CRIBBAGE_LIMIT) lines += ScoreLine("Thirty-one", 2)
// Pairs peg only off the end: a pair split by somebody else's card is
// not a pair, which is the whole point of playing one to break it up.
val last = series.last()
var matching = 0
for (card in series.asReversed()) {
if (card.rank != last.rank) break
matching++
}
if (matching >= 2) lines += ScoreLine(pairLabel(matching), matching * (matching - 1))
// The longest tail that is a run, in any order — 5-3-4 is a run of three.
for (length in series.size downTo 3) {
val tail = series.takeLast(length).map { runOrder(it) }
if (tail.distinct().size == length && tail.max() - tail.min() == length - 1) {
lines += ScoreLine("Run of $length", length)
break
}
}
return lines
}
/** Every combination adding to fifteen, of any size. */
private fun countFifteens(cards: List<Card>): Int {
var found = 0
for (mask in 1 until (1 shl cards.size)) {
var sum = 0
for (index in cards.indices) {
if ((mask shr index) and 1 == 1) sum += pipValue(cards[index])
}
if (sum == 15) found++
}
return found
}
private fun pairs(cards: List<Card>): List<ScoreLine> = cards
.groupBy { it.rank }
.values
.filter { it.size >= 2 }
.sortedBy { runOrder(it.first()) }
.map { group ->
ScoreLine(
label = "${pairLabel(group.size)} of ${group.first().rank.short}s",
points = group.size * (group.size - 1),
)
}
private fun pairLabel(size: Int): String = when (size) {
2 -> "Pair"
3 -> "Pair royal"
else -> "Double pair royal"
}
/**
* Runs, with duplicated ranks multiplying rather than adding.
*
* 4-5-5-6 is two runs of three and not a run of four: the two fives each
* make their own run with the four and the six, which is why the length is
* multiplied by how many ways each rank in it can be filled.
*/
private fun runs(cards: List<Card>): List<ScoreLine> {
val copies = cards.groupingBy { runOrder(it) }.eachCount()
val ordered = copies.keys.sorted()
val lines = mutableListOf<ScoreLine>()
var index = 0
while (index < ordered.size) {
var end = index
while (end + 1 < ordered.size && ordered[end + 1] == ordered[end] + 1) end++
val length = end - index + 1
if (length >= 3) {
val ways = (index..end).fold(1) { acc, at -> acc * copies.getValue(ordered[at]) }
lines += ScoreLine(
label = if (ways == 1) "Run of $length" else "$ways runs of $length",
points = length * ways,
)
}
index = end + 1
}
return lines
}
private fun flush(hand: List<Card>, starter: Card, isCrib: Boolean): ScoreLine? {
val suit = hand.firstOrNull()?.suit ?: return null
if (hand.any { it.suit != suit }) return null
val withStarter = starter.suit == suit
// A crib takes nothing for four of a suit — it has to be all five.
if (isCrib && !withStarter) return null
val size = hand.size + if (withStarter) 1 else 0
return ScoreLine("Flush of $size", size)
}
}
@@ -98,6 +98,18 @@ class MatchController<S : Any, M : Any>(
private val lock = Mutex()
private var sequence = 0
/**
* A scoreboard a player has read and released, waiting to be let past.
*
* The hold belongs to a particular scoreboard rather than to the phase, and
* releasing it has to be spent: [awaitsConfirmation] is asked about the
* state, which has not changed while the players were reading it, so
* without something consumable here the same state would stop the table
* again the instant [confirmAdvance] restarted the loop — and the next hand
* would never be dealt.
*/
private var released = false
private val peers = mutableMapOf<String, Connection>()
private var hostLink: Connection? = null
@@ -288,9 +300,16 @@ class MatchController<S : Any, M : Any>(
if (role == Role.HOST && awaitsConfirmation(current)) {
// Hold here until a player has read the scoreboard. Only the
// host waits; clients simply render what they were sent.
val letThrough = lock.withLock {
val granted = released
released = false
granted
}
if (!letThrough) {
_awaitingConfirmation.value = true
return
}
}
delay(aiThinkingMillis() * 2)
val advanced = advanceIdle(current)
if (advanced == null) {
@@ -398,7 +417,10 @@ class MatchController<S : Any, M : Any>(
if (!_awaitingConfirmation.value) return
_awaitingConfirmation.value = false
if (role != Role.HOST) return
scope.launch { driveIdleSeats() }
scope.launch {
lock.withLock { released = true }
driveIdleSeats()
}
}
/** Lets a player go on looking at a table they have been told is over. */
@@ -37,6 +37,7 @@ import org.prolibertate.games.game.checkers.CheckersOptions
import org.prolibertate.games.game.chess.ChessLevel
import org.prolibertate.games.game.chess.ChessOptions
import org.prolibertate.games.game.crazyeights.CrazyEightsOptions
import org.prolibertate.games.game.cribbage.CribbageOptions
import org.prolibertate.games.game.euchre.EuchreOptions
import org.prolibertate.games.game.golf.GolfOptions
import org.prolibertate.games.game.kaiser.KaiserOptions
@@ -78,6 +79,7 @@ fun GameSetupScreen(
var golf by remember { mutableStateOf(GolfOptions()) }
var kaiser by remember { mutableStateOf(KaiserOptions()) }
var crazyEights by remember { mutableStateOf(CrazyEightsOptions()) }
var cribbage by remember { mutableStateOf(CribbageOptions()) }
var wizard by remember { mutableStateOf(WizardOptions()) }
var chess by remember { mutableStateOf(ChessOptions()) }
var tayu by remember { mutableStateOf(TayuOptions()) }
@@ -100,6 +102,7 @@ fun GameSetupScreen(
GameCatalog.GOLF -> setupJson.encodeToString(golf)
GameCatalog.KAISER -> setupJson.encodeToString(kaiser)
GameCatalog.CRAZY_EIGHTS -> setupJson.encodeToString(crazyEights)
GameCatalog.CRIBBAGE -> setupJson.encodeToString(cribbage)
GameCatalog.WIZARD -> setupJson.encodeToString(wizard)
GameCatalog.CHESS -> setupJson.encodeToString(chess)
GameCatalog.TAYU -> setupJson.encodeToString(tayu)
@@ -132,6 +135,8 @@ fun GameSetupScreen(
crazyEights = it
}
GameCatalog.CRIBBAGE -> CribbageOptionsEditor(cribbage) { cribbage = it }
GameCatalog.WIZARD -> WizardOptionsEditor(wizard) { wizard = it }
GameCatalog.CHESS -> ChessOptionsEditor(
options = chess,
@@ -204,7 +209,7 @@ private fun offlineConfig(
seat = seat,
name = if (seat == localSeat) playerName else "Computer $seat",
kind = if (seat == localSeat) PlayerKind.HUMAN_LOCAL else PlayerKind.AI,
team = teamForSeat(gameId, seat),
team = teamForSeat(gameId, seat, seatCount),
)
},
optionsJson = optionsJson,
@@ -236,6 +241,9 @@ fun seatCountFor(gameId: String, optionsJson: String): Int {
GameCatalog.CRAZY_EIGHTS ->
setupJson.decodeFromString<CrazyEightsOptions>(optionsJson).playerCount
GameCatalog.CRIBBAGE ->
setupJson.decodeFromString<CribbageOptions>(optionsJson).playerCount
GameCatalog.WIZARD ->
setupJson.decodeFromString<WizardOptions>(optionsJson).playerCount
@@ -250,11 +258,20 @@ fun seatCountFor(gameId: String, optionsJson: String): Int {
}.getOrDefault(fallback)
}
fun teamForSeat(gameId: String, seat: Int): Int = when (gameId) {
/**
* Which side a seat is on.
*
* [seatCount] is here for the games that are only a partnership game at some
* table sizes: cribbage seats two, three or four, and only the four-handed game
* is played in pairs — reading `seat % 2` at a table of three would put the
* first and third players on the same side, which is not a game anybody plays.
*/
fun teamForSeat(gameId: String, seat: Int, seatCount: Int): Int = when (gameId) {
// Partners sit opposite each other. In Ta Yü that is also which pair of
// edges you are running your rivers to: even seats north and south, odd
// seats east and west.
GameCatalog.EUCHRE, GameCatalog.KAISER, GameCatalog.SEQUENCE, GameCatalog.TAYU -> seat % 2
GameCatalog.CRIBBAGE -> if (seatCount == 4) seat % 2 else seat
// Everyone else plays for themselves.
else -> seat
}
@@ -502,6 +519,50 @@ private fun CrazyEightsOptionsEditor(
}
}
@Composable
private fun CribbageOptionsEditor(options: CribbageOptions, onChange: (CribbageOptions) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ChipRow(
label = "Players",
values = listOf(2, 3, 4),
selected = options.playerCount,
display = { count ->
when (count) {
2 -> "2, head to head"
3 -> "3, every hand for itself"
else -> "4, in partnerships"
}
},
onSelect = { onChange(options.copy(playerCount = it)) },
)
ChipRow(
label = "Game to",
values = listOf(61, 121),
selected = options.pointsToWin,
display = { if (it == 61) "61, once round" else "121, twice round" },
onSelect = { onChange(options.copy(pointsToWin = it)) },
)
ToggleRow(
title = "Call the skunk",
subtitle = "Say so at the finish when the loser never got off the second street",
checked = options.countSkunks,
onChange = { onChange(options.copy(countSkunks = it)) },
)
val shortCrib = if (options.playerCount == 3) {
", with a fourth dealt straight into it"
} else {
""
}
Text(
text = "${options.dealSize} cards each, and everyone lays " +
"${options.layAwaySize} away to the dealer's crib$shortCrib. Hands are " +
"counted from the dealer's left, so a game can be won before the " +
"dealer counts at all.",
style = MaterialTheme.typography.bodySmall,
)
}
}
@Composable
private fun WizardOptionsEditor(options: WizardOptions, onChange: (WizardOptions) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -53,7 +53,7 @@ fun LobbyScreen(
optionsJson = route.optionsJson,
hostName = playerName,
seatCount = seatCount,
teamOf = { seat -> teamForSeat(route.gameId, seat) },
teamOf = { seat -> teamForSeat(route.gameId, seat, seatCount) },
)
} else {
env.lobby.startDiscovery()
@@ -16,6 +16,11 @@ import org.prolibertate.games.game.crazyeights.CrazyEightsMove
import org.prolibertate.games.game.crazyeights.CrazyEightsPhase
import org.prolibertate.games.game.crazyeights.CrazyEightsRules
import org.prolibertate.games.game.crazyeights.CrazyEightsState
import org.prolibertate.games.game.cribbage.CribbageAi
import org.prolibertate.games.game.cribbage.CribbageMove
import org.prolibertate.games.game.cribbage.CribbagePhase
import org.prolibertate.games.game.cribbage.CribbageRules
import org.prolibertate.games.game.cribbage.CribbageState
import org.prolibertate.games.game.euchre.EuchreAi
import org.prolibertate.games.game.euchre.EuchreMove
import org.prolibertate.games.game.euchre.EuchrePhase
@@ -75,6 +80,7 @@ import org.prolibertate.games.ui.game.BackgammonScreen
import org.prolibertate.games.ui.game.CheckersScreen
import org.prolibertate.games.ui.game.ChessScreen
import org.prolibertate.games.ui.game.CrazyEightsScreen
import org.prolibertate.games.ui.game.CribbageScreen
import org.prolibertate.games.ui.game.EuchreScreen
import org.prolibertate.games.ui.game.GolfScreen
import org.prolibertate.games.ui.game.KaiserScreen
@@ -308,6 +314,33 @@ fun PlayScreen(
CrazyEightsScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
}
GameCatalog.CRIBBAGE -> {
val controller = remember(route) {
MatchController<CribbageState, CribbageMove>(
rules = CribbageRules,
ai = CribbageAi(),
config = route.config,
scope = scope,
role = role,
localSeats = setOf(route.localSeat),
primarySeat = route.localSeat,
advanceIdle = { state ->
if (state.phase == CribbagePhase.SHOW) {
CribbageRules.nextHand(state)
} else {
null
}
},
// The show is the half of cribbage that is read rather than
// played, so the table stops on it until somebody says go on.
awaitsConfirmation = { state -> state.phase == CribbagePhase.SHOW },
aiThinkingMillis = { settings.scaled(700L) },
)
}
StartMatch(env, controller, route)
CribbageScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
}
GameCatalog.TAYU -> {
val controller = remember(route) {
MatchController<TayuState, TayuMove>(
@@ -0,0 +1,545 @@
package org.prolibertate.games.ui.game
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cribbage.CribbageMove
import org.prolibertate.games.game.cribbage.CribbagePhase
import org.prolibertate.games.game.cribbage.CribbageState
import org.prolibertate.games.game.cribbage.LayAway
import org.prolibertate.games.game.cribbage.PegCard
import org.prolibertate.games.game.cribbage.ShowScore
import org.prolibertate.games.net.MatchController
import org.prolibertate.games.ui.ScreenScaffold
import org.prolibertate.games.ui.common.CardBackView
import org.prolibertate.games.ui.common.PlayingCardView
import org.prolibertate.games.ui.theme.FeltGreenDark
import org.prolibertate.games.ui.theme.TeamColours
/**
* The cribbage table.
*
* Two things have to be legible at a glance, because the whole game turns on
* them: the count, which decides what can be played at all, and where the pegs
* are. The board is drawn as a track per side with both pegs on it — the pale
* one is where that side stood before its last score, which is exactly what the
* back peg on a real board is for.
*/
@Composable
fun CribbageScreen(
controller: MatchController<CribbageState, CribbageMove>,
localSeat: Int,
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val legal by controller.legalMoves.collectAsState()
val current = state
if (current == null) {
ScreenScaffold(title = "Cribbage", onBack = onExit) { modifier ->
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
}
// Cards picked out for the crib, held here until they are laid away
// together. Forgotten whenever a new hand is dealt.
var chosen by remember(current.handNumber) { mutableStateOf(emptySet<Card>()) }
ScreenScaffold(title = "Cribbage", onBack = onExit) { modifier ->
BoxWithConstraints(modifier = modifier) {
val cardWidth = (minOf(maxWidth, maxHeight) * 0.16f).coerceIn(44.dp, 92.dp)
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
PegBoard(current, localSeat)
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(FeltGreenDark),
contentAlignment = Alignment.Center,
) {
TableArea(current, localSeat, cardWidth)
}
StatusLine(current, localSeat)
if (current.phase == CribbagePhase.DISCARD) {
LayAwayControls(
state = current,
localSeat = localSeat,
chosen = chosen,
onLayAway = {
controller.submit(LayAway.of(chosen))
chosen = emptySet()
},
)
}
HandRow(
state = current,
localSeat = localSeat,
legal = legal,
cardWidth = cardWidth,
chosen = chosen,
onTap = { card ->
when (current.phase) {
CribbagePhase.DISCARD -> {
chosen = if (card in chosen) {
chosen - card
} else if (chosen.size < current.options.layAwaySize) {
chosen + card
} else {
// Already holding a full lay-away: the newest
// tap replaces the oldest rather than being
// ignored, so no card has to be untapped first.
chosen.drop(1).toSet() + card
}
}
CribbagePhase.PLAY -> controller.submit(PegCard(card))
else -> Unit
}
},
)
}
if (current.phase == CribbagePhase.SHOW || current.phase == CribbagePhase.GAME_OVER) {
ShowDialog(
state = current,
localSeat = localSeat,
onContinue = { controller.confirmAdvance() },
onExit = onExit,
)
}
}
}
}
// ---------------------------------------------------------------------------
// The board
// ---------------------------------------------------------------------------
@Composable
private fun PegBoard(state: CribbageState, localSeat: Int) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
(0 until state.options.teamCount).forEach { team ->
val ours = team == state.options.teamOf(localSeat)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = sideLabel(state, team, localSeat),
style = MaterialTheme.typography.labelLarge,
fontWeight = if (ours) FontWeight.Bold else FontWeight.Normal,
)
Text(
text = "${state.scores[team]} of ${state.options.pointsToWin}",
style = MaterialTheme.typography.labelLarge,
)
}
PegTrack(
score = state.scores[team],
previous = state.previousScores[team],
target = state.options.pointsToWin,
colour = TeamColours[team % TeamColours.size],
)
}
}
}
/** One street of the board: the holes, the back peg and the front peg. */
@Composable
private fun PegTrack(score: Int, previous: Int, target: Int, colour: Color) {
val holes = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f)
Canvas(modifier = Modifier.fillMaxWidth().height(14.dp)) {
val middle = size.height / 2f
val pegSize = size.height * 0.34f
val track = size.height * 0.16f
fun at(points: Int): Float =
size.width * (points.coerceIn(0, target).toFloat() / target.toFloat())
drawLine(
color = holes,
start = Offset(0f, middle),
end = Offset(size.width, middle),
strokeWidth = track,
)
// A hole every five, as they are grouped on a real board.
var mark = 5
while (mark <= target) {
drawCircle(color = holes, radius = pegSize * 0.3f, center = Offset(at(mark), middle))
mark += 5
}
drawLine(
color = colour,
start = Offset(0f, middle),
end = Offset(at(score), middle),
strokeWidth = track,
)
drawCircle(
color = colour.copy(alpha = 0.45f),
radius = pegSize,
center = Offset(at(previous), middle),
)
drawCircle(color = colour, radius = pegSize, center = Offset(at(score), middle))
}
}
// ---------------------------------------------------------------------------
// The table
// ---------------------------------------------------------------------------
@Composable
private fun TableArea(state: CribbageState, localSeat: Int, cardWidth: Dp) {
val ink = MaterialTheme.colorScheme.onPrimary
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(8.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Starter", style = MaterialTheme.typography.labelSmall, color = ink)
val starter = state.starter
if (starter == null) {
CardBackView(width = cardWidth)
} else {
PlayingCardView(card = starter, width = cardWidth)
}
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "${cribOwner(state, localSeat)} crib",
style = MaterialTheme.typography.labelSmall,
color = ink,
)
Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
if (state.crib.isEmpty()) {
repeat(state.cribCount) { CardBackView(width = cardWidth * 0.45f) }
} else {
// Face up from the show onwards, when it is counted.
state.crib.forEach { PlayingCardView(card = it, width = cardWidth * 0.45f) }
}
}
}
}
if (state.phase == CribbagePhase.PLAY || state.played.isNotEmpty()) {
Text(
text = "The count is ${state.count}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = ink,
)
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(3.dp),
) {
if (state.series.isEmpty()) {
Text(
text = "A fresh count.",
style = MaterialTheme.typography.labelSmall,
color = ink,
)
}
state.series.forEach { pegged ->
PlayingCardView(card = pegged.card, width = cardWidth * 0.7f)
}
}
}
OpponentCards(state, localSeat, ink)
}
}
@Composable
private fun OpponentCards(state: CribbageState, localSeat: Int, ink: Color) {
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
(0 until state.options.playerCount)
.filter { it != localSeat }
.forEach { seat ->
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = seatLabel(seat, localSeat) +
if (seat == state.dealer) " (deals)" else "",
style = MaterialTheme.typography.labelSmall,
color = ink,
fontWeight = if (seat == state.turn && !state.saidGo.contains(seat)) {
FontWeight.Bold
} else {
FontWeight.Normal
},
)
Row(horizontalArrangement = Arrangement.spacedBy(1.dp)) {
repeat(state.handCounts.getOrElse(seat) { 0 }) {
CardBackView(width = 14.dp)
}
}
}
}
}
}
// ---------------------------------------------------------------------------
// Your side of the table
// ---------------------------------------------------------------------------
@Composable
private fun StatusLine(state: CribbageState, localSeat: Int) {
val yours = state.turn == localSeat &&
(state.phase == CribbagePhase.DISCARD || state.phase == CribbagePhase.PLAY)
val headline = when {
state.phase == CribbagePhase.DISCARD && yours ->
"Choose ${state.options.layAwaySize} for ${cribOwner(state, localSeat).lowercase()} crib"
state.phase == CribbagePhase.DISCARD ->
"${seatLabel(state.turn, localSeat)} is choosing for the crib…"
state.phase == CribbagePhase.PLAY && yours -> "Your turn — the count is ${state.count}"
state.phase == CribbagePhase.PLAY -> "${seatLabel(state.turn, localSeat)} to play…"
else -> "The show"
}
Column {
Text(headline, fontWeight = FontWeight.Bold)
state.log.lastOrNull()?.let {
Text(it, style = MaterialTheme.typography.bodySmall)
}
}
}
@Composable
private fun LayAwayControls(
state: CribbageState,
localSeat: Int,
chosen: Set<Card>,
onLayAway: () -> Unit,
) {
if (state.turn != localSeat) return
val wanted = state.options.layAwaySize
Button(
onClick = onLayAway,
enabled = chosen.size == wanted,
modifier = Modifier.fillMaxWidth(),
) {
Text(
if (chosen.size == wanted) {
"Lay away ${chosen.joinToString(" ") { it.label }}"
} else {
"Pick ${wanted - chosen.size} more"
}
)
}
}
@Composable
private fun HandRow(
state: CribbageState,
localSeat: Int,
legal: List<CribbageMove>,
cardWidth: Dp,
chosen: Set<Card>,
onTap: (Card) -> Unit,
) {
val hand = when {
localSeat !in state.hands.indices -> emptyList()
state.phase == CribbagePhase.DISCARD -> state.hands[localSeat]
// Cards stay in the hand once pegged so they can be counted again at
// the show, so what is still holdable has to be asked for.
else -> state.remaining(localSeat)
}
val playable = legal.filterIsInstance<PegCard>().map { it.card }.toSet()
Column {
Text(
text = if (state.dealer == localSeat) "Your hand — your crib" else "Your hand",
style = MaterialTheme.typography.labelLarge,
)
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (hand.isEmpty()) Text("—", style = MaterialTheme.typography.bodySmall)
hand.forEach { card ->
val enabled = when (state.phase) {
CribbagePhase.DISCARD -> state.turn == localSeat
CribbagePhase.PLAY -> card in playable
else -> false
}
PlayingCardView(
card = card,
width = cardWidth,
enabled = enabled,
selected = card in chosen,
onClick = { onTap(card) },
)
}
}
}
}
// ---------------------------------------------------------------------------
// The show
// ---------------------------------------------------------------------------
@Composable
private fun ShowDialog(
state: CribbageState,
localSeat: Int,
onContinue: () -> Unit,
onExit: () -> Unit,
) {
var dismissed by remember(state.handNumber, state.phase) { mutableStateOf(false) }
if (dismissed) return
val over = state.phase == CribbagePhase.GAME_OVER
AlertDialog(
onDismissRequest = { },
title = {
Text(
text = when {
over && state.winner == state.options.teamOf(localSeat) -> "You win"
over -> "${sideLabel(state, state.winner ?: 0, localSeat)} wins"
else -> "The show"
}
)
},
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
state.starter?.let {
Text("Starter ${it.label}", style = MaterialTheme.typography.bodySmall)
}
if (state.show.isEmpty()) {
Text(
text = "Pegged out during the play — the hands were never counted.",
style = MaterialTheme.typography.bodyMedium,
)
}
state.show.forEach { entry -> ShowEntry(entry, localSeat) }
(0 until state.options.teamCount).forEach { team ->
Row(modifier = Modifier.fillMaxWidth()) {
Text(
text = sideLabel(state, team, localSeat),
modifier = Modifier.weight(1f),
fontWeight = FontWeight.Bold,
)
Text("${state.scores[team]}", fontWeight = FontWeight.Bold)
}
}
}
},
confirmButton = {
TextButton(onClick = {
dismissed = true
if (over) onExit() else onContinue()
}) {
Text(if (over) "Back to the menu" else "Deal the next hand")
}
},
)
}
@Composable
private fun ShowEntry(entry: ShowScore, localSeat: Int) {
val who = when {
entry.isCrib && entry.seat == localSeat -> "Your crib"
entry.isCrib -> "Seat ${entry.seat}'s crib"
else -> seatLabel(entry.seat, localSeat)
}
Column {
Row(modifier = Modifier.fillMaxWidth()) {
Text(
text = "$who — ${entry.cards.joinToString(" ") { it.label }}",
modifier = Modifier.weight(1f),
fontWeight = FontWeight.Bold,
)
Text(if (entry.counted) "${entry.total}" else "—", fontWeight = FontWeight.Bold)
}
if (!entry.counted) {
Text(
text = "Never counted — the game was already won. Would have been " +
"${entry.total}.",
style = MaterialTheme.typography.bodySmall,
)
}
if (entry.lines.isEmpty()) {
Text("Nineteen — nothing at all.", style = MaterialTheme.typography.bodySmall)
}
entry.lines.forEach { line ->
Row(modifier = Modifier.fillMaxWidth()) {
Text(
text = line.label,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
)
Text("${line.points}", style = MaterialTheme.typography.bodySmall)
}
}
}
}
// ---------------------------------------------------------------------------
private fun seatLabel(seat: Int, localSeat: Int): String =
if (seat == localSeat) "You" else "Seat $seat"
/** Whose crib this hand is, in the second person where that is the answer. */
private fun cribOwner(state: CribbageState, localSeat: Int): String =
if (state.dealer == localSeat) "Your" else "Seat ${state.dealer}'s"
private fun sideLabel(state: CribbageState, team: Int, localSeat: Int): String = when {
state.options.playerCount == 4 ->
if (team == state.options.teamOf(localSeat)) "Your side" else "The other side"
team == localSeat -> "You"
else -> "Seat $team"
}
@@ -70,6 +70,7 @@ class CatalogTest {
GameCatalog.GOLF,
GameCatalog.KAISER,
GameCatalog.CRAZY_EIGHTS,
GameCatalog.CRIBBAGE,
GameCatalog.WIZARD,
GameCatalog.CHESS,
GameCatalog.TAYU,
@@ -0,0 +1,533 @@
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)
}
@Test
fun `a partnership plays for its own crib and not just its own hand`() {
// Four-handed, with the two AI seats facing each other. The lay-away is
// where a partner's crib has to count as your own, so a side that
// treated it as an opponent's would be giving away half its own cribs.
val ai = CribbageAi()
var aiWins = 0
repeat(8) { iteration ->
val random = Random(900L + iteration)
val options = CribbageOptions(playerCount = 4, pointsToWin = 61)
var state = CribbageRules.initialState(config(options, seed = 500L + iteration))
var guard = 0
while (!CribbageRules.isFinished(state) && guard++ < 40000) {
val seat = CribbageRules.currentSeat(state)
if (seat == null) {
state = CribbageRules.nextHand(state)
continue
}
val legal = CribbageRules.legalMoves(state, seat)
val move = if (options.teamOf(seat) == 0) {
ai.chooseMove(state, seat, legal)
} else {
legal[random.nextInt(legal.size)]
}
state = CribbageRules.applyMove(state, seat, move)
}
if (state.winner == 0) aiWins++
}
assertTrue("the ai partnership won $aiWins of eight", aiWins > 4)
}
// -- 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)
}
}
@@ -0,0 +1,151 @@
package org.prolibertate.games.net
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.engine.GameAi
import org.prolibertate.games.game.engine.GameRules
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
/**
* The pause between hands, on a table with nobody but the local player at it.
*
* Cribbage's show, Golf's card and Wizard's bids-against-tricks all stop the
* table on a scoreboard and wait to be released. The release has to be spent:
* the state does not change while it is being read, so a controller that only
* re-asks `awaitsConfirmation` about it stops on the same scoreboard again the
* moment it is restarted, and the next hand is never dealt.
*/
class MatchControllerScoreboardTest {
private val scopes = mutableListOf<CoroutineScope>()
@After
fun tearDown() {
scopes.forEach { it.cancel() }
}
/** A hand is one move long, and every hand ends on a scoreboard. */
private data class Hands(val handNumber: Int, val scoring: Boolean)
private object Rules : GameRules<Hands, String> {
override val gameId: String = "scoreboard"
override fun initialState(config: TableConfig) = Hands(0, scoring = false)
override fun currentSeat(state: Hands): Int? = if (state.scoring) null else 0
override fun legalMoves(state: Hands, seat: Int): List<String> =
if (state.scoring) emptyList() else listOf("play")
override fun applyMove(state: Hands, seat: Int, move: String) =
state.copy(scoring = true)
override fun isFinished(state: Hands) = state.handNumber >= HANDS
override fun summary(state: Hands) = state.toString()
override fun viewFor(state: Hands, seat: Int) = state
override fun encodeState(state: Hands) = "${state.handNumber}:${state.scoring}"
override fun decodeState(json: String) = Hands(
handNumber = json.substringBefore(':').toInt(),
scoring = json.substringAfter(':').toBooleanStrict(),
)
override fun encodeMove(move: String) = move
override fun decodeMove(json: String) = json
}
private fun controller(): MatchController<Hands, String> {
val scope = CoroutineScope(Job() + Dispatchers.Default).also { scopes += it }
return MatchController(
rules = Rules,
ai = object : GameAi<Hands, String> {
override fun chooseMove(state: Hands, seat: Int, legal: List<String>) = legal.first()
},
config = TableConfig(
gameId = "scoreboard",
seats = listOf(PlayerSlot(0, "Me", PlayerKind.HUMAN_LOCAL, team = 0)),
optionsJson = "{}",
seed = 1L,
),
scope = scope,
role = MatchController.Role.HOST,
localSeats = setOf(0),
primarySeat = 0,
advanceIdle = { state ->
if (state.scoring) {
Hands(state.handNumber + 1, scoring = false)
} else {
null
}
},
aiThinkingMillis = { 0L },
awaitsConfirmation = { state -> state.scoring },
)
}
@Test
fun `the table stops on a scoreboard`() = runBlocking {
val table = controller()
table.startAsHost(emptyList())
settle()
assertFalse("nothing to read yet", table.awaitingConfirmation.value)
table.submit("play")
settle()
assertTrue("the scoreboard was skipped past", table.awaitingConfirmation.value)
assertEquals(Hands(0, scoring = true), table.state.value)
}
@Test
fun `confirming it deals the next hand`() = runBlocking {
val table = controller()
table.startAsHost(emptyList())
settle()
table.submit("play")
settle()
table.confirmAdvance()
settle()
assertEquals(
"the table is still sitting on the scoreboard it was told to leave",
Hands(1, scoring = false),
table.state.value,
)
assertFalse(table.awaitingConfirmation.value)
assertTrue("and the next hand is playable", table.legalMoves.value.isNotEmpty())
}
@Test
fun `every hand in the game stops and is released in turn`() = runBlocking {
val table = controller()
table.startAsHost(emptyList())
settle()
repeat(HANDS) { hand ->
table.submit("play")
settle()
assertTrue("hand $hand did not stop to be read", table.awaitingConfirmation.value)
table.confirmAdvance()
settle()
}
assertTrue(table.finished.value)
assertEquals(HANDS, table.state.value?.handNumber)
}
private suspend fun settle() = delay(SETTLE_MILLIS)
private companion object {
const val HANDS = 3
const val SETTLE_MILLIS = 250L
}
}
+118
View File
@@ -0,0 +1,118 @@
# Cribbage — implemented rules
This is the ruleset the engine enforces. Anything marked **toggle** is in the
game setup screen.
## Table
- 2, 3 or 4 players — **toggle**.
- **Two** play head to head, six cards each, laying two away.
- **Three** play singly, five cards each, laying one away — and a fourth card
is dealt straight into the crib, which is the only way three hands can fill
it.
- **Four** play in partnerships, sitting across from each other. Five cards
each, one away, and **the two partners peg on one score**: your partner's
crib is your crib, and their points are yours.
- One 52-card deck. The deal moves one seat to the left each hand.
- Game to **121** — twice round the board — or **61** — once — **toggle**.
Whatever the table, everybody keeps four cards and the crib holds four.
## The cut
Once everyone has laid away, the starter is cut and turned up. It belongs to
every hand and to the crib at the show.
**A jack turned is two for his heels**, and they are the dealer's however the
cut fell.
## The play
Starting at the dealer's left, cards are laid down one at a time and the count
runs up towards **thirty-one, which it may never pass**. A card that would take
it past cannot be played.
Scored as the card lands:
| | |
| --- | --- |
| Count reaches fifteen | 2 |
| Count reaches thirty-one | 2 |
| Pair | 2 |
| Third of a rank (pair royal) | 6 |
| Fourth of a rank (double pair royal) | 12 |
| Run of three or more | 1 a card |
Pairs and runs are read off the **end of the cards on the table**, so a pair
split by somebody else's card is not a pair, and a run counts however jumbled
the order it was laid in — 5, 3, 4 is a run of three.
**A player who cannot play says go**, and the table plays past them. When
nobody can play at all, whoever laid the last card takes **one for the last
card**, the count is cleared, and the seat after them starts the next one.
Thirty-one has already paid two, so no extra point is taken for it.
A go is called by the engine rather than tapped: the count only ever rises
within a series, so a hand that cannot play now cannot play later in it either,
and there is nothing to decide.
## The show
The hands are counted **from the dealer's left, round to the dealer, and the
dealer's crib last of all**. Each hand is its four cards plus the starter.
| | |
| --- | --- |
| Each combination of cards adding to fifteen | 2 |
| Each pair | 2 |
| Each run of three or more | 1 a card |
| Four cards of one suit in hand | 4 |
| …with the starter matching | 5 |
| Jack in hand of the starter's suit (his nob) | 1 |
Duplicated ranks multiply runs rather than lengthening them: 4-5-5-6 is **two
runs of three**, not a run of four.
**A crib takes nothing for four of a suit.** It scores a flush only when all
five cards, starter included, share one — the one rule that makes counting a
crib different from counting a hand.
The best hand in the game is twenty-nine: three fives and the jack of the suit
that is cut, with the fourth five as the starter.
## Winning
**The game ends on the point that reaches the target, mid-count if that is
where it falls.** A non-dealer who can count out has won before the dealer's
hand and crib are ever counted, and the table says so — hands the game never
got to are shown with what they would have been worth, greyed out rather than
added on.
**Skunks** — **toggle**, on by default. A loser left on the second street is
skunked, and one who never left the first is double skunked. In a game to 121
those lines are 91 and 61. It is a way of describing the result, not extra
points.
## What is not here
- **Muggins.** Points missed at the show cannot be claimed by an opponent,
because they are never missed: the engine counts every hand in full. The rule
exists to punish bad arithmetic, and there is none to punish.
- **The five-card game.** The old two-handed game — five cards each, a crib of
four, three for last, game to 61 — is a different game rather than an option,
and this is the six-card one.
- **Cutting for the deal.** Seat 0 deals the first hand and the deal moves left
from there.
## What the computer opponent does
The lay-away is worked out rather than guessed. For every way of keeping four,
it scores that hand against **all forty-six cards that could still be cut** and
compares the averages. What goes to the crib is valued separately and then
added or subtracted depending on whose crib it is — a five, a pair or a fifteen
laid away is a gift to your own crib and a present to the other side's. At four
players your partner's crib counts as your own.
In the play it takes the points on offer, and otherwise tries not to leave the
count at five or twenty-one, where any of the sixteen ten-cards collects fifteen
or thirty-one. It will not lead a five for the same reason.