diff --git a/README.md b/README.md index b71f5f2..c70579e 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 @@ -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 @@ -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. @@ -91,13 +91,20 @@ 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). + 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). ## Branding @@ -184,8 +191,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 +247,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 +287,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 @@ -292,6 +308,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 +336,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 @@ -328,7 +347,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/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/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/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/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/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/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/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/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/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/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..f57aff7 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/update/UpdatePolicy.kt @@ -0,0 +1,112 @@ +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() + +/** + * 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]. + * + * **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/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..4d25950 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/cribbage/CribbageRulesTest.kt @@ -0,0 +1,533 @@ +package org.prolibertate.games.game.cribbage + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.cards.Rank +import org.prolibertate.games.game.cards.Suit +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig +import kotlin.random.Random + +class CribbageRulesTest { + + private val json = Json { encodeDefaults = true } + + private fun config( + options: CribbageOptions = CribbageOptions(), + seed: Long = 21L, + ) = TableConfig( + gameId = "cribbage", + seats = (0 until options.playerCount).map { + PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = options.teamOf(it)) + }, + optionsJson = json.encodeToString(options), + seed = seed, + ) + + private fun c(rank: Rank, suit: Suit) = Card(rank, suit) + + /** A table part-way through the play, built rather than dealt. */ + private fun playing( + hands: List>, + 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) + } + + @Test + fun `a partnership plays for its own crib and not just its own hand`() { + // Four-handed, with the two AI seats facing each other. The lay-away is + // where a partner's crib has to count as your own, so a side that + // treated it as an opponent's would be giving away half its own cribs. + val ai = CribbageAi() + var aiWins = 0 + + repeat(8) { iteration -> + val random = Random(900L + iteration) + val options = CribbageOptions(playerCount = 4, pointsToWin = 61) + var state = CribbageRules.initialState(config(options, seed = 500L + iteration)) + var guard = 0 + while (!CribbageRules.isFinished(state) && guard++ < 40000) { + val seat = CribbageRules.currentSeat(state) + if (seat == null) { + state = CribbageRules.nextHand(state) + continue + } + val legal = CribbageRules.legalMoves(state, seat) + val move = if (options.teamOf(seat) == 0) { + ai.chooseMove(state, seat, legal) + } else { + legal[random.nextInt(legal.size)] + } + state = CribbageRules.applyMove(state, seat, move) + } + if (state.winner == 0) aiWins++ + } + + assertTrue("the ai partnership won $aiWins of eight", aiWins > 4) + } + + // -- Redaction and wire format ------------------------------------------ + + @Test + fun `a seat view hides the other hands, the crib and the pack`() { + val state = dealAndLayAway(CribbageRules.initialState(config(CribbageOptions(playerCount = 4)))) + val view = CribbageRules.viewFor(state, seat = 2) + + assertEquals(state.hands[2], view.hands[2]) + assertTrue(view.hands.filterIndexed { seat, _ -> seat != 2 }.all { it.isEmpty() }) + assertTrue("the crib is nobody's business until the show", view.crib.isEmpty()) + assertTrue("the cut must not be knowable in advance", view.deck.isEmpty()) + assertEquals(CRIBBAGE_HAND_SIZE, view.cribCount) + assertEquals(state.handCounts, view.handCounts) + assertEquals(state.starter, view.starter) + } + + @Test + fun `everything is turned face up at the show`() { + var state = dealAndLayAway(CribbageRules.initialState(config(seed = 12L))) + while (state.phase == CribbagePhase.PLAY) { + val seat = CribbageRules.currentSeat(state)!! + state = CribbageRules.applyMove(state, seat, CribbageRules.legalMoves(state, seat).first()) + } + val view = CribbageRules.viewFor(state, seat = 0) + assertEquals(state.hands, view.hands) + assertEquals(state.crib, view.crib) + } + + @Test + fun `state and moves survive a round trip`() { + val state = CribbageRules.initialState(config()) + assertEquals(state, CribbageRules.decodeState(CribbageRules.encodeState(state))) + listOf( + PegCard(c(Rank.KING, Suit.HEARTS)), + LayAway.of(listOf(c(Rank.FIVE, Suit.SPADES), c(Rank.SIX, Suit.CLUBS))), + ).forEach { + assertEquals(it, CribbageRules.decodeMove(CribbageRules.encodeMove(it))) + } + } + + @Test + fun `four play in partnerships and two or three play for themselves`() { + val four = CribbageOptions(playerCount = 4) + assertEquals(2, four.teamCount) + assertEquals(listOf(0, 1, 0, 1), (0..3).map { four.teamOf(it) }) + + val three = CribbageOptions(playerCount = 3) + assertEquals(3, three.teamCount) + assertEquals(listOf(0, 1, 2), (0..2).map { three.teamOf(it) }) + } +} 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/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 + } +} 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..9796266 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/update/UpdatePolicyTest.kt @@ -0,0 +1,161 @@ +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"), + ) + } + + // -- 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 + * 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, + ) + } + } +} 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.