From 27af03409551d9281bb5931892ba7d80cbb504bd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 19:07:19 +0000 Subject: [PATCH 1/5] Add cribbage for two, three or four players MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01DCuRESmSZeJoJond43itw2 --- README.md | 7 +- .../org/prolibertate/games/game/Catalog.kt | 13 + .../games/game/cribbage/CribbageAi.kt | 104 ++++ .../games/game/cribbage/CribbageModel.kt | 172 ++++++ .../games/game/cribbage/CribbageRules.kt | 421 ++++++++++++++ .../games/game/cribbage/CribbageScoring.kt | 149 +++++ .../prolibertate/games/ui/GameSetupScreen.kt | 65 ++- .../org/prolibertate/games/ui/LobbyScreen.kt | 2 +- .../org/prolibertate/games/ui/PlayScreen.kt | 33 ++ .../games/ui/game/CribbageScreen.kt | 545 ++++++++++++++++++ .../prolibertate/games/game/CatalogTest.kt | 1 + .../games/game/cribbage/CribbageRulesTest.kt | 500 ++++++++++++++++ .../game/cribbage/CribbageScoringTest.kt | 275 +++++++++ docs/RULES-cribbage.md | 118 ++++ 14 files changed, 2400 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/org/prolibertate/games/game/cribbage/CribbageAi.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/cribbage/CribbageModel.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/cribbage/CribbageRules.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/cribbage/CribbageScoring.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/CribbageScreen.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/cribbage/CribbageRulesTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/cribbage/CribbageScoringTest.kt create mode 100644 docs/RULES-cribbage.md diff --git a/README.md b/README.md index b71f5f2..11a50e8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/src/main/java/org/prolibertate/games/game/Catalog.kt b/app/src/main/java/org/prolibertate/games/game/Catalog.kt index e91558c..63a926b 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -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", diff --git a/app/src/main/java/org/prolibertate/games/game/cribbage/CribbageAi.kt b/app/src/main/java/org/prolibertate/games/game/cribbage/CribbageAi.kt new file mode 100644 index 0000000..7db04e3 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/cribbage/CribbageAi.kt @@ -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 { + + override fun chooseMove( + state: CribbageState, + seat: Int, + legal: List, + ): CribbageMove { + require(legal.isNotEmpty()) { "No legal move for seat $seat" } + val layAways = legal.filterIsInstance() + if (layAways.isNotEmpty()) return chooseLayAway(state, seat, layAways) + return choosePeg(state, legal.filterIsInstance()) + } + + private fun chooseLayAway( + state: CribbageState, + seat: Int, + choices: List, + ): 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): 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 { + 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() + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/cribbage/CribbageModel.kt b/app/src/main/java/org/prolibertate/games/game/cribbage/CribbageModel.kt new file mode 100644 index 0000000..29f3cd2 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/cribbage/CribbageModel.kt @@ -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, + val lines: List, + 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>, + /** Cards still held per seat, which survives redaction where [hands] does not. */ + val handCounts: List, + val crib: List, + val cribCount: Int, + /** What is left of the pack. Never sent to anyone. */ + val deck: List, + val starter: Card?, + /** Everything pegged this hand, in order. */ + val played: List, + /** The cards of the series being counted now, which is cleared at thirty-one or a go. */ + val series: List, + val saidGo: Set, + val turn: Int, + val lastToPlay: Int?, + /** By team. Two or three play singly, so there a team is a seat. */ + val scores: List, + /** Where the back peg stands: each side's score before its last award. */ + val previousScores: List, + val show: List, + val phase: CribbagePhase, + val winner: Int?, + val log: List, +) { + /** 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 { + 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) : CribbageMove { + companion object { + fun of(cards: Collection): 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 = + compareBy({ it.suit.ordinal }, { it.rank.ordinal }) diff --git a/app/src/main/java/org/prolibertate/games/game/cribbage/CribbageRules.kt b/app/src/main/java/org/prolibertate/games/game/cribbage/CribbageRules.kt new file mode 100644 index 0000000..474164b --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/cribbage/CribbageRules.kt @@ -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 { + + override val gameId: String = GameCatalog.CRIBBAGE + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + override fun initialState(config: TableConfig): CribbageState { + val options = json.decodeFromString(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, + log: List, + ): CribbageState { + val random = Random(seed + handNumber * 7919L) + var pack = Decks.standard52().shuffledWith(random) + + val hands = MutableList(options.playerCount) { emptyList() } + // 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 { + 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() + 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 = + (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, size: Int): List> { + if (size == 0) return listOf(emptyList()) + if (cards.size < size) return emptyList() + + val chosen = mutableListOf>() + fun take(from: Int, soFar: List) { + 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) +} diff --git a/app/src/main/java/org/prolibertate/games/game/cribbage/CribbageScoring.kt b/app/src/main/java/org/prolibertate/games/game/cribbage/CribbageScoring.kt new file mode 100644 index 0000000..b8fb080 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/cribbage/CribbageScoring.kt @@ -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, starter: Card, isCrib: Boolean): List { + val all = hand + starter + val lines = mutableListOf() + + 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): List { + if (series.isEmpty()) return emptyList() + val lines = mutableListOf() + 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): 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): List = 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): List { + val copies = cards.groupingBy { runOrder(it) }.eachCount() + val ordered = copies.keys.sorted() + val lines = mutableListOf() + + 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, 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) + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt index 8890dfe..1e03faa 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -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(optionsJson).playerCount + GameCatalog.CRIBBAGE -> + setupJson.decodeFromString(optionsJson).playerCount + GameCatalog.WIZARD -> setupJson.decodeFromString(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)) { diff --git a/app/src/main/java/org/prolibertate/games/ui/LobbyScreen.kt b/app/src/main/java/org/prolibertate/games/ui/LobbyScreen.kt index 9a001f2..d31e32d 100644 --- a/app/src/main/java/org/prolibertate/games/ui/LobbyScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/LobbyScreen.kt @@ -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() diff --git a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt index c29eba2..56ecd49 100644 --- a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt @@ -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( + 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( diff --git a/app/src/main/java/org/prolibertate/games/ui/game/CribbageScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/CribbageScreen.kt new file mode 100644 index 0000000..13400a4 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/CribbageScreen.kt @@ -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, + 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()) } + + 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, + 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, + cardWidth: Dp, + chosen: Set, + 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().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" +} diff --git a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt index 107be12..80766a4 100644 --- a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt @@ -70,6 +70,7 @@ class CatalogTest { GameCatalog.GOLF, GameCatalog.KAISER, GameCatalog.CRAZY_EIGHTS, + GameCatalog.CRIBBAGE, GameCatalog.WIZARD, GameCatalog.CHESS, GameCatalog.TAYU, diff --git a/app/src/test/java/org/prolibertate/games/game/cribbage/CribbageRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/cribbage/CribbageRulesTest.kt new file mode 100644 index 0000000..7ad541e --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/cribbage/CribbageRulesTest.kt @@ -0,0 +1,500 @@ +package org.prolibertate.games.game.cribbage + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.cards.Rank +import org.prolibertate.games.game.cards.Suit +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig +import kotlin.random.Random + +class CribbageRulesTest { + + private val json = Json { encodeDefaults = true } + + private fun config( + options: CribbageOptions = CribbageOptions(), + seed: Long = 21L, + ) = TableConfig( + gameId = "cribbage", + seats = (0 until options.playerCount).map { + PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = options.teamOf(it)) + }, + optionsJson = json.encodeToString(options), + seed = seed, + ) + + private fun c(rank: Rank, suit: Suit) = Card(rank, suit) + + /** A table part-way through the play, built rather than dealt. */ + private fun playing( + hands: List>, + options: CribbageOptions = CribbageOptions(playerCount = hands.size), + dealer: Int = hands.size - 1, + turn: Int = 0, + starter: Card = c(Rank.NINE, Suit.CLUBS), + played: List = emptyList(), + series: List = played, + crib: List = emptyList(), + scores: List = 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(PegCard(c(Rank.TWO, Suit.HEARTS))), + CribbageRules.legalMoves(state, 0), + ) + } + + @Test + fun `the last card is worth one and the next series starts beyond it`() { + val state = playing( + hands = listOf( + listOf( + c(Rank.KING, Suit.SPADES), c(Rank.TEN, Suit.HEARTS), + c(Rank.NINE, Suit.DIAMONDS), c(Rank.EIGHT, Suit.CLUBS), + ), + listOf( + c(Rank.QUEEN, Suit.CLUBS), c(Rank.JACK, Suit.SPADES), + c(Rank.TEN, Suit.DIAMONDS), c(Rank.NINE, Suit.HEARTS), + ), + ), + played = listOf( + PeggedCard(0, c(Rank.KING, Suit.SPADES)), + PeggedCard(1, c(Rank.QUEEN, Suit.CLUBS)), + ), + turn = 0, + ) + assertEquals(20, state.count) + + val after = CribbageRules.applyMove(state, 0, PegCard(c(Rank.TEN, Suit.HEARTS))) + + // Thirty on the table and nobody holds a card small enough, so seat 0 + // takes one for the last card and the count is cleared. + assertEquals(1, after.scores[0]) + assertEquals(0, after.scores[1]) + assertEquals(0, after.count) + assertTrue(after.series.isEmpty()) + assertTrue(after.saidGo.isEmpty()) + assertEquals("the seat after the last card leads", 1, after.turn) + assertTrue(after.log.any { it.contains("says go") }) + } + + @Test + fun `thirty-one pays two and nothing more for the go`() { + val state = playing( + hands = listOf( + listOf( + c(Rank.FIVE, Suit.SPADES), c(Rank.TWO, Suit.HEARTS), + c(Rank.THREE, Suit.DIAMONDS), c(Rank.FOUR, Suit.CLUBS), + ), + listOf( + c(Rank.KING, Suit.CLUBS), c(Rank.QUEEN, Suit.SPADES), + c(Rank.SIX, Suit.HEARTS), c(Rank.SEVEN, Suit.DIAMONDS), + ), + ), + played = listOf( + PeggedCard(0, c(Rank.FOUR, Suit.CLUBS)), + PeggedCard(1, c(Rank.KING, Suit.CLUBS)), + PeggedCard(0, c(Rank.TWO, Suit.HEARTS)), + PeggedCard(1, c(Rank.QUEEN, Suit.SPADES)), + ), + turn = 0, + ) + assertEquals(26, state.count) + + val after = CribbageRules.applyMove(state, 0, PegCard(c(Rank.FIVE, Suit.SPADES))) + + assertEquals("two for thirty-one, and no point for the go on top", 2, after.scores[0]) + assertEquals(0, after.count) + assertEquals(1, after.turn) + } + + @Test + fun `pegging out ends the game in the middle of the play`() { + val state = playing( + hands = listOf( + listOf( + c(Rank.SEVEN, Suit.SPADES), c(Rank.SEVEN, Suit.HEARTS), + c(Rank.NINE, Suit.DIAMONDS), c(Rank.EIGHT, Suit.CLUBS), + ), + listOf( + c(Rank.SEVEN, Suit.CLUBS), c(Rank.QUEEN, Suit.SPADES), + c(Rank.TEN, Suit.DIAMONDS), c(Rank.NINE, Suit.HEARTS), + ), + ), + played = listOf( + PeggedCard(0, c(Rank.SEVEN, Suit.SPADES)), + PeggedCard(1, c(Rank.SEVEN, Suit.CLUBS)), + ), + scores = listOf(119, 60), + turn = 0, + ) + val after = CribbageRules.applyMove(state, 0, PegCard(c(Rank.SEVEN, Suit.HEARTS))) + + assertEquals("pair royal", 125, after.scores[0]) + assertEquals(0, after.winner) + assertTrue(CribbageRules.isFinished(after)) + assertNull(CribbageRules.currentSeat(after)) + assertTrue("the hands were never counted", after.show.isEmpty()) + } + + // -- The show ------------------------------------------------------------ + + @Test + fun `the hands are counted from the dealer's left with the crib last`() { + var state = dealAndLayAway( + CribbageRules.initialState(config(CribbageOptions(playerCount = 3), seed = 4L)) + ) + while (state.phase == CribbagePhase.PLAY) { + val seat = CribbageRules.currentSeat(state)!! + state = CribbageRules.applyMove(state, seat, CribbageRules.legalMoves(state, seat).first()) + } + + assertEquals(CribbagePhase.SHOW, state.phase) + assertEquals(4, state.show.size) + assertEquals(listOf(1, 2, 0, 0), state.show.map { it.seat }) + assertEquals(listOf(false, false, false, true), state.show.map { it.isCrib }) + assertTrue(state.show.all { it.counted }) + assertEquals(state.crib, state.show.last().cards) + } + + @Test + fun `a hand counted out wins before the dealer ever counts`() { + // Seat 0 is holding the twenty-nine hand and counts first. + val state = playing( + hands = listOf( + listOf( + c(Rank.FIVE, Suit.SPADES), c(Rank.FIVE, Suit.HEARTS), + c(Rank.FIVE, Suit.DIAMONDS), c(Rank.JACK, Suit.CLUBS), + ), + listOf( + c(Rank.TWO, Suit.SPADES), c(Rank.THREE, Suit.HEARTS), + c(Rank.FOUR, Suit.DIAMONDS), c(Rank.SIX, Suit.CLUBS), + ), + ), + dealer = 1, + starter = c(Rank.FIVE, Suit.CLUBS), + crib = listOf( + c(Rank.KING, Suit.SPADES), c(Rank.QUEEN, Suit.HEARTS), + c(Rank.NINE, Suit.DIAMONDS), c(Rank.EIGHT, Suit.SPADES), + ), + played = listOf( + PeggedCard(0, c(Rank.FIVE, Suit.SPADES)), + PeggedCard(1, c(Rank.TWO, Suit.SPADES)), + PeggedCard(0, c(Rank.FIVE, Suit.HEARTS)), + PeggedCard(1, c(Rank.THREE, Suit.HEARTS)), + PeggedCard(0, c(Rank.FIVE, Suit.DIAMONDS)), + PeggedCard(1, c(Rank.FOUR, Suit.DIAMONDS)), + PeggedCard(1, c(Rank.SIX, Suit.CLUBS)), + ), + scores = listOf(100, 100), + turn = 0, + ).let { it.copy(series = listOf(PeggedCard(1, c(Rank.SIX, Suit.CLUBS)))) } + + // Seat 0's last card ends the play and takes the table to the show. + val after = CribbageRules.applyMove(state, 0, PegCard(c(Rank.JACK, Suit.CLUBS))) + + assertEquals(CribbagePhase.GAME_OVER, after.phase) + assertEquals(0, after.winner) + assertEquals(3, after.show.size) + assertTrue("the non-dealer counted", after.show[0].counted) + assertFalse("the dealer never got to count", after.show[1].counted) + assertFalse("nor did the crib", after.show[2].counted) + assertEquals(29, after.show[0].total) + } + + // -- Whole games --------------------------------------------------------- + + @Test + fun `random legal play always finishes, at every table size`() { + listOf(2, 3, 4).forEach { players -> + repeat(6) { iteration -> + val random = Random(iteration.toLong()) + var state = CribbageRules.initialState( + config( + CribbageOptions(playerCount = players, pointsToWin = 61), + seed = 700L + iteration, + ) + ) + var guard = 0 + while (!CribbageRules.isFinished(state) && guard++ < 20000) { + val seat = CribbageRules.currentSeat(state) + if (seat == null) { + state = CribbageRules.nextHand(state) + continue + } + val legal = CribbageRules.legalMoves(state, seat) + assertTrue("$players players: seat $seat had no legal move", legal.isNotEmpty()) + state = CribbageRules.applyMove(state, seat, legal[random.nextInt(legal.size)]) + } + assertTrue("$players players, game $iteration hung", CribbageRules.isFinished(state)) + assertNotNull(state.winner) + assertTrue(state.scores.max() >= 61) + } + } + } + + @Test + fun `nobody is ever carried past the target`() { + // The game stops on the point that reaches it, so the winner's score is + // the only one that may sit on or above the line. + var state = CribbageRules.initialState(config(CribbageOptions(pointsToWin = 61), seed = 9L)) + val random = Random(9L) + var guard = 0 + while (!CribbageRules.isFinished(state) && guard++ < 20000) { + val seat = CribbageRules.currentSeat(state) + if (seat == null) { + state = CribbageRules.nextHand(state) + continue + } + val legal = CribbageRules.legalMoves(state, seat) + state = CribbageRules.applyMove(state, seat, legal[random.nextInt(legal.size)]) + } + val winner = state.winner!! + state.scores.forEachIndexed { team, score -> + if (team != winner) assertTrue("team $team reached $score", score < 61) + } + } + + @Test + fun `the computer beats random play head to head`() { + val ai = CribbageAi() + var aiWins = 0 + var randomWins = 0 + + repeat(10) { iteration -> + val random = Random(4000L + iteration) + var state = CribbageRules.initialState( + config(CribbageOptions(pointsToWin = 61), seed = 200L + iteration) + ) + var guard = 0 + while (!CribbageRules.isFinished(state) && guard++ < 20000) { + val seat = CribbageRules.currentSeat(state) + if (seat == null) { + state = CribbageRules.nextHand(state) + continue + } + val legal = CribbageRules.legalMoves(state, seat) + val move = if (seat == 0) { + ai.chooseMove(state, seat, legal) + } else { + legal[random.nextInt(legal.size)] + } + assertTrue("the ai returned an illegal move $move", legal.contains(move)) + state = CribbageRules.applyMove(state, seat, move) + } + if (state.winner == 0) aiWins++ else randomWins++ + } + + assertTrue("ai won $aiWins of ten against random play", aiWins > randomWins) + } + + // -- Redaction and wire format ------------------------------------------ + + @Test + fun `a seat view hides the other hands, the crib and the pack`() { + val state = dealAndLayAway(CribbageRules.initialState(config(CribbageOptions(playerCount = 4)))) + val view = CribbageRules.viewFor(state, seat = 2) + + assertEquals(state.hands[2], view.hands[2]) + assertTrue(view.hands.filterIndexed { seat, _ -> seat != 2 }.all { it.isEmpty() }) + assertTrue("the crib is nobody's business until the show", view.crib.isEmpty()) + assertTrue("the cut must not be knowable in advance", view.deck.isEmpty()) + assertEquals(CRIBBAGE_HAND_SIZE, view.cribCount) + assertEquals(state.handCounts, view.handCounts) + assertEquals(state.starter, view.starter) + } + + @Test + fun `everything is turned face up at the show`() { + var state = dealAndLayAway(CribbageRules.initialState(config(seed = 12L))) + while (state.phase == CribbagePhase.PLAY) { + val seat = CribbageRules.currentSeat(state)!! + state = CribbageRules.applyMove(state, seat, CribbageRules.legalMoves(state, seat).first()) + } + val view = CribbageRules.viewFor(state, seat = 0) + assertEquals(state.hands, view.hands) + assertEquals(state.crib, view.crib) + } + + @Test + fun `state and moves survive a round trip`() { + val state = CribbageRules.initialState(config()) + assertEquals(state, CribbageRules.decodeState(CribbageRules.encodeState(state))) + listOf( + PegCard(c(Rank.KING, Suit.HEARTS)), + LayAway.of(listOf(c(Rank.FIVE, Suit.SPADES), c(Rank.SIX, Suit.CLUBS))), + ).forEach { + assertEquals(it, CribbageRules.decodeMove(CribbageRules.encodeMove(it))) + } + } + + @Test + fun `four play in partnerships and two or three play for themselves`() { + val four = CribbageOptions(playerCount = 4) + assertEquals(2, four.teamCount) + assertEquals(listOf(0, 1, 0, 1), (0..3).map { four.teamOf(it) }) + + val three = CribbageOptions(playerCount = 3) + assertEquals(3, three.teamCount) + assertEquals(listOf(0, 1, 2), (0..2).map { three.teamOf(it) }) + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/cribbage/CribbageScoringTest.kt b/app/src/test/java/org/prolibertate/games/game/cribbage/CribbageScoringTest.kt new file mode 100644 index 0000000..467e928 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/cribbage/CribbageScoringTest.kt @@ -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, 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) + } +} diff --git a/docs/RULES-cribbage.md b/docs/RULES-cribbage.md new file mode 100644 index 0000000..33dde6e --- /dev/null +++ b/docs/RULES-cribbage.md @@ -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. From 136f5bc4f1341a34537a5e412d95e11392472794 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 19:11:59 +0000 Subject: [PATCH 2/5] Let a scoreboard actually be dismissed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pause between hands never ended. driveIdleSeats asks awaitsConfirmation about the state, and the state does not change while the players are reading it — so confirmAdvance restarted the loop, the loop stopped on the same scoreboard, and the next hand was never dealt. The screens hid their own dialog on the way out, so the table looked released and then simply sat there. The release is now something spendable rather than a question re-asked about an unchanged state: confirmAdvance grants one, and the loop consumes it to walk past the hold exactly once. This is not new with cribbage — Golf's card, Wizard's bids against tricks and the Crazy 8s round score all stop on the same hold and all end there. Cribbage's show is what made it impossible to miss, because a game of cribbage is nothing but hands one after another. Covered by MatchControllerScoreboardTest, which plays a whole game of one-move hands and insists every one of them stops to be read and then goes on. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DCuRESmSZeJoJond43itw2 --- .../prolibertate/games/net/MatchController.kt | 28 +++- .../net/MatchControllerScoreboardTest.kt | 151 ++++++++++++++++++ 2 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/org/prolibertate/games/net/MatchControllerScoreboardTest.kt diff --git a/app/src/main/java/org/prolibertate/games/net/MatchController.kt b/app/src/main/java/org/prolibertate/games/net/MatchController.kt index f43fd8f..89dde9c 100644 --- a/app/src/main/java/org/prolibertate/games/net/MatchController.kt +++ b/app/src/main/java/org/prolibertate/games/net/MatchController.kt @@ -98,6 +98,18 @@ class MatchController( 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() private var hostLink: Connection? = null @@ -288,8 +300,15 @@ class MatchController( 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. - _awaitingConfirmation.value = true - return + val letThrough = lock.withLock { + val granted = released + released = false + granted + } + if (!letThrough) { + _awaitingConfirmation.value = true + return + } } delay(aiThinkingMillis() * 2) val advanced = advanceIdle(current) @@ -398,7 +417,10 @@ class MatchController( 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. */ diff --git a/app/src/test/java/org/prolibertate/games/net/MatchControllerScoreboardTest.kt b/app/src/test/java/org/prolibertate/games/net/MatchControllerScoreboardTest.kt new file mode 100644 index 0000000..6cc1cb9 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/net/MatchControllerScoreboardTest.kt @@ -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() + + @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 { + 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 = + 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 { + val scope = CoroutineScope(Job() + Dispatchers.Default).also { scopes += it } + return MatchController( + rules = Rules, + ai = object : GameAi { + override fun chooseMove(state: Hands, seat: Int, legal: List) = 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 + } +} From 6d490a50b1e3ca02746fa8d3619589712f846d4e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 19:13:18 +0000 Subject: [PATCH 3/5] Test that a cribbage partnership plays for its own crib Four-handed is the only table where the crib in front of somebody else is still yours, and the lay-away is where that has to be read. Two AI seats facing each other take more than half the games against two random ones, which a side giving away half its own cribs would not. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DCuRESmSZeJoJond43itw2 --- .../games/game/cribbage/CribbageRulesTest.kt | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/app/src/test/java/org/prolibertate/games/game/cribbage/CribbageRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/cribbage/CribbageRulesTest.kt index 7ad541e..4d25950 100644 --- a/app/src/test/java/org/prolibertate/games/game/cribbage/CribbageRulesTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/cribbage/CribbageRulesTest.kt @@ -447,6 +447,39 @@ class CribbageRulesTest { 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 From 3372adf87ab8638eb3e51fbabb331bb27b097298 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 03:24:48 +0000 Subject: [PATCH 4/5] Let each channel update only from itself, with nothing to choose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The updater took the channel from a setting, so a production install could be pointed at dev builds and vice versa. That setting was the only thing standing between a phone and the wrong line of builds, and it was a chip you could tap by accident. The channel is now a property of the running APK and nothing else. The app reads the -dev suffix in its own versionName, asks that channel and no other, and then checks again at the point of decision — a release belonging to the other channel is refused whatever came back from the fetch. There is no preference, no key in the DataStore, and no way to cross over. Crossing over was never an upgrade in the first place. The two channels are counted by different workflows and their numbers are unrelated: dev is on 69 while production is on 40, so "newer" means nothing across the divide. They are separate apps under separate applicationIds, so having both is a matter of installing both, and each then keeps itself current. The rule is now a pure function with no Android in it — update/UpdatePolicy.kt — so it is unit-tested on the JVM like the rules engines are, rather than being buried in a class that needs an Activity. UpdatePolicyTest holds the wall: a dev install is refused a production build and a production install a dev one, at every version number including the ones where the two sequences overlap. The settings screen states which build is running and where its updates come from. It no longer offers a switch, because there is no longer one to offer. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DCuRESmSZeJoJond43itw2 --- README.md | 44 +++--- .../games/settings/SettingsRepository.kt | 16 +- .../prolibertate/games/ui/SettingsScreen.kt | 85 +++++------ .../prolibertate/games/update/UpdatePolicy.kt | 99 +++++++++++++ .../org/prolibertate/games/update/Updater.kt | 97 +++--------- .../games/update/UpdatePolicyTest.kt | 140 ++++++++++++++++++ 6 files changed, 325 insertions(+), 156 deletions(-) create mode 100644 app/src/main/java/org/prolibertate/games/update/UpdatePolicy.kt create mode 100644 app/src/test/java/org/prolibertate/games/update/UpdatePolicyTest.kt diff --git a/README.md b/README.md index 11a50e8..377d574 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,8 @@ machinery for it is still there and still tested: a game listed in `GameCatalog` with `available = false` appears in the menu marked *Coming soon* **on dev builds only**, so a production release lists just what can actually be played and nobody installs it and taps into a dead end. The menu decides this from the installed -APK's own version name, so it follows the build rather than the channel selected -for future updates. +APK's own version name — the same source the updater uses to know which channel +it is on. Adding a game means writing a `GameRules` implementation, a `GameAi` and a screen, then flipping `available` — the menu, lobby, networking, settings and OTA @@ -91,13 +91,14 @@ it: every rule above is a pure function and unit-tested on the JVM. - **Sound** on/off. - **Animation speed**, 0.5×–2×. One multiplier drives card movement and how long the computer appears to think. -- **Updates** — check on launch, or check on demand, from either of two - channels: - - **Production** — stable builds, published from `main`. - - **Dev** — preview builds, published from `dev` on every green CI run. +- **Updates** — check on launch, or check on demand. **There is no channel to + choose**: a build updates from its own channel and no other. A production + install is offered production builds from `main`; a dev install is offered dev + builds from `dev`. The settings screen states which one is running rather than + offering a switch. - The two install as separate apps, so both can be kept on one device — see - [Both channels on one device](#both-channels-on-one-device). + Wanting both means installing both — they are separate apps and each updates + itself, see [Both channels on one device](#both-channels-on-one-device). ## Branding @@ -184,8 +185,9 @@ the versioned copy, so an installed build can always be traced back to its tag. There is no equivalent link for the dev channel: `/releases/latest` skips prereleases by design, and that is exactly what keeps a production install from -being handed a dev build. Dev builds are reached through the in-app updater with -the channel set to dev, or from the releases page. +being handed a dev build. A dev build is reached by the in-app updater of a dev +install — which looks at the prerelease list rather than that endpoint — or from +the releases page. ### The version series @@ -239,9 +241,16 @@ aimed elsewhere does nothing rather than shipping unreviewed code. **The two channels have independent version sequences** — a build's `versionCode` is the run number of the workflow that produced it, and the two workflows count -separately. Versions are therefore only ever compared *within* a channel; the app -reads the `-dev` suffix in its own `versionName` to know which channel it is on, -and treats a change of channel as an explicit switch rather than an upgrade. +separately. Dev is on 69 while production is on 40, so the numbers say nothing +across the divide. + +That is why **an install only ever updates within its own channel**, and why +there is no setting for it. The app reads the `-dev` suffix in its own +`versionName` to know which line it is on, asks only that channel, and refuses +anything that comes back belonging to the other one — checked again at the point +of decision rather than trusted from the fetch. The rule is a pure function, +`verdictFor` in `update/UpdatePolicy.kt`, with no Android in it, so it is +unit-tested on the JVM like the rules engines are. ### Both channels on one device @@ -272,9 +281,10 @@ reference `@color/tartan_field` rather than a literal, which is how one set of vectors serves both colourways. `BuildConfig.DEV_BUILD` carries the same flag into Kotlin, where `AppSett` picks the matching Compose sett for the splash. -Switching channels in Settings therefore installs the other channel's app -alongside this one and leaves this one in place; the settings screen says so -when the selected channel is not the installed one. +Because they are separate apps, having both is a matter of installing both, and +each then updates itself down its own line. Nothing in the app moves a phone +from one channel to the other: the settings screen states which build is +running and where its updates come from, and that is all it does. The debug keystore is committed on purpose: every build is signed with the same key, so an OTA update installs over the previous one instead of being rejected @@ -331,7 +341,7 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested net/ wire protocol, LAN transport, lobby, match driver score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store settings/ DataStore-backed preferences -update/ GitHub Releases OTA updater +update/ GitHub Releases OTA updater, and the channel rule — pure, tested ui/ Compose screens ``` diff --git a/app/src/main/java/org/prolibertate/games/settings/SettingsRepository.kt b/app/src/main/java/org/prolibertate/games/settings/SettingsRepository.kt index c086124..ccbb2d0 100644 --- a/app/src/main/java/org/prolibertate/games/settings/SettingsRepository.kt +++ b/app/src/main/java/org/prolibertate/games/settings/SettingsRepository.kt @@ -10,7 +10,6 @@ import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import org.prolibertate.games.update.UpdateChannel private val Context.dataStore: DataStore by preferencesDataStore(name = "settings") @@ -32,8 +31,6 @@ data class Settings( * snapped back before a real name could be typed. */ val playerName: String = "", - /** Which builds the updater offers. Production unless deliberately changed. */ - val updateChannel: UpdateChannel = UpdateChannel.PRODUCTION, ) { /** * The name other players actually see. This is where the default lives, so @@ -60,7 +57,11 @@ class SettingsRepository(private val context: Context) { val SPEED = floatPreferencesKey("animation_speed") val UPDATE_ON_LAUNCH = booleanPreferencesKey("check_updates_on_launch") val NAME = stringPreferencesKey("player_name") - val CHANNEL = stringPreferencesKey("update_channel") + // There is deliberately no update-channel key. The channel is a + // property of the installed APK, not a preference — an install that + // could be pointed at the other channel's builds is exactly the mix-up + // that separate applicationIds exist to prevent. Anything an older + // version left under "update_channel" is simply never read again. } val settings: Flow = context.dataStore.data.map { prefs -> @@ -69,16 +70,9 @@ class SettingsRepository(private val context: Context) { animationSpeed = prefs[Keys.SPEED] ?: 1.0f, checkForUpdatesOnLaunch = prefs[Keys.UPDATE_ON_LAUNCH] ?: true, playerName = prefs[Keys.NAME] ?: "", - updateChannel = prefs[Keys.CHANNEL] - ?.let { stored -> runCatching { UpdateChannel.valueOf(stored) }.getOrNull() } - ?: UpdateChannel.PRODUCTION, ) } - suspend fun setUpdateChannel(channel: UpdateChannel) { - context.dataStore.edit { it[Keys.CHANNEL] = channel.name } - } - suspend fun setSoundEnabled(enabled: Boolean) { context.dataStore.edit { it[Keys.SOUND] = enabled } } diff --git a/app/src/main/java/org/prolibertate/games/ui/SettingsScreen.kt b/app/src/main/java/org/prolibertate/games/ui/SettingsScreen.kt index ebfea7e..26410ef 100644 --- a/app/src/main/java/org/prolibertate/games/ui/SettingsScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/SettingsScreen.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Divider -import androidx.compose.material3.FilterChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Slider @@ -26,8 +25,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import org.prolibertate.games.settings.Settings +import org.prolibertate.games.update.Release import org.prolibertate.games.update.UpdateChannel -import org.prolibertate.games.update.Updater +import org.prolibertate.games.update.UpdateVerdict @Composable fun SettingsScreen( @@ -42,7 +42,7 @@ fun SettingsScreen( // caret jumped and a cleared field refilled itself. var editedName by remember { mutableStateOf(null) } var updateStatus by remember { mutableStateOf(null) } - var pendingRelease by remember { mutableStateOf(null) } + var pendingRelease by remember { mutableStateOf(null) } ScreenScaffold(title = "Settings", onBack = onBack) { modifier -> Column( @@ -111,45 +111,32 @@ fun SettingsScreen( Divider() Text("Updates", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium) - Text( - text = "Installed: v${env.updater.installedVersionName()} " + - "(${env.updater.installedChannel().label} channel).", - style = MaterialTheme.typography.bodySmall, - ) + // The channel is not a setting. It is read off the running APK, and + // this build updates down its own line and no other — so it is + // stated here rather than offered. + val channel = env.updater.installedChannel() Column { - Text("Update channel", fontWeight = FontWeight.Bold) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - UpdateChannel.entries.forEach { channel -> - FilterChip( - selected = settings.updateChannel == channel, - onClick = { - scope.launch { - env.settingsRepository.setUpdateChannel(channel) - } - updateStatus = null - pendingRelease = null - }, - label = { Text(channel.label) }, - ) - } - } Text( - text = settings.updateChannel.blurb + ".", + text = "Installed: v${env.updater.installedVersionName()} — " + + "${channel.label} channel.", + style = MaterialTheme.typography.bodySmall, + ) + Text( + text = "${channel.blurb}. This build updates from the " + + "${channel.label} channel only.", + style = MaterialTheme.typography.bodySmall, + ) + Text( + text = if (channel == UpdateChannel.DEV) { + "The production app is a separate install with its own updates. " + + "Install it from the releases page if you want both." + } else { + "Dev builds are a separate install with their own updates, from " + + "the releases page." + }, style = MaterialTheme.typography.bodySmall, ) - if (settings.updateChannel != env.updater.installedChannel()) { - // Each channel installs under its own package, so the other - // one arrives as a second app rather than replacing this - // one — no downgrade for Android to refuse. - Text( - text = "You're on a ${env.updater.installedChannel().label} build. " + - "The two channels install as separate apps, so this adds the " + - "${settings.updateChannel.label} build alongside this one and " + - "leaves it in place.", - style = MaterialTheme.typography.bodySmall, - ) - } } SettingRow( @@ -166,26 +153,20 @@ fun SettingsScreen( PrimaryAction(text = "Check for updates now") { scope.launch { - updateStatus = "Checking the ${settings.updateChannel.label} channel…" - when (val result = env.updater.check(settings.updateChannel)) { - is Updater.Result.UpToDate -> { - updateStatus = - "You're on the latest ${settings.updateChannel.label} build." + updateStatus = "Checking the ${channel.label} channel…" + when (val verdict = env.updater.check()) { + is UpdateVerdict.UpToDate -> { + updateStatus = "You're on the latest ${channel.label} build." pendingRelease = null } - is Updater.Result.Available -> { - pendingRelease = result.release - updateStatus = if (result.isChannelSwitch) { - "${result.release.tag} is the current " + - "${result.release.channel.label} build." - } else { - "Version ${result.release.tag} is available." - } + is UpdateVerdict.Install -> { + pendingRelease = verdict.release + updateStatus = "Version ${verdict.release.tag} is available." } - is Updater.Result.Failed -> { - updateStatus = result.reason + is UpdateVerdict.Refused -> { + updateStatus = verdict.reason pendingRelease = null } } diff --git a/app/src/main/java/org/prolibertate/games/update/UpdatePolicy.kt b/app/src/main/java/org/prolibertate/games/update/UpdatePolicy.kt new file mode 100644 index 0000000..b1eac5c --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/update/UpdatePolicy.kt @@ -0,0 +1,99 @@ +package org.prolibertate.games.update + +/** + * Which line of builds an APK belongs to. + * + * This is a property of the build, never a preference. The two channels are + * published by different workflows and have entirely separate version + * sequences — a dev build's versionCode is its CI run number, a production + * build's is its release run number, and the two are unrelated. There is no + * meaning to be had from comparing across them, so nothing does. + * + * They also carry different applicationIds — a dev build installs as + * `…games.dev` — so both can sit on one device, each updating itself down its + * own line and leaving the other alone. + */ +enum class UpdateChannel(val label: String, val blurb: String) { + PRODUCTION("Production", "Stable builds released from main"), + DEV("Dev", "Preview builds from the dev branch — newer, less tested"), +} + +/** Marks a tag or a version name as belonging to the dev channel. */ +const val DEV_SUFFIX = "-dev" + +/** One published build, as read off a GitHub release. */ +data class Release( + val tag: String, + val versionCode: Int, + val apkUrl: String, + val apkName: String, + val channel: UpdateChannel, +) + +sealed interface UpdateVerdict { + data class Install(val release: Release) : UpdateVerdict + data object UpToDate : UpdateVerdict + data class Refused(val reason: String) : UpdateVerdict +} + +/** + * The channel an installed build belongs to, read from its own version name. + * + * The APK says what it is, so this cannot disagree with what is actually + * running — which is the whole point of not storing it anywhere. + */ +fun channelOfVersionName(versionName: String): UpdateChannel = + if (versionName.endsWith(DEV_SUFFIX)) UpdateChannel.DEV else UpdateChannel.PRODUCTION + +/** + * The channel a release belongs to. + * + * Two independent marks, and either is enough: the `-dev` suffix the workflow + * puts on the tag, and GitHub's own prerelease flag. A release would have to + * lose both to be mistaken for the other channel. + */ +fun channelOfTag(tag: String, prerelease: Boolean): UpdateChannel = + if (tag.endsWith(DEV_SUFFIX) || prerelease) UpdateChannel.DEV else UpdateChannel.PRODUCTION + +/** + * The run number out of a `v.` tag, which is what an APK's + * versionCode is. + * + * Only the trailing number is read. The series is a name for people, and + * bumping it must never make an older build look newer. + */ +fun versionCodeOfTag(tag: String): Int? = + tag.substringAfterLast('.').removeSuffix(DEV_SUFFIX).toIntOrNull() + +/** + * Whether [latest] should be offered to a build of [installedChannel] at + * [installedVersionCode]. + * + * **A build only ever updates within its own channel.** A dev install is + * offered dev builds and a production install production ones; there is no + * setting for this and no way to cross over, because crossing over is not an + * upgrade — it is a different app with an unrelated version sequence, and + * offering it as though it were newer is how a phone ends up being handed a + * build nobody meant it to have. + * + * The channel is checked here rather than trusted from the fetch, so a release + * that does not belong to this build is refused whatever the endpoint returned. + */ +fun verdictFor( + latest: Release?, + installedChannel: UpdateChannel, + installedVersionCode: Int, +): UpdateVerdict = when { + latest == null -> + UpdateVerdict.Refused("No ${installedChannel.label.lowercase()} build has been published yet.") + + latest.channel != installedChannel -> + UpdateVerdict.Refused( + "${latest.tag} is a ${latest.channel.label.lowercase()} build, and this is a " + + "${installedChannel.label.lowercase()} install. The two update separately." + ) + + latest.versionCode > installedVersionCode -> UpdateVerdict.Install(latest) + + else -> UpdateVerdict.UpToDate +} diff --git a/app/src/main/java/org/prolibertate/games/update/Updater.kt b/app/src/main/java/org/prolibertate/games/update/Updater.kt index 67aa69d..598bf7a 100644 --- a/app/src/main/java/org/prolibertate/games/update/Updater.kt +++ b/app/src/main/java/org/prolibertate/games/update/Updater.kt @@ -14,27 +14,6 @@ import java.io.File import java.net.HttpURLConnection import java.net.URL -/** - * Which builds the updater offers. - * - * The two channels are published by different workflows and so have entirely - * separate version sequences — a dev build's versionCode is its CI run number, - * a production build's is its release run number, and the two are unrelated. - * Versions are therefore only ever compared *within* a channel; moving between - * them is treated as an explicit switch rather than an upgrade. - * - * The two also carry different applicationIds — a dev build installs as - * `…games.dev` — so a switch installs the other channel beside this one instead - * of over it, and both can be kept on the same device. - */ -enum class UpdateChannel(val label: String, val blurb: String) { - PRODUCTION("Production", "Stable builds released from main"), - DEV("Dev", "Preview builds from the dev branch — newer, less tested"), -} - -/** Marks a version name as belonging to the dev channel. */ -private const val DEV_SUFFIX = "-dev" - /** * Over-the-air updates from the repository's GitHub Releases. * @@ -42,50 +21,29 @@ private const val DEV_SUFFIX = "-dev" * number, so comparing the two is enough to tell whether a newer build exists. * The repository's releases must be publicly downloadable — no token is * embedded in the app, and GitHub blocks anonymous access to private assets. + * + * There is nothing to choose here. The channel comes from the running APK, so + * a dev install looks only at dev builds and a production install only at + * production ones — see [verdictFor], where that rule lives and is tested. */ class Updater(private val activity: Activity) { - data class Release( - val tag: String, - val versionCode: Int, - val apkUrl: String, - val apkName: String, - val channel: UpdateChannel, - ) - - sealed interface Result { - /** - * [isChannelSwitch] means this build is from a different channel than - * the one installed, so it is an explicit move rather than a newer - * version of the same line — and, because the channels have separate - * applicationIds, it lands as a second app rather than an upgrade. - */ - data class Available(val release: Release, val isChannelSwitch: Boolean) : Result - - data object UpToDate : Result - data class Failed(val reason: String) : Result - } - - suspend fun check(channel: UpdateChannel): Result = withContext(Dispatchers.IO) { + /** + * Looks for a newer build of whatever is installed. + * + * Takes no channel: asking for one would be asking which line of builds + * this phone is on, and the phone already knows. + */ + suspend fun check(): UpdateVerdict = withContext(Dispatchers.IO) { try { - val latest = fetchLatestRelease(channel) - ?: return@withContext Result.Failed( - "No ${channel.label.lowercase()} build has been published yet." - ) - - when { - // Different channel: version numbers are not comparable, so - // always offer it and let the user decide. - latest.channel != installedChannel() -> - Result.Available(latest, isChannelSwitch = true) - - latest.versionCode > installedVersionCode() -> - Result.Available(latest, isChannelSwitch = false) - - else -> Result.UpToDate - } + val channel = installedChannel() + verdictFor( + latest = fetchLatestRelease(channel), + installedChannel = channel, + installedVersionCode = installedVersionCode(), + ) } catch (e: Exception) { - Result.Failed(e.message ?: "Update check failed.") + UpdateVerdict.Refused(e.message ?: "Update check failed.") } } @@ -112,19 +70,8 @@ class Updater(private val activity: Activity) { private fun toRelease(json: JSONObject): Release? { val tag = json.optString("tag_name").ifBlank { return null } - // Tags are v., with -dev on a prerelease. Only the trailing - // run number is read: the series is a name for people and changing it - // must not make an older build look newer. - val versionCode = tag.substringAfterLast('.') - .removeSuffix(DEV_SUFFIX) - .toIntOrNull() - ?: return null - val channel = - if (tag.endsWith(DEV_SUFFIX) || json.optBoolean("prerelease")) { - UpdateChannel.DEV - } else { - UpdateChannel.PRODUCTION - } + val versionCode = versionCodeOfTag(tag) ?: return null + val channel = channelOfTag(tag, json.optBoolean("prerelease")) val assets = json.optJSONArray("assets") ?: return null val apks = (0 until assets.length()) @@ -178,9 +125,7 @@ class Updater(private val activity: Activity) { } /** Read back from the installed APK's own version name. */ - fun installedChannel(): UpdateChannel = - if (installedVersionName().endsWith(DEV_SUFFIX)) UpdateChannel.DEV - else UpdateChannel.PRODUCTION + fun installedChannel(): UpdateChannel = channelOfVersionName(installedVersionName()) /** Downloads the APK and hands it to the system installer. */ suspend fun downloadAndInstall(release: Release): String? = withContext(Dispatchers.IO) { diff --git a/app/src/test/java/org/prolibertate/games/update/UpdatePolicyTest.kt b/app/src/test/java/org/prolibertate/games/update/UpdatePolicyTest.kt new file mode 100644 index 0000000..8113c2e --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/update/UpdatePolicyTest.kt @@ -0,0 +1,140 @@ +package org.prolibertate.games.update + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The wall between the two channels. + * + * A dev install may only ever be offered dev builds and a production install + * production ones. There is no setting for it, so the only way this can go + * wrong now is here. + */ +class UpdatePolicyTest { + + private fun release( + tag: String, + versionCode: Int, + channel: UpdateChannel, + ) = Release( + tag = tag, + versionCode = versionCode, + apkUrl = "https://example.invalid/$tag.apk", + apkName = "ProLibertateGames-$tag.apk", + channel = channel, + ) + + private val devBuild = release("v1.1.69-dev", 69, UpdateChannel.DEV) + private val productionBuild = release("v1.1.40", 40, UpdateChannel.PRODUCTION) + + // -- Reading what a build is -------------------------------------------- + + @Test + fun `a version name says which channel it belongs to`() { + assertEquals(UpdateChannel.DEV, channelOfVersionName("1.1.69-dev")) + assertEquals(UpdateChannel.PRODUCTION, channelOfVersionName("1.1.40")) + } + + @Test + fun `either mark is enough to make a release a dev build`() { + assertEquals(UpdateChannel.DEV, channelOfTag("v1.1.69-dev", prerelease = false)) + assertEquals(UpdateChannel.DEV, channelOfTag("v1.1.69", prerelease = true)) + assertEquals(UpdateChannel.PRODUCTION, channelOfTag("v1.1.40", prerelease = false)) + } + + @Test + fun `only the run number is read from a tag`() { + assertEquals(69, versionCodeOfTag("v1.1.69-dev")) + assertEquals(40, versionCodeOfTag("v1.1.40")) + // A series bump must never read as a downgrade. + assertTrue(versionCodeOfTag("v2.0.41")!! > versionCodeOfTag("v1.1.40")!!) + assertNull(versionCodeOfTag("nightly")) + } + + // -- The wall ----------------------------------------------------------- + + @Test + fun `a dev install is never offered a production build`() { + val verdict = verdictFor( + latest = productionBuild, + installedChannel = UpdateChannel.DEV, + // Deliberately far behind: even a much "newer" number on the other + // channel must not get through, because the numbers are unrelated. + installedVersionCode = 1, + ) + assertTrue("a dev install was offered $productionBuild", verdict is UpdateVerdict.Refused) + } + + @Test + fun `a production install is never offered a dev build`() { + val verdict = verdictFor( + latest = devBuild, + installedChannel = UpdateChannel.PRODUCTION, + installedVersionCode = 1, + ) + assertTrue("a production install was offered $devBuild", verdict is UpdateVerdict.Refused) + } + + // -- Within a channel --------------------------------------------------- + + @Test + fun `a newer build on the same channel is offered`() { + assertEquals( + UpdateVerdict.Install(devBuild), + verdictFor(devBuild, UpdateChannel.DEV, installedVersionCode = 62), + ) + assertEquals( + UpdateVerdict.Install(productionBuild), + verdictFor(productionBuild, UpdateChannel.PRODUCTION, installedVersionCode = 39), + ) + } + + @Test + fun `the same build is not offered to itself`() { + assertEquals( + UpdateVerdict.UpToDate, + verdictFor(devBuild, UpdateChannel.DEV, installedVersionCode = 69), + ) + } + + @Test + fun `an older build is not offered`() { + assertEquals( + UpdateVerdict.UpToDate, + verdictFor(devBuild, UpdateChannel.DEV, installedVersionCode = 70), + ) + } + + @Test + fun `nothing published yet says so rather than failing silently`() { + val verdict = verdictFor(null, UpdateChannel.DEV, installedVersionCode = 69) + assertTrue(verdict is UpdateVerdict.Refused) + assertTrue( + "the reason should name the channel: ${(verdict as UpdateVerdict.Refused).reason}", + verdict.reason.contains("dev"), + ) + } + + /** + * The two sequences genuinely overlap — production is on 40 while dev is on + * 69 — so a build's own number is meaningless on the other channel. This is + * the case the old channel picker could produce and this one cannot. + */ + @Test + fun `overlapping version numbers never cross the channels`() { + listOf(1, 40, 69, 200).forEach { installed -> + assertTrue( + "production build offered to a dev install on $installed", + verdictFor(productionBuild, UpdateChannel.DEV, installed) + is UpdateVerdict.Refused, + ) + assertTrue( + "dev build offered to a production install on $installed", + verdictFor(devBuild, UpdateChannel.PRODUCTION, installed) + is UpdateVerdict.Refused, + ) + } + } +} From cd7f86f47b052d1a5d3472ba0a3cf2e3b0ac7373 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 03:29:42 +0000 Subject: [PATCH 5/5] Make "check on launch" actually check on launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The switch has been in Settings since the updater landed and has never been read by anything: there was no launch-time check to turn on or off. It is now wired to one. It speaks only when there is a build to install. An app that is already current says nothing, and neither does one that could not reach GitHub — opening the app is not asking a question, and a phone with no signal should not be told off for it once a launch. The button in Settings *is* a question and still answers every time, up to date and failures alike. That rule is `launchOffer`, next to the channel rule and tested with it. Two things it is careful about: The setting is read straight from the store rather than from the state the screens are drawn with. That state begins as the defaults — where checking is on — and is replaced a moment later by what is stored, so a check driven off it would have fired on the default before ever learning it had been switched off. The prompt waits for the splash to clear before appearing, while the check itself runs behind it. The look costs no time and cannot land on top of the tartan. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DCuRESmSZeJoJond43itw2 --- README.md | 6 ++ .../java/org/prolibertate/games/ui/AppRoot.kt | 5 + .../games/ui/LaunchUpdateCheck.kt | 97 +++++++++++++++++++ .../prolibertate/games/update/UpdatePolicy.kt | 13 +++ .../games/update/UpdatePolicyTest.kt | 21 ++++ 5 files changed, 142 insertions(+) create mode 100644 app/src/main/java/org/prolibertate/games/ui/LaunchUpdateCheck.kt diff --git a/README.md b/README.md index 377d574..c70579e 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,12 @@ it: every rule above is a pure function and unit-tested on the JVM. builds from `dev`. The settings screen states which one is running rather than offering a switch. + The check on launch **only ever speaks when there is a build to install**. It + runs behind the splash and waits for it to clear, and an app that is already + current — or a phone that cannot reach GitHub — says nothing, because opening + the app was not a question. The button in Settings is a question, so it + answers every time, including "you're on the latest" and whatever went wrong. + Wanting both means installing both — they are separate apps and each updates itself, see [Both channels on one device](#both-channels-on-one-device). diff --git a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt index c82fa4e..e7ae875 100644 --- a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt +++ b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt @@ -129,6 +129,11 @@ fun AppRoot(env: AppEnv) { if (!splashDone) { SplashScreen(onFinished = { splashDone = true }) } + + // Looks while the splash is up and waits until it has gone before + // saying anything, so the check costs nothing in time and cannot + // interrupt the opening of the app. + LaunchUpdateCheck(env = env, visible = splashDone) } } diff --git a/app/src/main/java/org/prolibertate/games/ui/LaunchUpdateCheck.kt b/app/src/main/java/org/prolibertate/games/ui/LaunchUpdateCheck.kt new file mode 100644 index 0000000..5a395ad --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/LaunchUpdateCheck.kt @@ -0,0 +1,97 @@ +package org.prolibertate.games.ui + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.prolibertate.games.update.Release +import org.prolibertate.games.update.launchOffer + +/** + * The check on launch, and the only thing it is allowed to say. + * + * Runs once per start of the app, if the setting is on, and speaks only when + * there is a build to install — see [launchOffer]. Everything else is silence: + * an up-to-date app has nothing to report, and a phone with no network has + * nothing to apologise for. + * + * It reads the setting straight out of the store rather than from the state the + * screens are drawn with. That state starts life as the defaults and is + * replaced a moment later by what is actually stored, so a check driven off it + * would fire on the default — on — before ever learning the setting had been + * turned off. This waits for the real value instead. + */ +@Composable +fun LaunchUpdateCheck(env: AppEnv, visible: Boolean) { + val scope = rememberCoroutineScope() + var release by remember { mutableStateOf(null) } + var dismissed by remember { mutableStateOf(false) } + var status by remember { mutableStateOf(null) } + + // Kept apart from [status] on purpose. Inferring "still working" from there + // being a message meant a failed download — which leaves its reason on + // screen — disabled both buttons for good, and the dialog could not be shut. + var working by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + val stored = env.settingsRepository.settings.first() + if (!stored.checkForUpdatesOnLaunch) return@LaunchedEffect + release = launchOffer(env.updater.check()) + } + + // Held back behind the splash, so an update prompt cannot appear over the + // tartan before the app itself has. + val pending = release + if (pending == null || dismissed || !visible) return + + AlertDialog( + onDismissRequest = { if (!working) dismissed = true }, + title = { Text("Update available") }, + text = { + Text( + text = status ?: "${pending.tag} is out on the " + + "${pending.channel.label.lowercase()} channel. You're on " + + "v${env.updater.installedVersionName()}.", + style = MaterialTheme.typography.bodyMedium, + ) + }, + confirmButton = { + TextButton( + enabled = !working, + onClick = { + scope.launch { + working = true + if (env.updater.needsInstallPermission()) { + // Android has to be told this app may install one + // first. Say so and stand down: they will be looking + // at a system screen, not at this. + status = "Allow installs from this app, then check " + + "again in Settings." + env.updater.requestInstallPermission() + } else { + status = "Downloading ${pending.tag}…" + val error = env.updater.downloadAndInstall(pending) + status = error ?: "Handing over to the installer…" + } + working = false + } + }, + ) { Text("Install") } + }, + dismissButton = { + TextButton( + enabled = !working, + onClick = { dismissed = true }, + ) { Text("Not now") } + }, + ) +} diff --git a/app/src/main/java/org/prolibertate/games/update/UpdatePolicy.kt b/app/src/main/java/org/prolibertate/games/update/UpdatePolicy.kt index b1eac5c..f57aff7 100644 --- a/app/src/main/java/org/prolibertate/games/update/UpdatePolicy.kt +++ b/app/src/main/java/org/prolibertate/games/update/UpdatePolicy.kt @@ -65,6 +65,19 @@ fun channelOfTag(tag: String, prerelease: Boolean): UpdateChannel = fun versionCodeOfTag(tag: String): Int? = tag.substringAfterLast('.').removeSuffix(DEV_SUFFIX).toIntOrNull() +/** + * The build a check on launch should interrupt somebody for, or null to say + * nothing at all. + * + * Only an actual update is worth a word. Opening the app is not asking a + * question, so a check that finds nothing — or that cannot reach GitHub at all + * — stays quiet; a phone on a train would otherwise be told off for it every + * single launch. The button in Settings *is* asking a question, and reports + * every answer including those two. + */ +fun launchOffer(verdict: UpdateVerdict): Release? = + (verdict as? UpdateVerdict.Install)?.release + /** * Whether [latest] should be offered to a build of [installedChannel] at * [installedVersionCode]. diff --git a/app/src/test/java/org/prolibertate/games/update/UpdatePolicyTest.kt b/app/src/test/java/org/prolibertate/games/update/UpdatePolicyTest.kt index 8113c2e..9796266 100644 --- a/app/src/test/java/org/prolibertate/games/update/UpdatePolicyTest.kt +++ b/app/src/test/java/org/prolibertate/games/update/UpdatePolicyTest.kt @@ -117,6 +117,27 @@ class UpdatePolicyTest { ) } + // -- What a check on launch is allowed to say --------------------------- + + @Test + fun `a check on launch speaks only when there is something to install`() { + assertEquals(devBuild, launchOffer(UpdateVerdict.Install(devBuild))) + } + + @Test + fun `a check on launch says nothing when there is nothing to say`() { + assertNull("an up-to-date app has no business interrupting", launchOffer(UpdateVerdict.UpToDate)) + assertNull( + "a phone with no network has nothing to apologise for", + launchOffer(UpdateVerdict.Refused("Unable to resolve host api.github.com")), + ) + // Including the refusal that this whole change exists to produce: being + // shown the other channel's build is not a thing to be told about. + assertNull( + launchOffer(verdictFor(productionBuild, UpdateChannel.DEV, installedVersionCode = 1)), + ) + } + /** * The two sequences genuinely overlap — production is on 40 while dev is on * 69 — so a build's own number is meaningless on the other channel. This is