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:
Claude
2026-08-07 19:07:19 +00:00
parent 0e23d752c4
commit 27af034095
14 changed files with 2400 additions and 5 deletions
@@ -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)
}
}
@@ -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"
}