Merge dev: cribbage, and an updater that cannot cross channels

Cribbage for two, three or four players; the fix that lets an
end-of-hand scoreboard be dismissed, which Golf, Wizard and Crazy 8s
have needed since they shipped; the update channel read off the running
APK rather than chosen in Settings; and a check on launch that finally
runs and only speaks when there is something to install.
This commit is contained in:
adrianoftyriel
2026-08-07 21:46:51 -06:00
committed by GitHub
23 changed files with 3076 additions and 164 deletions
+38 -19
View File
@@ -9,7 +9,7 @@ The app shell is complete and every game in the catalogue is playable end to end
| | | | | |
| --- | --- | | --- | --- |
| **Cards** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s | | **Cards** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s, Cribbage |
| **Board** | Sequence, Chess, Checkers, Backgammon, Mastermind, Nine Men's Morris, Pirates and Bulgars, Ta Yü | | **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 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 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 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 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 APK's own version name — the same source the updater uses to know which channel
for future updates. it is on.
Adding a game means writing a `GameRules` implementation, a `GameAi` and a Adding a game means writing a `GameRules` implementation, a `GameAi` and a
screen, then flipping `available` — the menu, lobby, networking, settings and OTA screen, then flipping `available` — the menu, lobby, networking, settings and OTA
@@ -47,7 +47,7 @@ them.
## Scorekeeper ## Scorekeeper
**Scorekeeper** on the main menu is a pencil for games the app does not deal — **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 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. 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. - **Sound** on/off.
- **Animation speed**, 0.5×–2×. One multiplier drives card movement and how long - **Animation speed**, 0.5×–2×. One multiplier drives card movement and how long
the computer appears to think. the computer appears to think.
- **Updates** — check on launch, or check on demand, from either of two - **Updates** — check on launch, or check on demand. **There is no channel to
channels: choose**: a build updates from its own channel and no other. A production
- **Production** — stable builds, published from `main`. install is offered production builds from `main`; a dev install is offered dev
- **Dev** — preview builds, published from `dev` on every green CI run. 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 The check on launch **only ever speaks when there is a build to install**. It
[Both channels on one device](#both-channels-on-one-device). 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 ## 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 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 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 being handed a dev build. A dev build is reached by the in-app updater of a dev
the channel set to dev, or from the releases page. install — which looks at the prerelease list rather than that endpoint — or from
the releases page.
### The version series ### 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` **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 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 separately. Dev is on 69 while production is on 40, so the numbers say nothing
reads the `-dev` suffix in its own `versionName` to know which channel it is on, across the divide.
and treats a change of channel as an explicit switch rather than an upgrade.
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 ### 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 vectors serves both colourways. `BuildConfig.DEV_BUILD` carries the same flag
into Kotlin, where `AppSett` picks the matching Compose sett for the splash. into Kotlin, where `AppSett` picks the matching Compose sett for the splash.
Switching channels in Settings therefore installs the other channel's app Because they are separate apps, having both is a matter of installing both, and
alongside this one and leaves this one in place; the settings screen says so each then updates itself down its own line. Nothing in the app moves a phone
when the selected channel is not the installed one. 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 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 key, so an OTA update installs over the previous one instead of being rejected
@@ -292,6 +308,8 @@ game:
- [Golf](docs/RULES-golf.md) - [Golf](docs/RULES-golf.md)
- [Wizard](docs/RULES-wizard.md) - [Wizard](docs/RULES-wizard.md)
- [Crazy 8s](docs/RULES-crazy8s.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) - [Chess](docs/RULES-chess.md)
- [Checkers](docs/RULES-checkers.md) — **note the caveat about flying kings** - [Checkers](docs/RULES-checkers.md) — **note the caveat about flying kings**
- [Backgammon](docs/RULES-backgammon.md) — **note that there is no doubling cube** - [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 golf/ Golf model, scoring, rules, AI
wizard/ Wizard model, rules, AI wizard/ Wizard model, rules, AI
crazyeights/ Crazy 8s 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 chess/ Chess model, FEN, move generation, search
morris/ Nine Men's Morris board geometry, mills, rules, search morris/ Nine Men's Morris board geometry, mills, rules, search
checkers/ Checkers board, compulsory captures, crowning, 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 net/ wire protocol, LAN transport, lobby, match driver
score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store
settings/ DataStore-backed preferences settings/ DataStore-backed preferences
update/ GitHub Releases OTA updater update/ GitHub Releases OTA updater, and the channel rule — pure, tested
ui/ Compose screens ui/ Compose screens
``` ```
@@ -33,6 +33,7 @@ object GameCatalog {
const val GOLF = "golf" const val GOLF = "golf"
const val WIZARD = "wizard" const val WIZARD = "wizard"
const val CRAZY_EIGHTS = "crazy8s" const val CRAZY_EIGHTS = "crazy8s"
const val CRIBBAGE = "cribbage"
const val SEQUENCE = "sequence" const val SEQUENCE = "sequence"
const val MASTERMIND = "mastermind" const val MASTERMIND = "mastermind"
const val BACKGAMMON = "backgammon" const val BACKGAMMON = "backgammon"
@@ -103,6 +104,18 @@ object GameCatalog {
blurb = "Match rank or suit, and eights change everything.", blurb = "Match rank or suit, and eights change everything.",
available = true, 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( GameDescriptor(
id = SEQUENCE, id = SEQUENCE,
title = "Sequence", title = "Sequence",
@@ -0,0 +1,104 @@
package org.prolibertate.games.game.cribbage
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Decks
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.engine.GameAi
import kotlin.math.abs
/**
* A cribbage opponent that counts rather than guesses.
*
* The lay-away is the decision that decides most hands, so it is the one part
* that is worked out exactly: every way of keeping four is scored against all
* forty-six cards that could still be cut, and the average is compared. What
* goes to the crib is then valued separately and added or subtracted depending
* on whose crib it is — the same two cards are a gift to your own crib and a
* present to the other side's.
*
* The play is a heuristic: take the points on offer, and try not to leave the
* count where a ten-card collects fifteen or thirty-one.
*/
class CribbageAi : GameAi<CribbageState, CribbageMove> {
override fun chooseMove(
state: CribbageState,
seat: Int,
legal: List<CribbageMove>,
): CribbageMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
val layAways = legal.filterIsInstance<LayAway>()
if (layAways.isNotEmpty()) return chooseLayAway(state, seat, layAways)
return choosePeg(state, legal.filterIsInstance<PegCard>())
}
private fun chooseLayAway(
state: CribbageState,
seat: Int,
choices: List<LayAway>,
): LayAway {
val hand = state.hands[seat]
val held = hand.toSet()
val unseen = Decks.standard52().filter { it !in held }
// A partner's crib is your crib. That is the whole of four-handed play.
val ownCrib = state.options.teamOf(seat) == state.options.teamOf(state.dealer)
return choices.maxByOrNull { choice ->
val given = choice.cards.toSet()
val keep = hand.filter { it !in given }
val expected = unseen.sumOf { starter ->
CribbageScoring.show(keep, starter, isCrib = false).sumOf { it.points }
}.toDouble() / unseen.size
expected + cribValue(choice.cards) * (if (ownCrib) 1.0 else -1.0)
} ?: choices.first()
}
/**
* Roughly what a lay-away is worth to whoever owns the crib.
*
* A five is the card of the game — every one of the sixteen ten-cards makes
* fifteen with it — so it is never given away cheaply, and a pair or a
* fifteen laid away together is two points the crib scores whatever is cut.
*/
private fun cribValue(cards: List<Card>): Double {
var value = cards.sumOf { card ->
when (card.rank) {
Rank.FIVE -> 2.0
Rank.JACK -> 0.6
Rank.TWO, Rank.THREE -> 0.3
Rank.ACE, Rank.QUEEN, Rank.KING -> 0.1
else -> 0.0
}
}
if (cards.size == 2) {
val first = cards[0]
val second = cards[1]
if (first.rank == second.rank) value += 2.0
if (pipValue(first) + pipValue(second) == 15) value += 2.0
when (abs(runOrder(first) - runOrder(second))) {
1 -> value += 1.0
2 -> value += 0.5
}
}
return value
}
private fun choosePeg(state: CribbageState, choices: List<PegCard>): PegCard {
val count = state.count
val onTheTable = state.series.map { it.card }
return choices.maxByOrNull { choice ->
val scored = CribbageScoring.peg(onTheTable + choice.card).sumOf { it.points }
val after = count + pipValue(choice.card)
var value = scored * 10.0
// Five and twenty-one are the two counts to avoid leaving: any of
// the sixteen ten-cards takes fifteen or thirty-one off them.
if (after == 5 || after == 21) value -= 3.0
// Leading a five is the same present, offered first.
if (count == 0 && choice.card.rank == Rank.FIVE) value -= 4.0
// Big cards early. Low ones are what let you keep playing at the
// awkward end of a series, where the last card is worth a point.
value + pipValue(choice.card) * 0.1
} ?: choices.first()
}
}
@@ -0,0 +1,172 @@
package org.prolibertate.games.game.cribbage
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
@Serializable
data class CribbageOptions(
val playerCount: Int = 2,
val pointsToWin: Int = 121,
/** Say so at the finish when the loser never got off the second street. */
val countSkunks: Boolean = true,
) {
init {
require(playerCount in 2..4) { "Cribbage seats two to four" }
require(pointsToWin == 61 || pointsToWin == 121) {
"Cribbage is played once round the board to 61 or twice to 121"
}
}
/** Six cards each head to head, five otherwise. */
val dealSize: Int get() = if (playerCount == 2) 6 else 5
/** Two players lay away two cards each; three or four lay away one. */
val layAwaySize: Int get() = if (playerCount == 2) 2 else 1
/**
* Three hands laying away one card apiece leave the crib a card short, so
* one is dealt straight into it off the top of the pack.
*/
val cribFromDeck: Int get() = if (playerCount == 3) 1 else 0
/** Four play in partnerships across the table; two or three play singly. */
val teamCount: Int get() = if (playerCount == 4) 2 else playerCount
fun teamOf(seat: Int): Int = if (playerCount == 4) seat % 2 else seat
}
/** Everyone holds four cards once the crib is laid away, whatever the table. */
const val CRIBBAGE_HAND_SIZE = 4
/** The count never passes this, and reaching it exactly is worth two. */
const val CRIBBAGE_LIMIT = 31
/**
* What a card is worth to the count and to a fifteen: aces are one, and every
* court card is ten.
*/
fun pipValue(card: Card): Int = when (card.rank) {
Rank.ACE -> 1
Rank.JACK, Rank.QUEEN, Rank.KING -> 10
else -> card.rank.order
}
/**
* Where a card sits in a run. The ace is low here — A-2-3 is a run and Q-K-A is
* not — which is why this cannot be [Rank.order], where the ace is high.
*/
fun runOrder(card: Card): Int = if (card.rank == Rank.ACE) 1 else card.rank.order
/** One card laid on the table during the play, and by whom. */
@Serializable
data class PeggedCard(val seat: Int, val card: Card)
/** One thing a hand scored for, in the words a player would use for it. */
@Serializable
data class ScoreLine(val label: String, val points: Int)
/**
* A hand or a crib as it was counted at the show.
*
* [counted] is false for a hand the game never got to: somebody pegged out
* ahead of it in the counting order, and the rest of the table shows what it
* would have been worth rather than pretending it was scored.
*/
@Serializable
data class ShowScore(
val seat: Int,
val isCrib: Boolean,
val cards: List<Card>,
val lines: List<ScoreLine>,
val counted: Boolean = true,
) {
val total: Int get() = lines.sumOf { it.points }
}
@Serializable
enum class CribbagePhase {
/** Everyone is choosing what to give the crib. */
DISCARD,
/** Pegging: cards on the table one at a time, count to thirty-one. */
PLAY,
/** The hands are counted and on screen, waiting to be read. */
SHOW,
GAME_OVER,
}
@Serializable
data class CribbageState(
val options: CribbageOptions,
val seed: Long,
val handNumber: Int,
val dealer: Int,
/**
* What each seat was dealt, less what it laid away. Cards stay here once
* they have been pegged — [remaining] is what is still in the hand, and the
* whole four are wanted again at the show.
*/
val hands: List<List<Card>>,
/** Cards still held per seat, which survives redaction where [hands] does not. */
val handCounts: List<Int>,
val crib: List<Card>,
val cribCount: Int,
/** What is left of the pack. Never sent to anyone. */
val deck: List<Card>,
val starter: Card?,
/** Everything pegged this hand, in order. */
val played: List<PeggedCard>,
/** The cards of the series being counted now, which is cleared at thirty-one or a go. */
val series: List<PeggedCard>,
val saidGo: Set<Int>,
val turn: Int,
val lastToPlay: Int?,
/** By team. Two or three play singly, so there a team is a seat. */
val scores: List<Int>,
/** Where the back peg stands: each side's score before its last award. */
val previousScores: List<Int>,
val show: List<ShowScore>,
val phase: CribbagePhase,
val winner: Int?,
val log: List<String>,
) {
/** The running count of the series in play. Never above [CRIBBAGE_LIMIT]. */
val count: Int get() = series.sumOf { pipValue(it.card) }
/** What [seat] has not pegged yet. Empty for a seat whose hand is redacted. */
fun remaining(seat: Int): List<Card> {
val gone = played.filter { it.seat == seat }.map { it.card }.toSet()
return hands[seat].filter { it !in gone }
}
}
@Serializable
sealed interface CribbageMove
/**
* Cards given to the crib. Two of them head to head, one otherwise.
*
* The cards are held in a fixed order rather than the order they were tapped
* in, because the host compares a move against the list it generated itself —
* a client that picked the same two cards the other way round is making the
* same move and must not be refused for it.
*/
@Serializable
@SerialName("layaway")
data class LayAway(val cards: List<Card>) : CribbageMove {
companion object {
fun of(cards: Collection<Card>): LayAway = LayAway(cards.sortedWith(cribbageCardOrder))
}
}
@Serializable
@SerialName("peg")
data class PegCard(val card: Card) : CribbageMove
/** An arbitrary but stable order, used only to make a lay-away comparable. */
val cribbageCardOrder: Comparator<Card> =
compareBy({ it.suit.ordinal }, { it.rank.ordinal })
@@ -0,0 +1,421 @@
package org.prolibertate.games.game.cribbage
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Decks
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.shuffledWith
import org.prolibertate.games.game.engine.GameRules
import org.prolibertate.games.game.engine.TableConfig
import kotlin.random.Random
/**
* Cribbage as a pure state machine, for two, three or four. See RULES-cribbage.md.
*
* One thing shapes the whole of this file: a game of cribbage is over the
* instant a peg reaches the target, in the middle of a count if that is where
* it happens. So every award goes through [award], which stops the game there
* and refuses everything after it, rather than the hand being scored in full
* and the total compared afterwards.
*/
object CribbageRules : GameRules<CribbageState, CribbageMove> {
override val gameId: String = GameCatalog.CRIBBAGE
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
override fun initialState(config: TableConfig): CribbageState {
val options = json.decodeFromString<CribbageOptions>(config.optionsJson)
require(config.seats.size == options.playerCount) {
"Seat count ${config.seats.size} does not match ${options.playerCount} players"
}
return deal(
options = options,
seed = config.seed,
handNumber = 0,
dealer = 0,
scores = List(options.teamCount) { 0 },
log = listOf("Hand 1. Seat 0 deals."),
)
}
private fun deal(
options: CribbageOptions,
seed: Long,
handNumber: Int,
dealer: Int,
scores: List<Int>,
log: List<String>,
): CribbageState {
val random = Random(seed + handNumber * 7919L)
var pack = Decks.standard52().shuffledWith(random)
val hands = MutableList(options.playerCount) { emptyList<Card>() }
// Dealt round the table from the dealer's left, as they would be.
for (step in 1..options.playerCount) {
val seat = (dealer + step) % options.playerCount
hands[seat] = pack.take(options.dealSize)
pack = pack.drop(options.dealSize)
}
val crib = pack.take(options.cribFromDeck)
pack = pack.drop(options.cribFromDeck)
return CribbageState(
options = options,
seed = seed,
handNumber = handNumber,
dealer = dealer,
hands = hands,
handCounts = hands.map { it.size },
crib = crib,
cribCount = crib.size,
deck = pack,
starter = null,
played = emptyList(),
series = emptyList(),
saidGo = emptySet(),
turn = (dealer + 1) % options.playerCount,
lastToPlay = null,
scores = scores,
previousScores = scores,
show = emptyList(),
phase = CribbagePhase.DISCARD,
winner = null,
log = log,
)
}
/** Advances from [CribbagePhase.SHOW] into the next deal, the deal moving on one seat. */
fun nextHand(state: CribbageState): CribbageState {
check(state.phase == CribbagePhase.SHOW) { "The hand is not over" }
val dealer = (state.dealer + 1) % state.options.playerCount
return deal(
options = state.options,
seed = state.seed,
handNumber = state.handNumber + 1,
dealer = dealer,
scores = state.scores,
log = state.log + "Hand ${state.handNumber + 2}. Seat $dealer deals.",
)
}
override fun currentSeat(state: CribbageState): Int? = when (state.phase) {
CribbagePhase.DISCARD, CribbagePhase.PLAY -> state.turn
CribbagePhase.SHOW, CribbagePhase.GAME_OVER -> null
}
override fun legalMoves(state: CribbageState, seat: Int): List<CribbageMove> {
if (currentSeat(state) != seat) return emptyList()
return when (state.phase) {
CribbagePhase.DISCARD ->
combinations(state.hands[seat], state.options.layAwaySize).map { LayAway.of(it) }
CribbagePhase.PLAY -> state.remaining(seat)
.filter { pipValue(it) + state.count <= CRIBBAGE_LIMIT }
.map { PegCard(it) }
else -> emptyList()
}
}
override fun applyMove(state: CribbageState, seat: Int, move: CribbageMove): CribbageState {
require(currentSeat(state) == seat) { "Not seat $seat's turn" }
require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" }
return when (move) {
is LayAway -> applyLayAway(state, seat, move)
is PegCard -> applyPeg(state, seat, move)
}
}
// -----------------------------------------------------------------------
// The crib
// -----------------------------------------------------------------------
private fun applyLayAway(
state: CribbageState,
seat: Int,
move: LayAway,
): CribbageState {
val hands = state.hands.toMutableList()
hands[seat] = hands[seat] - move.cards.toSet()
val crib = state.crib + move.cards
val next = state.copy(
hands = hands,
handCounts = hands.map { it.size },
crib = crib,
cribCount = crib.size,
turn = (seat + 1) % state.options.playerCount,
log = state.log + "Seat $seat lays ${move.cards.size} away to seat ${state.dealer}'s crib.",
)
val everyoneHasLaidAway = next.hands.all { it.size == CRIBBAGE_HAND_SIZE }
return if (everyoneHasLaidAway) cutTheStarter(next) else next
}
/**
* The cut, which is the top of what is left of a pack already shuffled — so
* cutting it anywhere gives the same distribution as cutting it here.
*/
private fun cutTheStarter(state: CribbageState): CribbageState {
val starter = state.deck.first()
val cut = state.copy(
starter = starter,
deck = state.deck.drop(1),
turn = (state.dealer + 1) % state.options.playerCount,
phase = CribbagePhase.PLAY,
log = state.log + "The starter is ${starter.label}.",
)
// Two for his heels, and it is the dealer's whoever cut it.
return if (starter.rank == Rank.JACK) {
award(cut, state.dealer, 2, "his heels")
} else {
cut
}
}
// -----------------------------------------------------------------------
// The play
// -----------------------------------------------------------------------
private fun applyPeg(state: CribbageState, seat: Int, move: PegCard): CribbageState {
val series = state.series + PeggedCard(seat, move.card)
var next = state.copy(
played = state.played + PeggedCard(seat, move.card),
series = series,
lastToPlay = seat,
)
next = next.copy(
handCounts = handCounts(next),
log = next.log + "Seat $seat plays ${move.card.label} for ${next.count}.",
)
CribbageScoring.peg(series.map { it.card }).forEach { line ->
next = award(next, seat, line.points, line.label.lowercase())
}
if (next.winner != null) return next
return advancePlay(next, seat)
}
/**
* Hands the play to the next seat that can actually lay a card.
*
* A seat that cannot play says go — the count only ever rises within a
* series, so a seat that cannot play now cannot play later in it either,
* and this walks past it rather than stopping the table on a move with one
* possible answer. When nobody at all can play, the series is closed.
*/
private fun advancePlay(state: CribbageState, justPlayed: Int): CribbageState {
if (state.count == CRIBBAGE_LIMIT) {
// Thirty-one has already paid two; there is no point for the go on top.
return closeSeries(state, justPlayed, payForTheGo = false)
}
var next = state
val seats = next.options.playerCount
for (step in 1..seats) {
val seat = (justPlayed + step) % seats
val held = next.remaining(seat)
if (held.isEmpty()) continue
if (held.any { pipValue(it) + next.count <= CRIBBAGE_LIMIT }) {
return next.copy(turn = seat)
}
if (seat !in next.saidGo) {
next = next.copy(
saidGo = next.saidGo + seat,
log = next.log + "Seat $seat says go.",
)
}
}
return closeSeries(next, justPlayed, payForTheGo = true)
}
/**
* Clears the count after a thirty-one or a go, and pays for the last card.
*
* The seat after whoever played that last card leads the next series, which
* is why a hand can turn on being left holding cards nobody can follow.
*/
private fun closeSeries(
state: CribbageState,
lastPlayer: Int,
payForTheGo: Boolean,
): CribbageState {
var next = state
if (payForTheGo) next = award(next, lastPlayer, 1, "the last card")
if (next.winner != null) return next
val dealt = next.options.playerCount * CRIBBAGE_HAND_SIZE
if (next.played.size == dealt) return theShow(next)
val seats = next.options.playerCount
val lead = (1..seats)
.map { (lastPlayer + it) % seats }
.first { next.remaining(it).isNotEmpty() }
return next.copy(series = emptyList(), saidGo = emptySet(), turn = lead)
}
// -----------------------------------------------------------------------
// The show
// -----------------------------------------------------------------------
/**
* Counts the hands in order — the dealer's left first, round to the dealer,
* and the crib last of all.
*
* The order is the game: a non-dealer who can count out gets there before
* the dealer's hand and crib are ever counted, and that is what makes being
* behind on the dealer's turn worse than the numbers alone suggest.
*/
private fun theShow(state: CribbageState): CribbageState {
val starter = requireNotNull(state.starter) { "The show without a starter" }
val seats = state.options.playerCount
val entries = (1..seats).map { step ->
val seat = (state.dealer + step) % seats
ShowScore(
seat = seat,
isCrib = false,
cards = state.hands[seat],
lines = CribbageScoring.show(state.hands[seat], starter, isCrib = false),
)
} + ShowScore(
seat = state.dealer,
isCrib = true,
cards = state.crib,
lines = CribbageScoring.show(state.crib, starter, isCrib = true),
)
var next = state
val counted = mutableListOf<ShowScore>()
for (entry in entries) {
if (next.winner != null) {
counted += entry.copy(counted = false)
continue
}
counted += entry
entry.lines.forEach { line ->
next = award(next, entry.seat, line.points, line.label.lowercase())
}
}
return next.copy(
show = counted,
phase = if (next.winner != null) CribbagePhase.GAME_OVER else CribbagePhase.SHOW,
)
}
// -----------------------------------------------------------------------
// Pegging points
// -----------------------------------------------------------------------
/**
* Moves [seat]'s side up the board, and ends the game there if that reaches
* the target.
*
* Everything scored anywhere in the game comes through here, so the game
* cannot be carried past its own finish by the rest of a count.
*/
private fun award(
state: CribbageState,
seat: Int,
points: Int,
reason: String,
): CribbageState {
if (points <= 0 || state.winner != null) return state
val team = state.options.teamOf(seat)
val scores = state.scores.toMutableList()
val previous = state.previousScores.toMutableList()
previous[team] = scores[team]
scores[team] = scores[team] + points
val won = scores[team] >= state.options.pointsToWin
return state.copy(
scores = scores,
previousScores = previous,
winner = if (won) team else null,
phase = if (won) CribbagePhase.GAME_OVER else state.phase,
log = state.log + "Seat $seat pegs $points for $reason.",
)
}
private fun handCounts(state: CribbageState): List<Int> =
(0 until state.options.playerCount).map { seat ->
state.hands[seat].size - state.played.count { it.seat == seat }
}
/** Every way of choosing [size] cards, in the order they are held. */
private fun combinations(cards: List<Card>, size: Int): List<List<Card>> {
if (size == 0) return listOf(emptyList())
if (cards.size < size) return emptyList()
val chosen = mutableListOf<List<Card>>()
fun take(from: Int, soFar: List<Card>) {
if (soFar.size == size) {
chosen += soFar
return
}
for (index in from until cards.size) take(index + 1, soFar + cards[index])
}
take(0, emptyList())
return chosen
}
// -----------------------------------------------------------------------
// Engine plumbing
// -----------------------------------------------------------------------
override fun isFinished(state: CribbageState): Boolean =
state.phase == CribbagePhase.GAME_OVER
override fun summary(state: CribbageState): String {
val board = state.scores.mapIndexed { team, score -> "T$team:$score" }.joinToString(" ")
val winner = state.winner ?: return board
val trailing = state.scores
.filterIndexed { team, _ -> team != winner }
.maxOrNull() ?: 0
return "Team $winner wins ($board)" + skunk(state.options, trailing)
}
/**
* A loser left on the second street is skunked, and one who never left the
* first is double skunked. In a game to 121 those lines are 91 and 61.
*/
private fun skunk(options: CribbageOptions, trailing: Int): String {
if (!options.countSkunks) return ""
return when {
trailing <= options.pointsToWin / 2 -> " — a double skunk"
trailing < options.pointsToWin - options.pointsToWin / 4 -> " — a skunk"
else -> ""
}
}
/**
* Hands and the crib are hidden until the show, and the rest of the pack
* always: the starter is cut from it, so a client that could see it would
* know the cut before it happened.
*/
override fun viewFor(state: CribbageState, seat: Int): CribbageState {
val revealed = state.phase == CribbagePhase.SHOW || state.phase == CribbagePhase.GAME_OVER
return state.copy(
hands = state.hands.mapIndexed { index, hand ->
if (revealed || index == seat) hand else emptyList()
},
crib = if (revealed) state.crib else emptyList(),
deck = emptyList(),
)
}
override fun encodeState(state: CribbageState): String = json.encodeToString(state)
override fun decodeState(json: String): CribbageState = this.json.decodeFromString(json)
override fun encodeMove(move: CribbageMove): String = json.encodeToString(move)
override fun decodeMove(json: String): CribbageMove = this.json.decodeFromString(json)
}
@@ -0,0 +1,149 @@
package org.prolibertate.games.game.cribbage
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
/**
* Everything cribbage counts, as pure arithmetic over cards.
*
* Both halves of the game are here: [show] counts a hand against the starter,
* and [peg] counts what the card just laid on the table scored. They are kept
* apart because they genuinely differ — a flush and his nob are worth nothing
* during the play, and the count reaching fifteen is worth nothing at the show.
*/
object CribbageScoring {
/**
* A hand of four counted with the [starter].
*
* [isCrib] tightens one rule only: a crib scores a flush only when all five
* cards share a suit, where a hand takes four for its own four.
*/
fun show(hand: List<Card>, starter: Card, isCrib: Boolean): List<ScoreLine> {
val all = hand + starter
val lines = mutableListOf<ScoreLine>()
val fifteens = countFifteens(all)
if (fifteens > 0) {
lines += ScoreLine(
label = if (fifteens == 1) "Fifteen" else "$fifteens fifteens",
points = fifteens * 2,
)
}
lines += pairs(all)
lines += runs(all)
flush(hand, starter, isCrib)?.let { lines += it }
if (hand.any { it.rank == Rank.JACK && it.suit == starter.suit }) {
lines += ScoreLine("His nob", 1)
}
return lines
}
/**
* What the last card of [series] scored as it landed.
*
* The series is the run of cards since the count was last cleared, so
* everything here is read off its tail: only cards still on the table
* between one thirty-one and the next can pair or make a run.
*/
fun peg(series: List<Card>): List<ScoreLine> {
if (series.isEmpty()) return emptyList()
val lines = mutableListOf<ScoreLine>()
val count = series.sumOf { pipValue(it) }
if (count == 15) lines += ScoreLine("Fifteen", 2)
if (count == CRIBBAGE_LIMIT) lines += ScoreLine("Thirty-one", 2)
// Pairs peg only off the end: a pair split by somebody else's card is
// not a pair, which is the whole point of playing one to break it up.
val last = series.last()
var matching = 0
for (card in series.asReversed()) {
if (card.rank != last.rank) break
matching++
}
if (matching >= 2) lines += ScoreLine(pairLabel(matching), matching * (matching - 1))
// The longest tail that is a run, in any order — 5-3-4 is a run of three.
for (length in series.size downTo 3) {
val tail = series.takeLast(length).map { runOrder(it) }
if (tail.distinct().size == length && tail.max() - tail.min() == length - 1) {
lines += ScoreLine("Run of $length", length)
break
}
}
return lines
}
/** Every combination adding to fifteen, of any size. */
private fun countFifteens(cards: List<Card>): Int {
var found = 0
for (mask in 1 until (1 shl cards.size)) {
var sum = 0
for (index in cards.indices) {
if ((mask shr index) and 1 == 1) sum += pipValue(cards[index])
}
if (sum == 15) found++
}
return found
}
private fun pairs(cards: List<Card>): List<ScoreLine> = cards
.groupBy { it.rank }
.values
.filter { it.size >= 2 }
.sortedBy { runOrder(it.first()) }
.map { group ->
ScoreLine(
label = "${pairLabel(group.size)} of ${group.first().rank.short}s",
points = group.size * (group.size - 1),
)
}
private fun pairLabel(size: Int): String = when (size) {
2 -> "Pair"
3 -> "Pair royal"
else -> "Double pair royal"
}
/**
* Runs, with duplicated ranks multiplying rather than adding.
*
* 4-5-5-6 is two runs of three and not a run of four: the two fives each
* make their own run with the four and the six, which is why the length is
* multiplied by how many ways each rank in it can be filled.
*/
private fun runs(cards: List<Card>): List<ScoreLine> {
val copies = cards.groupingBy { runOrder(it) }.eachCount()
val ordered = copies.keys.sorted()
val lines = mutableListOf<ScoreLine>()
var index = 0
while (index < ordered.size) {
var end = index
while (end + 1 < ordered.size && ordered[end + 1] == ordered[end] + 1) end++
val length = end - index + 1
if (length >= 3) {
val ways = (index..end).fold(1) { acc, at -> acc * copies.getValue(ordered[at]) }
lines += ScoreLine(
label = if (ways == 1) "Run of $length" else "$ways runs of $length",
points = length * ways,
)
}
index = end + 1
}
return lines
}
private fun flush(hand: List<Card>, starter: Card, isCrib: Boolean): ScoreLine? {
val suit = hand.firstOrNull()?.suit ?: return null
if (hand.any { it.suit != suit }) return null
val withStarter = starter.suit == suit
// A crib takes nothing for four of a suit — it has to be all five.
if (isCrib && !withStarter) return null
val size = hand.size + if (withStarter) 1 else 0
return ScoreLine("Flush of $size", size)
}
}
@@ -98,6 +98,18 @@ class MatchController<S : Any, M : Any>(
private val lock = Mutex() private val lock = Mutex()
private var sequence = 0 private var sequence = 0
/**
* A scoreboard a player has read and released, waiting to be let past.
*
* The hold belongs to a particular scoreboard rather than to the phase, and
* releasing it has to be spent: [awaitsConfirmation] is asked about the
* state, which has not changed while the players were reading it, so
* without something consumable here the same state would stop the table
* again the instant [confirmAdvance] restarted the loop — and the next hand
* would never be dealt.
*/
private var released = false
private val peers = mutableMapOf<String, Connection>() private val peers = mutableMapOf<String, Connection>()
private var hostLink: Connection? = null private var hostLink: Connection? = null
@@ -288,9 +300,16 @@ class MatchController<S : Any, M : Any>(
if (role == Role.HOST && awaitsConfirmation(current)) { if (role == Role.HOST && awaitsConfirmation(current)) {
// Hold here until a player has read the scoreboard. Only the // Hold here until a player has read the scoreboard. Only the
// host waits; clients simply render what they were sent. // host waits; clients simply render what they were sent.
val letThrough = lock.withLock {
val granted = released
released = false
granted
}
if (!letThrough) {
_awaitingConfirmation.value = true _awaitingConfirmation.value = true
return return
} }
}
delay(aiThinkingMillis() * 2) delay(aiThinkingMillis() * 2)
val advanced = advanceIdle(current) val advanced = advanceIdle(current)
if (advanced == null) { if (advanced == null) {
@@ -398,7 +417,10 @@ class MatchController<S : Any, M : Any>(
if (!_awaitingConfirmation.value) return if (!_awaitingConfirmation.value) return
_awaitingConfirmation.value = false _awaitingConfirmation.value = false
if (role != Role.HOST) return 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. */ /** Lets a player go on looking at a table they have been told is over. */
@@ -10,7 +10,6 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.prolibertate.games.update.UpdateChannel
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings") private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
@@ -32,8 +31,6 @@ data class Settings(
* snapped back before a real name could be typed. * snapped back before a real name could be typed.
*/ */
val playerName: String = "", 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 * 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 SPEED = floatPreferencesKey("animation_speed")
val UPDATE_ON_LAUNCH = booleanPreferencesKey("check_updates_on_launch") val UPDATE_ON_LAUNCH = booleanPreferencesKey("check_updates_on_launch")
val NAME = stringPreferencesKey("player_name") 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<Settings> = context.dataStore.data.map { prefs -> val settings: Flow<Settings> = context.dataStore.data.map { prefs ->
@@ -69,16 +70,9 @@ class SettingsRepository(private val context: Context) {
animationSpeed = prefs[Keys.SPEED] ?: 1.0f, animationSpeed = prefs[Keys.SPEED] ?: 1.0f,
checkForUpdatesOnLaunch = prefs[Keys.UPDATE_ON_LAUNCH] ?: true, checkForUpdatesOnLaunch = prefs[Keys.UPDATE_ON_LAUNCH] ?: true,
playerName = prefs[Keys.NAME] ?: "", 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) { suspend fun setSoundEnabled(enabled: Boolean) {
context.dataStore.edit { it[Keys.SOUND] = enabled } context.dataStore.edit { it[Keys.SOUND] = enabled }
} }
@@ -129,6 +129,11 @@ fun AppRoot(env: AppEnv) {
if (!splashDone) { if (!splashDone) {
SplashScreen(onFinished = { splashDone = true }) 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)
} }
} }
@@ -37,6 +37,7 @@ import org.prolibertate.games.game.checkers.CheckersOptions
import org.prolibertate.games.game.chess.ChessLevel import org.prolibertate.games.game.chess.ChessLevel
import org.prolibertate.games.game.chess.ChessOptions import org.prolibertate.games.game.chess.ChessOptions
import org.prolibertate.games.game.crazyeights.CrazyEightsOptions 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.euchre.EuchreOptions
import org.prolibertate.games.game.golf.GolfOptions import org.prolibertate.games.game.golf.GolfOptions
import org.prolibertate.games.game.kaiser.KaiserOptions import org.prolibertate.games.game.kaiser.KaiserOptions
@@ -78,6 +79,7 @@ fun GameSetupScreen(
var golf by remember { mutableStateOf(GolfOptions()) } var golf by remember { mutableStateOf(GolfOptions()) }
var kaiser by remember { mutableStateOf(KaiserOptions()) } var kaiser by remember { mutableStateOf(KaiserOptions()) }
var crazyEights by remember { mutableStateOf(CrazyEightsOptions()) } var crazyEights by remember { mutableStateOf(CrazyEightsOptions()) }
var cribbage by remember { mutableStateOf(CribbageOptions()) }
var wizard by remember { mutableStateOf(WizardOptions()) } var wizard by remember { mutableStateOf(WizardOptions()) }
var chess by remember { mutableStateOf(ChessOptions()) } var chess by remember { mutableStateOf(ChessOptions()) }
var tayu by remember { mutableStateOf(TayuOptions()) } var tayu by remember { mutableStateOf(TayuOptions()) }
@@ -100,6 +102,7 @@ fun GameSetupScreen(
GameCatalog.GOLF -> setupJson.encodeToString(golf) GameCatalog.GOLF -> setupJson.encodeToString(golf)
GameCatalog.KAISER -> setupJson.encodeToString(kaiser) GameCatalog.KAISER -> setupJson.encodeToString(kaiser)
GameCatalog.CRAZY_EIGHTS -> setupJson.encodeToString(crazyEights) GameCatalog.CRAZY_EIGHTS -> setupJson.encodeToString(crazyEights)
GameCatalog.CRIBBAGE -> setupJson.encodeToString(cribbage)
GameCatalog.WIZARD -> setupJson.encodeToString(wizard) GameCatalog.WIZARD -> setupJson.encodeToString(wizard)
GameCatalog.CHESS -> setupJson.encodeToString(chess) GameCatalog.CHESS -> setupJson.encodeToString(chess)
GameCatalog.TAYU -> setupJson.encodeToString(tayu) GameCatalog.TAYU -> setupJson.encodeToString(tayu)
@@ -132,6 +135,8 @@ fun GameSetupScreen(
crazyEights = it crazyEights = it
} }
GameCatalog.CRIBBAGE -> CribbageOptionsEditor(cribbage) { cribbage = it }
GameCatalog.WIZARD -> WizardOptionsEditor(wizard) { wizard = it } GameCatalog.WIZARD -> WizardOptionsEditor(wizard) { wizard = it }
GameCatalog.CHESS -> ChessOptionsEditor( GameCatalog.CHESS -> ChessOptionsEditor(
options = chess, options = chess,
@@ -204,7 +209,7 @@ private fun offlineConfig(
seat = seat, seat = seat,
name = if (seat == localSeat) playerName else "Computer $seat", name = if (seat == localSeat) playerName else "Computer $seat",
kind = if (seat == localSeat) PlayerKind.HUMAN_LOCAL else PlayerKind.AI, kind = if (seat == localSeat) PlayerKind.HUMAN_LOCAL else PlayerKind.AI,
team = teamForSeat(gameId, seat), team = teamForSeat(gameId, seat, seatCount),
) )
}, },
optionsJson = optionsJson, optionsJson = optionsJson,
@@ -236,6 +241,9 @@ fun seatCountFor(gameId: String, optionsJson: String): Int {
GameCatalog.CRAZY_EIGHTS -> GameCatalog.CRAZY_EIGHTS ->
setupJson.decodeFromString<CrazyEightsOptions>(optionsJson).playerCount setupJson.decodeFromString<CrazyEightsOptions>(optionsJson).playerCount
GameCatalog.CRIBBAGE ->
setupJson.decodeFromString<CribbageOptions>(optionsJson).playerCount
GameCatalog.WIZARD -> GameCatalog.WIZARD ->
setupJson.decodeFromString<WizardOptions>(optionsJson).playerCount setupJson.decodeFromString<WizardOptions>(optionsJson).playerCount
@@ -250,11 +258,20 @@ fun seatCountFor(gameId: String, optionsJson: String): Int {
}.getOrDefault(fallback) }.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 // 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 // edges you are running your rivers to: even seats north and south, odd
// seats east and west. // seats east and west.
GameCatalog.EUCHRE, GameCatalog.KAISER, GameCatalog.SEQUENCE, GameCatalog.TAYU -> seat % 2 GameCatalog.EUCHRE, GameCatalog.KAISER, GameCatalog.SEQUENCE, GameCatalog.TAYU -> seat % 2
GameCatalog.CRIBBAGE -> if (seatCount == 4) seat % 2 else seat
// Everyone else plays for themselves. // Everyone else plays for themselves.
else -> seat 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 @Composable
private fun WizardOptionsEditor(options: WizardOptions, onChange: (WizardOptions) -> Unit) { private fun WizardOptionsEditor(options: WizardOptions, onChange: (WizardOptions) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -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<Release?>(null) }
var dismissed by remember { mutableStateOf(false) }
var status by remember { mutableStateOf<String?>(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") }
},
)
}
@@ -53,7 +53,7 @@ fun LobbyScreen(
optionsJson = route.optionsJson, optionsJson = route.optionsJson,
hostName = playerName, hostName = playerName,
seatCount = seatCount, seatCount = seatCount,
teamOf = { seat -> teamForSeat(route.gameId, seat) }, teamOf = { seat -> teamForSeat(route.gameId, seat, seatCount) },
) )
} else { } else {
env.lobby.startDiscovery() env.lobby.startDiscovery()
@@ -16,6 +16,11 @@ import org.prolibertate.games.game.crazyeights.CrazyEightsMove
import org.prolibertate.games.game.crazyeights.CrazyEightsPhase import org.prolibertate.games.game.crazyeights.CrazyEightsPhase
import org.prolibertate.games.game.crazyeights.CrazyEightsRules import org.prolibertate.games.game.crazyeights.CrazyEightsRules
import org.prolibertate.games.game.crazyeights.CrazyEightsState 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.EuchreAi
import org.prolibertate.games.game.euchre.EuchreMove import org.prolibertate.games.game.euchre.EuchreMove
import org.prolibertate.games.game.euchre.EuchrePhase 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.CheckersScreen
import org.prolibertate.games.ui.game.ChessScreen import org.prolibertate.games.ui.game.ChessScreen
import org.prolibertate.games.ui.game.CrazyEightsScreen 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.EuchreScreen
import org.prolibertate.games.ui.game.GolfScreen import org.prolibertate.games.ui.game.GolfScreen
import org.prolibertate.games.ui.game.KaiserScreen import org.prolibertate.games.ui.game.KaiserScreen
@@ -308,6 +314,33 @@ fun PlayScreen(
CrazyEightsScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) CrazyEightsScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
} }
GameCatalog.CRIBBAGE -> {
val controller = remember(route) {
MatchController<CribbageState, CribbageMove>(
rules = CribbageRules,
ai = CribbageAi(),
config = route.config,
scope = scope,
role = role,
localSeats = setOf(route.localSeat),
primarySeat = route.localSeat,
advanceIdle = { state ->
if (state.phase == CribbagePhase.SHOW) {
CribbageRules.nextHand(state)
} else {
null
}
},
// The show is the half of cribbage that is read rather than
// played, so the table stops on it until somebody says go on.
awaitsConfirmation = { state -> state.phase == CribbagePhase.SHOW },
aiThinkingMillis = { settings.scaled(700L) },
)
}
StartMatch(env, controller, route)
CribbageScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
}
GameCatalog.TAYU -> { GameCatalog.TAYU -> {
val controller = remember(route) { val controller = remember(route) {
MatchController<TayuState, TayuMove>( MatchController<TayuState, TayuMove>(
@@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Divider import androidx.compose.material3.Divider
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
@@ -26,8 +25,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.prolibertate.games.settings.Settings import org.prolibertate.games.settings.Settings
import org.prolibertate.games.update.Release
import org.prolibertate.games.update.UpdateChannel import org.prolibertate.games.update.UpdateChannel
import org.prolibertate.games.update.Updater import org.prolibertate.games.update.UpdateVerdict
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
@@ -42,7 +42,7 @@ fun SettingsScreen(
// caret jumped and a cleared field refilled itself. // caret jumped and a cleared field refilled itself.
var editedName by remember { mutableStateOf<String?>(null) } var editedName by remember { mutableStateOf<String?>(null) }
var updateStatus by remember { mutableStateOf<String?>(null) } var updateStatus by remember { mutableStateOf<String?>(null) }
var pendingRelease by remember { mutableStateOf<Updater.Release?>(null) } var pendingRelease by remember { mutableStateOf<Release?>(null) }
ScreenScaffold(title = "Settings", onBack = onBack) { modifier -> ScreenScaffold(title = "Settings", onBack = onBack) { modifier ->
Column( Column(
@@ -111,45 +111,32 @@ fun SettingsScreen(
Divider() Divider()
Text("Updates", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium) 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 { Column {
Text("Update channel", fontWeight = FontWeight.Bold) Text(
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { text = "Installed: v${env.updater.installedVersionName()} — " +
UpdateChannel.entries.forEach { channel -> "${channel.label} channel.",
FilterChip( style = MaterialTheme.typography.bodySmall,
selected = settings.updateChannel == channel, )
onClick = { Text(
scope.launch { text = "${channel.blurb}. This build updates from the " +
env.settingsRepository.setUpdateChannel(channel) "${channel.label} channel only.",
} style = MaterialTheme.typography.bodySmall,
updateStatus = null )
pendingRelease = null 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."
}, },
label = { Text(channel.label) },
)
}
}
Text(
text = settings.updateChannel.blurb + ".",
style = MaterialTheme.typography.bodySmall, 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( SettingRow(
@@ -166,26 +153,20 @@ fun SettingsScreen(
PrimaryAction(text = "Check for updates now") { PrimaryAction(text = "Check for updates now") {
scope.launch { scope.launch {
updateStatus = "Checking the ${settings.updateChannel.label} channel…" updateStatus = "Checking the ${channel.label} channel…"
when (val result = env.updater.check(settings.updateChannel)) { when (val verdict = env.updater.check()) {
is Updater.Result.UpToDate -> { is UpdateVerdict.UpToDate -> {
updateStatus = updateStatus = "You're on the latest ${channel.label} build."
"You're on the latest ${settings.updateChannel.label} build."
pendingRelease = null pendingRelease = null
} }
is Updater.Result.Available -> { is UpdateVerdict.Install -> {
pendingRelease = result.release pendingRelease = verdict.release
updateStatus = if (result.isChannelSwitch) { updateStatus = "Version ${verdict.release.tag} is available."
"${result.release.tag} is the current " +
"${result.release.channel.label} build."
} else {
"Version ${result.release.tag} is available."
}
} }
is Updater.Result.Failed -> { is UpdateVerdict.Refused -> {
updateStatus = result.reason updateStatus = verdict.reason
pendingRelease = null pendingRelease = null
} }
} }
@@ -0,0 +1,545 @@
package org.prolibertate.games.ui.game
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cribbage.CribbageMove
import org.prolibertate.games.game.cribbage.CribbagePhase
import org.prolibertate.games.game.cribbage.CribbageState
import org.prolibertate.games.game.cribbage.LayAway
import org.prolibertate.games.game.cribbage.PegCard
import org.prolibertate.games.game.cribbage.ShowScore
import org.prolibertate.games.net.MatchController
import org.prolibertate.games.ui.ScreenScaffold
import org.prolibertate.games.ui.common.CardBackView
import org.prolibertate.games.ui.common.PlayingCardView
import org.prolibertate.games.ui.theme.FeltGreenDark
import org.prolibertate.games.ui.theme.TeamColours
/**
* The cribbage table.
*
* Two things have to be legible at a glance, because the whole game turns on
* them: the count, which decides what can be played at all, and where the pegs
* are. The board is drawn as a track per side with both pegs on it — the pale
* one is where that side stood before its last score, which is exactly what the
* back peg on a real board is for.
*/
@Composable
fun CribbageScreen(
controller: MatchController<CribbageState, CribbageMove>,
localSeat: Int,
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val legal by controller.legalMoves.collectAsState()
val current = state
if (current == null) {
ScreenScaffold(title = "Cribbage", onBack = onExit) { modifier ->
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
}
// Cards picked out for the crib, held here until they are laid away
// together. Forgotten whenever a new hand is dealt.
var chosen by remember(current.handNumber) { mutableStateOf(emptySet<Card>()) }
ScreenScaffold(title = "Cribbage", onBack = onExit) { modifier ->
BoxWithConstraints(modifier = modifier) {
val cardWidth = (minOf(maxWidth, maxHeight) * 0.16f).coerceIn(44.dp, 92.dp)
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
PegBoard(current, localSeat)
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(FeltGreenDark),
contentAlignment = Alignment.Center,
) {
TableArea(current, localSeat, cardWidth)
}
StatusLine(current, localSeat)
if (current.phase == CribbagePhase.DISCARD) {
LayAwayControls(
state = current,
localSeat = localSeat,
chosen = chosen,
onLayAway = {
controller.submit(LayAway.of(chosen))
chosen = emptySet()
},
)
}
HandRow(
state = current,
localSeat = localSeat,
legal = legal,
cardWidth = cardWidth,
chosen = chosen,
onTap = { card ->
when (current.phase) {
CribbagePhase.DISCARD -> {
chosen = if (card in chosen) {
chosen - card
} else if (chosen.size < current.options.layAwaySize) {
chosen + card
} else {
// Already holding a full lay-away: the newest
// tap replaces the oldest rather than being
// ignored, so no card has to be untapped first.
chosen.drop(1).toSet() + card
}
}
CribbagePhase.PLAY -> controller.submit(PegCard(card))
else -> Unit
}
},
)
}
if (current.phase == CribbagePhase.SHOW || current.phase == CribbagePhase.GAME_OVER) {
ShowDialog(
state = current,
localSeat = localSeat,
onContinue = { controller.confirmAdvance() },
onExit = onExit,
)
}
}
}
}
// ---------------------------------------------------------------------------
// The board
// ---------------------------------------------------------------------------
@Composable
private fun PegBoard(state: CribbageState, localSeat: Int) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
(0 until state.options.teamCount).forEach { team ->
val ours = team == state.options.teamOf(localSeat)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = sideLabel(state, team, localSeat),
style = MaterialTheme.typography.labelLarge,
fontWeight = if (ours) FontWeight.Bold else FontWeight.Normal,
)
Text(
text = "${state.scores[team]} of ${state.options.pointsToWin}",
style = MaterialTheme.typography.labelLarge,
)
}
PegTrack(
score = state.scores[team],
previous = state.previousScores[team],
target = state.options.pointsToWin,
colour = TeamColours[team % TeamColours.size],
)
}
}
}
/** One street of the board: the holes, the back peg and the front peg. */
@Composable
private fun PegTrack(score: Int, previous: Int, target: Int, colour: Color) {
val holes = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f)
Canvas(modifier = Modifier.fillMaxWidth().height(14.dp)) {
val middle = size.height / 2f
val pegSize = size.height * 0.34f
val track = size.height * 0.16f
fun at(points: Int): Float =
size.width * (points.coerceIn(0, target).toFloat() / target.toFloat())
drawLine(
color = holes,
start = Offset(0f, middle),
end = Offset(size.width, middle),
strokeWidth = track,
)
// A hole every five, as they are grouped on a real board.
var mark = 5
while (mark <= target) {
drawCircle(color = holes, radius = pegSize * 0.3f, center = Offset(at(mark), middle))
mark += 5
}
drawLine(
color = colour,
start = Offset(0f, middle),
end = Offset(at(score), middle),
strokeWidth = track,
)
drawCircle(
color = colour.copy(alpha = 0.45f),
radius = pegSize,
center = Offset(at(previous), middle),
)
drawCircle(color = colour, radius = pegSize, center = Offset(at(score), middle))
}
}
// ---------------------------------------------------------------------------
// The table
// ---------------------------------------------------------------------------
@Composable
private fun TableArea(state: CribbageState, localSeat: Int, cardWidth: Dp) {
val ink = MaterialTheme.colorScheme.onPrimary
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(8.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Starter", style = MaterialTheme.typography.labelSmall, color = ink)
val starter = state.starter
if (starter == null) {
CardBackView(width = cardWidth)
} else {
PlayingCardView(card = starter, width = cardWidth)
}
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "${cribOwner(state, localSeat)} crib",
style = MaterialTheme.typography.labelSmall,
color = ink,
)
Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
if (state.crib.isEmpty()) {
repeat(state.cribCount) { CardBackView(width = cardWidth * 0.45f) }
} else {
// Face up from the show onwards, when it is counted.
state.crib.forEach { PlayingCardView(card = it, width = cardWidth * 0.45f) }
}
}
}
}
if (state.phase == CribbagePhase.PLAY || state.played.isNotEmpty()) {
Text(
text = "The count is ${state.count}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = ink,
)
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(3.dp),
) {
if (state.series.isEmpty()) {
Text(
text = "A fresh count.",
style = MaterialTheme.typography.labelSmall,
color = ink,
)
}
state.series.forEach { pegged ->
PlayingCardView(card = pegged.card, width = cardWidth * 0.7f)
}
}
}
OpponentCards(state, localSeat, ink)
}
}
@Composable
private fun OpponentCards(state: CribbageState, localSeat: Int, ink: Color) {
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
(0 until state.options.playerCount)
.filter { it != localSeat }
.forEach { seat ->
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = seatLabel(seat, localSeat) +
if (seat == state.dealer) " (deals)" else "",
style = MaterialTheme.typography.labelSmall,
color = ink,
fontWeight = if (seat == state.turn && !state.saidGo.contains(seat)) {
FontWeight.Bold
} else {
FontWeight.Normal
},
)
Row(horizontalArrangement = Arrangement.spacedBy(1.dp)) {
repeat(state.handCounts.getOrElse(seat) { 0 }) {
CardBackView(width = 14.dp)
}
}
}
}
}
}
// ---------------------------------------------------------------------------
// Your side of the table
// ---------------------------------------------------------------------------
@Composable
private fun StatusLine(state: CribbageState, localSeat: Int) {
val yours = state.turn == localSeat &&
(state.phase == CribbagePhase.DISCARD || state.phase == CribbagePhase.PLAY)
val headline = when {
state.phase == CribbagePhase.DISCARD && yours ->
"Choose ${state.options.layAwaySize} for ${cribOwner(state, localSeat).lowercase()} crib"
state.phase == CribbagePhase.DISCARD ->
"${seatLabel(state.turn, localSeat)} is choosing for the crib…"
state.phase == CribbagePhase.PLAY && yours -> "Your turn — the count is ${state.count}"
state.phase == CribbagePhase.PLAY -> "${seatLabel(state.turn, localSeat)} to play…"
else -> "The show"
}
Column {
Text(headline, fontWeight = FontWeight.Bold)
state.log.lastOrNull()?.let {
Text(it, style = MaterialTheme.typography.bodySmall)
}
}
}
@Composable
private fun LayAwayControls(
state: CribbageState,
localSeat: Int,
chosen: Set<Card>,
onLayAway: () -> Unit,
) {
if (state.turn != localSeat) return
val wanted = state.options.layAwaySize
Button(
onClick = onLayAway,
enabled = chosen.size == wanted,
modifier = Modifier.fillMaxWidth(),
) {
Text(
if (chosen.size == wanted) {
"Lay away ${chosen.joinToString(" ") { it.label }}"
} else {
"Pick ${wanted - chosen.size} more"
}
)
}
}
@Composable
private fun HandRow(
state: CribbageState,
localSeat: Int,
legal: List<CribbageMove>,
cardWidth: Dp,
chosen: Set<Card>,
onTap: (Card) -> Unit,
) {
val hand = when {
localSeat !in state.hands.indices -> emptyList()
state.phase == CribbagePhase.DISCARD -> state.hands[localSeat]
// Cards stay in the hand once pegged so they can be counted again at
// the show, so what is still holdable has to be asked for.
else -> state.remaining(localSeat)
}
val playable = legal.filterIsInstance<PegCard>().map { it.card }.toSet()
Column {
Text(
text = if (state.dealer == localSeat) "Your hand — your crib" else "Your hand",
style = MaterialTheme.typography.labelLarge,
)
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (hand.isEmpty()) Text("—", style = MaterialTheme.typography.bodySmall)
hand.forEach { card ->
val enabled = when (state.phase) {
CribbagePhase.DISCARD -> state.turn == localSeat
CribbagePhase.PLAY -> card in playable
else -> false
}
PlayingCardView(
card = card,
width = cardWidth,
enabled = enabled,
selected = card in chosen,
onClick = { onTap(card) },
)
}
}
}
}
// ---------------------------------------------------------------------------
// The show
// ---------------------------------------------------------------------------
@Composable
private fun ShowDialog(
state: CribbageState,
localSeat: Int,
onContinue: () -> Unit,
onExit: () -> Unit,
) {
var dismissed by remember(state.handNumber, state.phase) { mutableStateOf(false) }
if (dismissed) return
val over = state.phase == CribbagePhase.GAME_OVER
AlertDialog(
onDismissRequest = { },
title = {
Text(
text = when {
over && state.winner == state.options.teamOf(localSeat) -> "You win"
over -> "${sideLabel(state, state.winner ?: 0, localSeat)} wins"
else -> "The show"
}
)
},
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
state.starter?.let {
Text("Starter ${it.label}", style = MaterialTheme.typography.bodySmall)
}
if (state.show.isEmpty()) {
Text(
text = "Pegged out during the play — the hands were never counted.",
style = MaterialTheme.typography.bodyMedium,
)
}
state.show.forEach { entry -> ShowEntry(entry, localSeat) }
(0 until state.options.teamCount).forEach { team ->
Row(modifier = Modifier.fillMaxWidth()) {
Text(
text = sideLabel(state, team, localSeat),
modifier = Modifier.weight(1f),
fontWeight = FontWeight.Bold,
)
Text("${state.scores[team]}", fontWeight = FontWeight.Bold)
}
}
}
},
confirmButton = {
TextButton(onClick = {
dismissed = true
if (over) onExit() else onContinue()
}) {
Text(if (over) "Back to the menu" else "Deal the next hand")
}
},
)
}
@Composable
private fun ShowEntry(entry: ShowScore, localSeat: Int) {
val who = when {
entry.isCrib && entry.seat == localSeat -> "Your crib"
entry.isCrib -> "Seat ${entry.seat}'s crib"
else -> seatLabel(entry.seat, localSeat)
}
Column {
Row(modifier = Modifier.fillMaxWidth()) {
Text(
text = "$who — ${entry.cards.joinToString(" ") { it.label }}",
modifier = Modifier.weight(1f),
fontWeight = FontWeight.Bold,
)
Text(if (entry.counted) "${entry.total}" else "—", fontWeight = FontWeight.Bold)
}
if (!entry.counted) {
Text(
text = "Never counted — the game was already won. Would have been " +
"${entry.total}.",
style = MaterialTheme.typography.bodySmall,
)
}
if (entry.lines.isEmpty()) {
Text("Nineteen — nothing at all.", style = MaterialTheme.typography.bodySmall)
}
entry.lines.forEach { line ->
Row(modifier = Modifier.fillMaxWidth()) {
Text(
text = line.label,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
)
Text("${line.points}", style = MaterialTheme.typography.bodySmall)
}
}
}
}
// ---------------------------------------------------------------------------
private fun seatLabel(seat: Int, localSeat: Int): String =
if (seat == localSeat) "You" else "Seat $seat"
/** Whose crib this hand is, in the second person where that is the answer. */
private fun cribOwner(state: CribbageState, localSeat: Int): String =
if (state.dealer == localSeat) "Your" else "Seat ${state.dealer}'s"
private fun sideLabel(state: CribbageState, team: Int, localSeat: Int): String = when {
state.options.playerCount == 4 ->
if (team == state.options.teamOf(localSeat)) "Your side" else "The other side"
team == localSeat -> "You"
else -> "Seat $team"
}
@@ -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<series>.<n>` 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
}
@@ -14,27 +14,6 @@ import java.io.File
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL 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. * 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. * 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 * The repository's releases must be publicly downloadable — no token is
* embedded in the app, and GitHub blocks anonymous access to private assets. * 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) { 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 * Looks for a newer build of whatever is installed.
* the one installed, so it is an explicit move rather than a newer *
* version of the same line — and, because the channels have separate * Takes no channel: asking for one would be asking which line of builds
* applicationIds, it lands as a second app rather than an upgrade. * this phone is on, and the phone already knows.
*/ */
data class Available(val release: Release, val isChannelSwitch: Boolean) : Result suspend fun check(): UpdateVerdict = withContext(Dispatchers.IO) {
data object UpToDate : Result
data class Failed(val reason: String) : Result
}
suspend fun check(channel: UpdateChannel): Result = withContext(Dispatchers.IO) {
try { try {
val latest = fetchLatestRelease(channel) val channel = installedChannel()
?: return@withContext Result.Failed( verdictFor(
"No ${channel.label.lowercase()} build has been published yet." latest = fetchLatestRelease(channel),
installedChannel = channel,
installedVersionCode = installedVersionCode(),
) )
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
}
} catch (e: Exception) { } 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? { private fun toRelease(json: JSONObject): Release? {
val tag = json.optString("tag_name").ifBlank { return null } val tag = json.optString("tag_name").ifBlank { return null }
// Tags are v<series>.<n>, with -dev on a prerelease. Only the trailing val versionCode = versionCodeOfTag(tag) ?: return null
// run number is read: the series is a name for people and changing it val channel = channelOfTag(tag, json.optBoolean("prerelease"))
// 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 assets = json.optJSONArray("assets") ?: return null val assets = json.optJSONArray("assets") ?: return null
val apks = (0 until assets.length()) 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. */ /** Read back from the installed APK's own version name. */
fun installedChannel(): UpdateChannel = fun installedChannel(): UpdateChannel = channelOfVersionName(installedVersionName())
if (installedVersionName().endsWith(DEV_SUFFIX)) UpdateChannel.DEV
else UpdateChannel.PRODUCTION
/** Downloads the APK and hands it to the system installer. */ /** Downloads the APK and hands it to the system installer. */
suspend fun downloadAndInstall(release: Release): String? = withContext(Dispatchers.IO) { suspend fun downloadAndInstall(release: Release): String? = withContext(Dispatchers.IO) {
@@ -70,6 +70,7 @@ class CatalogTest {
GameCatalog.GOLF, GameCatalog.GOLF,
GameCatalog.KAISER, GameCatalog.KAISER,
GameCatalog.CRAZY_EIGHTS, GameCatalog.CRAZY_EIGHTS,
GameCatalog.CRIBBAGE,
GameCatalog.WIZARD, GameCatalog.WIZARD,
GameCatalog.CHESS, GameCatalog.CHESS,
GameCatalog.TAYU, GameCatalog.TAYU,
@@ -0,0 +1,533 @@
package org.prolibertate.games.game.cribbage
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
import kotlin.random.Random
class CribbageRulesTest {
private val json = Json { encodeDefaults = true }
private fun config(
options: CribbageOptions = CribbageOptions(),
seed: Long = 21L,
) = TableConfig(
gameId = "cribbage",
seats = (0 until options.playerCount).map {
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = options.teamOf(it))
},
optionsJson = json.encodeToString(options),
seed = seed,
)
private fun c(rank: Rank, suit: Suit) = Card(rank, suit)
/** A table part-way through the play, built rather than dealt. */
private fun playing(
hands: List<List<Card>>,
options: CribbageOptions = CribbageOptions(playerCount = hands.size),
dealer: Int = hands.size - 1,
turn: Int = 0,
starter: Card = c(Rank.NINE, Suit.CLUBS),
played: List<PeggedCard> = emptyList(),
series: List<PeggedCard> = played,
crib: List<Card> = emptyList(),
scores: List<Int> = List(options.teamCount) { 0 },
) = CribbageState(
options = options,
seed = 1L,
handNumber = 0,
dealer = dealer,
hands = hands,
handCounts = hands.indices.map { seat ->
hands[seat].size - played.count { it.seat == seat }
},
crib = crib,
cribCount = crib.size,
deck = emptyList(),
starter = starter,
played = played,
series = series,
saidGo = emptySet(),
turn = turn,
lastToPlay = played.lastOrNull()?.seat,
scores = scores,
previousScores = scores,
show = emptyList(),
phase = CribbagePhase.PLAY,
winner = null,
log = emptyList(),
)
/** Lays away for every seat in turn, leaving the table cut and ready to play. */
private fun dealAndLayAway(state: CribbageState): CribbageState {
var next = state
while (next.phase == CribbagePhase.DISCARD) {
val seat = CribbageRules.currentSeat(next)!!
next = CribbageRules.applyMove(next, seat, CribbageRules.legalMoves(next, seat).first())
}
return next
}
// -- The deal ------------------------------------------------------------
@Test
fun `two players get six cards each and three or four get five`() {
assertEquals(6, CribbageOptions(playerCount = 2).dealSize)
assertEquals(5, CribbageOptions(playerCount = 3).dealSize)
assertEquals(5, CribbageOptions(playerCount = 4).dealSize)
assertEquals(2, CribbageOptions(playerCount = 2).layAwaySize)
assertEquals(1, CribbageOptions(playerCount = 4).layAwaySize)
}
@Test
fun `the crib is four cards at every table size`() {
listOf(2, 3, 4).forEach { players ->
val state = dealAndLayAway(
CribbageRules.initialState(config(CribbageOptions(playerCount = players)))
)
assertEquals("$players players", CRIBBAGE_HAND_SIZE, state.crib.size)
assertTrue(
"$players players kept the wrong number",
state.hands.all { it.size == CRIBBAGE_HAND_SIZE },
)
// No card is in two places at once.
val all = state.hands.flatten() + state.crib + state.starter!!
assertEquals(all.size, all.distinct().size)
}
}
@Test
fun `three-handed deals the odd card straight into the crib`() {
val state = CribbageRules.initialState(config(CribbageOptions(playerCount = 3)))
assertEquals(1, state.crib.size)
assertEquals(1, state.cribCount)
assertEquals(0, CribbageRules.initialState(config(CribbageOptions(playerCount = 2))).crib.size)
}
@Test
fun `the dealer's left lays away first and the dealer last`() {
val state = CribbageRules.initialState(config(CribbageOptions(playerCount = 3)))
assertEquals(0, state.dealer)
assertEquals(1, CribbageRules.currentSeat(state))
val afterOne = CribbageRules.applyMove(
state, 1, CribbageRules.legalMoves(state, 1).first()
)
assertEquals(2, CribbageRules.currentSeat(afterOne))
val afterTwo = CribbageRules.applyMove(
afterOne, 2, CribbageRules.legalMoves(afterOne, 2).first()
)
assertEquals(0, CribbageRules.currentSeat(afterTwo))
}
@Test
fun `a lay-away means the same cards whichever order they were picked in`() {
val one = c(Rank.FIVE, Suit.SPADES)
val two = c(Rank.KING, Suit.HEARTS)
assertEquals(LayAway.of(listOf(one, two)), LayAway.of(listOf(two, one)))
}
@Test
fun `heads-up offers every way of laying two away`() {
val state = CribbageRules.initialState(config())
// Fifteen ways of choosing two cards out of six.
assertEquals(15, CribbageRules.legalMoves(state, 1).size)
}
@Test
fun `the starter is cut once everybody has laid away`() {
val dealt = CribbageRules.initialState(config())
assertNull(dealt.starter)
val ready = dealAndLayAway(dealt)
assertNotNull(ready.starter)
assertEquals(CribbagePhase.PLAY, ready.phase)
// The non-dealer leads.
assertEquals(1, ready.turn)
}
@Test
fun `a jack cut is two for his heels and they are the dealer's`() {
var found = 0
for (seed in 0L until 200L) {
val ready = dealAndLayAway(CribbageRules.initialState(config(seed = seed)))
if (ready.starter?.rank != Rank.JACK) {
assertEquals("seed $seed scored something for a plain cut", 0, ready.scores.sum())
continue
}
found++
assertEquals("seed $seed", 2, ready.scores[ready.options.teamOf(ready.dealer)])
assertEquals(0, ready.scores[1 - ready.options.teamOf(ready.dealer)])
}
assertTrue("no jack was ever cut in two hundred deals", found > 0)
}
// -- The play ------------------------------------------------------------
@Test
fun `you may not take the count past thirty-one`() {
val state = playing(
hands = listOf(
listOf(
c(Rank.KING, Suit.SPADES), c(Rank.NINE, Suit.DIAMONDS),
c(Rank.TWO, Suit.HEARTS), c(Rank.SEVEN, Suit.CLUBS),
),
listOf(
c(Rank.QUEEN, Suit.CLUBS), c(Rank.JACK, Suit.SPADES),
c(Rank.TEN, Suit.DIAMONDS), c(Rank.THREE, Suit.HEARTS),
),
),
played = listOf(
PeggedCard(0, c(Rank.KING, Suit.SPADES)),
PeggedCard(1, c(Rank.QUEEN, Suit.CLUBS)),
PeggedCard(0, c(Rank.NINE, Suit.DIAMONDS)),
),
turn = 0,
)
// Twenty-nine on the table: of the two cards left, only the two fits.
assertEquals(29, state.count)
assertEquals(
listOf<CribbageMove>(PegCard(c(Rank.TWO, Suit.HEARTS))),
CribbageRules.legalMoves(state, 0),
)
}
@Test
fun `the last card is worth one and the next series starts beyond it`() {
val state = playing(
hands = listOf(
listOf(
c(Rank.KING, Suit.SPADES), c(Rank.TEN, Suit.HEARTS),
c(Rank.NINE, Suit.DIAMONDS), c(Rank.EIGHT, Suit.CLUBS),
),
listOf(
c(Rank.QUEEN, Suit.CLUBS), c(Rank.JACK, Suit.SPADES),
c(Rank.TEN, Suit.DIAMONDS), c(Rank.NINE, Suit.HEARTS),
),
),
played = listOf(
PeggedCard(0, c(Rank.KING, Suit.SPADES)),
PeggedCard(1, c(Rank.QUEEN, Suit.CLUBS)),
),
turn = 0,
)
assertEquals(20, state.count)
val after = CribbageRules.applyMove(state, 0, PegCard(c(Rank.TEN, Suit.HEARTS)))
// Thirty on the table and nobody holds a card small enough, so seat 0
// takes one for the last card and the count is cleared.
assertEquals(1, after.scores[0])
assertEquals(0, after.scores[1])
assertEquals(0, after.count)
assertTrue(after.series.isEmpty())
assertTrue(after.saidGo.isEmpty())
assertEquals("the seat after the last card leads", 1, after.turn)
assertTrue(after.log.any { it.contains("says go") })
}
@Test
fun `thirty-one pays two and nothing more for the go`() {
val state = playing(
hands = listOf(
listOf(
c(Rank.FIVE, Suit.SPADES), c(Rank.TWO, Suit.HEARTS),
c(Rank.THREE, Suit.DIAMONDS), c(Rank.FOUR, Suit.CLUBS),
),
listOf(
c(Rank.KING, Suit.CLUBS), c(Rank.QUEEN, Suit.SPADES),
c(Rank.SIX, Suit.HEARTS), c(Rank.SEVEN, Suit.DIAMONDS),
),
),
played = listOf(
PeggedCard(0, c(Rank.FOUR, Suit.CLUBS)),
PeggedCard(1, c(Rank.KING, Suit.CLUBS)),
PeggedCard(0, c(Rank.TWO, Suit.HEARTS)),
PeggedCard(1, c(Rank.QUEEN, Suit.SPADES)),
),
turn = 0,
)
assertEquals(26, state.count)
val after = CribbageRules.applyMove(state, 0, PegCard(c(Rank.FIVE, Suit.SPADES)))
assertEquals("two for thirty-one, and no point for the go on top", 2, after.scores[0])
assertEquals(0, after.count)
assertEquals(1, after.turn)
}
@Test
fun `pegging out ends the game in the middle of the play`() {
val state = playing(
hands = listOf(
listOf(
c(Rank.SEVEN, Suit.SPADES), c(Rank.SEVEN, Suit.HEARTS),
c(Rank.NINE, Suit.DIAMONDS), c(Rank.EIGHT, Suit.CLUBS),
),
listOf(
c(Rank.SEVEN, Suit.CLUBS), c(Rank.QUEEN, Suit.SPADES),
c(Rank.TEN, Suit.DIAMONDS), c(Rank.NINE, Suit.HEARTS),
),
),
played = listOf(
PeggedCard(0, c(Rank.SEVEN, Suit.SPADES)),
PeggedCard(1, c(Rank.SEVEN, Suit.CLUBS)),
),
scores = listOf(119, 60),
turn = 0,
)
val after = CribbageRules.applyMove(state, 0, PegCard(c(Rank.SEVEN, Suit.HEARTS)))
assertEquals("pair royal", 125, after.scores[0])
assertEquals(0, after.winner)
assertTrue(CribbageRules.isFinished(after))
assertNull(CribbageRules.currentSeat(after))
assertTrue("the hands were never counted", after.show.isEmpty())
}
// -- The show ------------------------------------------------------------
@Test
fun `the hands are counted from the dealer's left with the crib last`() {
var state = dealAndLayAway(
CribbageRules.initialState(config(CribbageOptions(playerCount = 3), seed = 4L))
)
while (state.phase == CribbagePhase.PLAY) {
val seat = CribbageRules.currentSeat(state)!!
state = CribbageRules.applyMove(state, seat, CribbageRules.legalMoves(state, seat).first())
}
assertEquals(CribbagePhase.SHOW, state.phase)
assertEquals(4, state.show.size)
assertEquals(listOf(1, 2, 0, 0), state.show.map { it.seat })
assertEquals(listOf(false, false, false, true), state.show.map { it.isCrib })
assertTrue(state.show.all { it.counted })
assertEquals(state.crib, state.show.last().cards)
}
@Test
fun `a hand counted out wins before the dealer ever counts`() {
// Seat 0 is holding the twenty-nine hand and counts first.
val state = playing(
hands = listOf(
listOf(
c(Rank.FIVE, Suit.SPADES), c(Rank.FIVE, Suit.HEARTS),
c(Rank.FIVE, Suit.DIAMONDS), c(Rank.JACK, Suit.CLUBS),
),
listOf(
c(Rank.TWO, Suit.SPADES), c(Rank.THREE, Suit.HEARTS),
c(Rank.FOUR, Suit.DIAMONDS), c(Rank.SIX, Suit.CLUBS),
),
),
dealer = 1,
starter = c(Rank.FIVE, Suit.CLUBS),
crib = listOf(
c(Rank.KING, Suit.SPADES), c(Rank.QUEEN, Suit.HEARTS),
c(Rank.NINE, Suit.DIAMONDS), c(Rank.EIGHT, Suit.SPADES),
),
played = listOf(
PeggedCard(0, c(Rank.FIVE, Suit.SPADES)),
PeggedCard(1, c(Rank.TWO, Suit.SPADES)),
PeggedCard(0, c(Rank.FIVE, Suit.HEARTS)),
PeggedCard(1, c(Rank.THREE, Suit.HEARTS)),
PeggedCard(0, c(Rank.FIVE, Suit.DIAMONDS)),
PeggedCard(1, c(Rank.FOUR, Suit.DIAMONDS)),
PeggedCard(1, c(Rank.SIX, Suit.CLUBS)),
),
scores = listOf(100, 100),
turn = 0,
).let { it.copy(series = listOf(PeggedCard(1, c(Rank.SIX, Suit.CLUBS)))) }
// Seat 0's last card ends the play and takes the table to the show.
val after = CribbageRules.applyMove(state, 0, PegCard(c(Rank.JACK, Suit.CLUBS)))
assertEquals(CribbagePhase.GAME_OVER, after.phase)
assertEquals(0, after.winner)
assertEquals(3, after.show.size)
assertTrue("the non-dealer counted", after.show[0].counted)
assertFalse("the dealer never got to count", after.show[1].counted)
assertFalse("nor did the crib", after.show[2].counted)
assertEquals(29, after.show[0].total)
}
// -- Whole games ---------------------------------------------------------
@Test
fun `random legal play always finishes, at every table size`() {
listOf(2, 3, 4).forEach { players ->
repeat(6) { iteration ->
val random = Random(iteration.toLong())
var state = CribbageRules.initialState(
config(
CribbageOptions(playerCount = players, pointsToWin = 61),
seed = 700L + iteration,
)
)
var guard = 0
while (!CribbageRules.isFinished(state) && guard++ < 20000) {
val seat = CribbageRules.currentSeat(state)
if (seat == null) {
state = CribbageRules.nextHand(state)
continue
}
val legal = CribbageRules.legalMoves(state, seat)
assertTrue("$players players: seat $seat had no legal move", legal.isNotEmpty())
state = CribbageRules.applyMove(state, seat, legal[random.nextInt(legal.size)])
}
assertTrue("$players players, game $iteration hung", CribbageRules.isFinished(state))
assertNotNull(state.winner)
assertTrue(state.scores.max() >= 61)
}
}
}
@Test
fun `nobody is ever carried past the target`() {
// The game stops on the point that reaches it, so the winner's score is
// the only one that may sit on or above the line.
var state = CribbageRules.initialState(config(CribbageOptions(pointsToWin = 61), seed = 9L))
val random = Random(9L)
var guard = 0
while (!CribbageRules.isFinished(state) && guard++ < 20000) {
val seat = CribbageRules.currentSeat(state)
if (seat == null) {
state = CribbageRules.nextHand(state)
continue
}
val legal = CribbageRules.legalMoves(state, seat)
state = CribbageRules.applyMove(state, seat, legal[random.nextInt(legal.size)])
}
val winner = state.winner!!
state.scores.forEachIndexed { team, score ->
if (team != winner) assertTrue("team $team reached $score", score < 61)
}
}
@Test
fun `the computer beats random play head to head`() {
val ai = CribbageAi()
var aiWins = 0
var randomWins = 0
repeat(10) { iteration ->
val random = Random(4000L + iteration)
var state = CribbageRules.initialState(
config(CribbageOptions(pointsToWin = 61), seed = 200L + iteration)
)
var guard = 0
while (!CribbageRules.isFinished(state) && guard++ < 20000) {
val seat = CribbageRules.currentSeat(state)
if (seat == null) {
state = CribbageRules.nextHand(state)
continue
}
val legal = CribbageRules.legalMoves(state, seat)
val move = if (seat == 0) {
ai.chooseMove(state, seat, legal)
} else {
legal[random.nextInt(legal.size)]
}
assertTrue("the ai returned an illegal move $move", legal.contains(move))
state = CribbageRules.applyMove(state, seat, move)
}
if (state.winner == 0) aiWins++ else randomWins++
}
assertTrue("ai won $aiWins of ten against random play", aiWins > randomWins)
}
@Test
fun `a partnership plays for its own crib and not just its own hand`() {
// Four-handed, with the two AI seats facing each other. The lay-away is
// where a partner's crib has to count as your own, so a side that
// treated it as an opponent's would be giving away half its own cribs.
val ai = CribbageAi()
var aiWins = 0
repeat(8) { iteration ->
val random = Random(900L + iteration)
val options = CribbageOptions(playerCount = 4, pointsToWin = 61)
var state = CribbageRules.initialState(config(options, seed = 500L + iteration))
var guard = 0
while (!CribbageRules.isFinished(state) && guard++ < 40000) {
val seat = CribbageRules.currentSeat(state)
if (seat == null) {
state = CribbageRules.nextHand(state)
continue
}
val legal = CribbageRules.legalMoves(state, seat)
val move = if (options.teamOf(seat) == 0) {
ai.chooseMove(state, seat, legal)
} else {
legal[random.nextInt(legal.size)]
}
state = CribbageRules.applyMove(state, seat, move)
}
if (state.winner == 0) aiWins++
}
assertTrue("the ai partnership won $aiWins of eight", aiWins > 4)
}
// -- Redaction and wire format ------------------------------------------
@Test
fun `a seat view hides the other hands, the crib and the pack`() {
val state = dealAndLayAway(CribbageRules.initialState(config(CribbageOptions(playerCount = 4))))
val view = CribbageRules.viewFor(state, seat = 2)
assertEquals(state.hands[2], view.hands[2])
assertTrue(view.hands.filterIndexed { seat, _ -> seat != 2 }.all { it.isEmpty() })
assertTrue("the crib is nobody's business until the show", view.crib.isEmpty())
assertTrue("the cut must not be knowable in advance", view.deck.isEmpty())
assertEquals(CRIBBAGE_HAND_SIZE, view.cribCount)
assertEquals(state.handCounts, view.handCounts)
assertEquals(state.starter, view.starter)
}
@Test
fun `everything is turned face up at the show`() {
var state = dealAndLayAway(CribbageRules.initialState(config(seed = 12L)))
while (state.phase == CribbagePhase.PLAY) {
val seat = CribbageRules.currentSeat(state)!!
state = CribbageRules.applyMove(state, seat, CribbageRules.legalMoves(state, seat).first())
}
val view = CribbageRules.viewFor(state, seat = 0)
assertEquals(state.hands, view.hands)
assertEquals(state.crib, view.crib)
}
@Test
fun `state and moves survive a round trip`() {
val state = CribbageRules.initialState(config())
assertEquals(state, CribbageRules.decodeState(CribbageRules.encodeState(state)))
listOf(
PegCard(c(Rank.KING, Suit.HEARTS)),
LayAway.of(listOf(c(Rank.FIVE, Suit.SPADES), c(Rank.SIX, Suit.CLUBS))),
).forEach {
assertEquals(it, CribbageRules.decodeMove(CribbageRules.encodeMove(it)))
}
}
@Test
fun `four play in partnerships and two or three play for themselves`() {
val four = CribbageOptions(playerCount = 4)
assertEquals(2, four.teamCount)
assertEquals(listOf(0, 1, 0, 1), (0..3).map { four.teamOf(it) })
val three = CribbageOptions(playerCount = 3)
assertEquals(3, three.teamCount)
assertEquals(listOf(0, 1, 2), (0..2).map { three.teamOf(it) })
}
}
@@ -0,0 +1,275 @@
package org.prolibertate.games.game.cribbage
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Decks
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
class CribbageScoringTest {
private fun c(rank: Rank, suit: Suit) = Card(rank, suit)
private fun showTotal(hand: List<Card>, starter: Card, isCrib: Boolean = false): Int =
CribbageScoring.show(hand, starter, isCrib).sumOf { it.points }
private fun pegTotal(vararg cards: Card): Int =
CribbageScoring.peg(cards.toList()).sumOf { it.points }
// -- What a card is worth ------------------------------------------------
@Test
fun `aces are one and every court card is ten`() {
assertEquals(1, pipValue(c(Rank.ACE, Suit.SPADES)))
assertEquals(10, pipValue(c(Rank.TEN, Suit.SPADES)))
assertEquals(10, pipValue(c(Rank.JACK, Suit.SPADES)))
assertEquals(10, pipValue(c(Rank.QUEEN, Suit.SPADES)))
assertEquals(10, pipValue(c(Rank.KING, Suit.SPADES)))
assertEquals(7, pipValue(c(Rank.SEVEN, Suit.SPADES)))
}
@Test
fun `the ace is low in a run and does not follow the king`() {
assertEquals(1, runOrder(c(Rank.ACE, Suit.SPADES)))
assertEquals(13, runOrder(c(Rank.KING, Suit.SPADES)))
// A-2-3 is a run of three.
val low = CribbageScoring.show(
hand = listOf(
c(Rank.ACE, Suit.SPADES),
c(Rank.TWO, Suit.HEARTS),
c(Rank.THREE, Suit.DIAMONDS),
c(Rank.NINE, Suit.CLUBS),
),
starter = c(Rank.SEVEN, Suit.CLUBS),
isCrib = false,
)
assertEquals(3, low.first { it.label == "Run of 3" }.points)
// Q-K-A is not.
val high = CribbageScoring.show(
hand = listOf(
c(Rank.QUEEN, Suit.SPADES),
c(Rank.KING, Suit.HEARTS),
c(Rank.ACE, Suit.DIAMONDS),
c(Rank.TWO, Suit.CLUBS),
),
starter = c(Rank.SEVEN, Suit.CLUBS),
isCrib = false,
)
assertTrue(high.none { it.label.startsWith("Run") })
}
// -- The show ------------------------------------------------------------
@Test
fun `the best hand in cribbage is twenty-nine`() {
// Three fives and the jack of the suit that is cut.
val hand = listOf(
c(Rank.FIVE, Suit.SPADES),
c(Rank.FIVE, Suit.HEARTS),
c(Rank.FIVE, Suit.DIAMONDS),
c(Rank.JACK, Suit.CLUBS),
)
val lines = CribbageScoring.show(hand, c(Rank.FIVE, Suit.CLUBS), isCrib = false)
assertEquals(29, lines.sumOf { it.points })
assertEquals(16, lines.first { it.label.endsWith("fifteens") }.points)
assertEquals(12, lines.first { it.label.startsWith("Double pair royal") }.points)
assertEquals(1, lines.first { it.label == "His nob" }.points)
}
@Test
fun `nineteen is no hand at all`() {
val hand = listOf(
c(Rank.KING, Suit.SPADES),
c(Rank.QUEEN, Suit.HEARTS),
c(Rank.EIGHT, Suit.DIAMONDS),
c(Rank.SIX, Suit.CLUBS),
)
assertTrue(CribbageScoring.show(hand, c(Rank.TWO, Suit.CLUBS), false).isEmpty())
}
@Test
fun `a doubled run counts twice over rather than lengthening`() {
// 4-5-5-6 is two runs of three, not a run of four.
val hand = listOf(
c(Rank.FOUR, Suit.SPADES),
c(Rank.FIVE, Suit.SPADES),
c(Rank.FIVE, Suit.HEARTS),
c(Rank.SIX, Suit.DIAMONDS),
)
val lines = CribbageScoring.show(hand, c(Rank.KING, Suit.CLUBS), isCrib = false)
val run = lines.first { it.label.contains("runs of") }
assertEquals("2 runs of 3", run.label)
assertEquals(6, run.points)
// Two 4-5-6 fifteens, two 5-king fifteens, and the pair of fives.
assertEquals(16, lines.sumOf { it.points })
}
@Test
fun `a hand takes four for its own suit and five with the starter`() {
val flush = listOf(
c(Rank.TWO, Suit.HEARTS),
c(Rank.FOUR, Suit.HEARTS),
c(Rank.NINE, Suit.HEARTS),
c(Rank.KING, Suit.HEARTS),
)
assertEquals(
ScoreLine("Flush of 4", 4),
CribbageScoring.show(flush, c(Rank.SEVEN, Suit.SPADES), isCrib = false)
.first { it.label.startsWith("Flush") },
)
assertEquals(
ScoreLine("Flush of 5", 5),
CribbageScoring.show(flush, c(Rank.SEVEN, Suit.HEARTS), isCrib = false)
.first { it.label.startsWith("Flush") },
)
}
@Test
fun `a crib takes nothing for four of a suit`() {
val flush = listOf(
c(Rank.TWO, Suit.HEARTS),
c(Rank.FOUR, Suit.HEARTS),
c(Rank.NINE, Suit.HEARTS),
c(Rank.KING, Suit.HEARTS),
)
assertNull(
CribbageScoring.show(flush, c(Rank.SEVEN, Suit.SPADES), isCrib = true)
.firstOrNull { it.label.startsWith("Flush") }
)
assertEquals(
ScoreLine("Flush of 5", 5),
CribbageScoring.show(flush, c(Rank.SEVEN, Suit.HEARTS), isCrib = true)
.first { it.label.startsWith("Flush") },
)
}
@Test
fun `his nob wants the jack in the hand and the suit that was cut`() {
val withJack = listOf(
c(Rank.JACK, Suit.HEARTS),
c(Rank.TWO, Suit.SPADES),
c(Rank.SEVEN, Suit.CLUBS),
c(Rank.NINE, Suit.DIAMONDS),
)
assertEquals(
ScoreLine("His nob", 1),
CribbageScoring.show(withJack, c(Rank.THREE, Suit.HEARTS), isCrib = false)
.first { it.label == "His nob" },
)
assertNull(
CribbageScoring.show(withJack, c(Rank.THREE, Suit.SPADES), isCrib = false)
.firstOrNull { it.label == "His nob" }
)
}
@Test
fun `fifteens count every combination and not just pairs of cards`() {
// 7-8 makes fifteen, and so does 2-3-4-6.
val hand = listOf(
c(Rank.SEVEN, Suit.SPADES),
c(Rank.EIGHT, Suit.HEARTS),
c(Rank.TWO, Suit.DIAMONDS),
c(Rank.THREE, Suit.CLUBS),
)
val lines = CribbageScoring.show(hand, c(Rank.FOUR, Suit.CLUBS), isCrib = false)
assertEquals(4, lines.first { it.label == "2 fifteens" }.points)
assertEquals(3, lines.first { it.label == "Run of 3" }.points)
assertEquals(7, lines.sumOf { it.points })
}
// -- The play ------------------------------------------------------------
@Test
fun `fifteen and thirty-one are worth two apiece during the play`() {
assertEquals(2, pegTotal(c(Rank.SEVEN, Suit.SPADES), c(Rank.EIGHT, Suit.HEARTS)))
assertEquals(
2,
pegTotal(
c(Rank.KING, Suit.SPADES),
c(Rank.QUEEN, Suit.HEARTS),
c(Rank.SIX, Suit.DIAMONDS),
c(Rank.FIVE, Suit.CLUBS),
),
)
}
@Test
fun `pairs peg off the end of the run and not through another card`() {
assertEquals(2, pegTotal(c(Rank.SEVEN, Suit.SPADES), c(Rank.SEVEN, Suit.HEARTS)))
assertEquals(
6,
pegTotal(
c(Rank.SEVEN, Suit.SPADES),
c(Rank.SEVEN, Suit.HEARTS),
c(Rank.SEVEN, Suit.CLUBS),
),
)
// Broken up by a three, so the sevens are no longer a pair.
assertEquals(
0,
pegTotal(
c(Rank.SEVEN, Suit.SPADES),
c(Rank.THREE, Suit.HEARTS),
c(Rank.SEVEN, Suit.CLUBS),
),
)
}
@Test
fun `a run pegs in any order and lengthens as it is extended`() {
assertEquals(
3,
pegTotal(
c(Rank.FIVE, Suit.SPADES),
c(Rank.THREE, Suit.HEARTS),
c(Rank.FOUR, Suit.CLUBS),
),
)
assertEquals(
4,
pegTotal(
c(Rank.FIVE, Suit.SPADES),
c(Rank.THREE, Suit.HEARTS),
c(Rank.FOUR, Suit.CLUBS),
c(Rank.SIX, Suit.DIAMONDS),
),
)
// A repeated rank breaks the run: 5-3-4-4 is no run at all.
assertEquals(
2,
pegTotal(
c(Rank.FIVE, Suit.SPADES),
c(Rank.THREE, Suit.HEARTS),
c(Rank.FOUR, Suit.CLUBS),
c(Rank.FOUR, Suit.DIAMONDS),
),
)
}
@Test
fun `twenty-nine really is the ceiling`() {
// Exhaustive over a shortened pack — aces to fives and the jacks, which
// is where every big hand lives. Nothing may beat 29, and 29 has to be
// reachable, or the scoring has quietly stopped counting something.
val pack = Decks.standard52().filter { runOrder(it) <= 5 || it.rank == Rank.JACK }
var best = 0
for (a in pack.indices) {
for (b in a + 1 until pack.size) {
for (d in b + 1 until pack.size) {
for (e in d + 1 until pack.size) {
val hand = listOf(pack[a], pack[b], pack[d], pack[e])
for (starter in pack) {
if (starter in hand) continue
best = maxOf(best, showTotal(hand, starter))
}
}
}
}
}
assertEquals(29, best)
}
}
@@ -0,0 +1,151 @@
package org.prolibertate.games.net
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.engine.GameAi
import org.prolibertate.games.game.engine.GameRules
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
/**
* The pause between hands, on a table with nobody but the local player at it.
*
* Cribbage's show, Golf's card and Wizard's bids-against-tricks all stop the
* table on a scoreboard and wait to be released. The release has to be spent:
* the state does not change while it is being read, so a controller that only
* re-asks `awaitsConfirmation` about it stops on the same scoreboard again the
* moment it is restarted, and the next hand is never dealt.
*/
class MatchControllerScoreboardTest {
private val scopes = mutableListOf<CoroutineScope>()
@After
fun tearDown() {
scopes.forEach { it.cancel() }
}
/** A hand is one move long, and every hand ends on a scoreboard. */
private data class Hands(val handNumber: Int, val scoring: Boolean)
private object Rules : GameRules<Hands, String> {
override val gameId: String = "scoreboard"
override fun initialState(config: TableConfig) = Hands(0, scoring = false)
override fun currentSeat(state: Hands): Int? = if (state.scoring) null else 0
override fun legalMoves(state: Hands, seat: Int): List<String> =
if (state.scoring) emptyList() else listOf("play")
override fun applyMove(state: Hands, seat: Int, move: String) =
state.copy(scoring = true)
override fun isFinished(state: Hands) = state.handNumber >= HANDS
override fun summary(state: Hands) = state.toString()
override fun viewFor(state: Hands, seat: Int) = state
override fun encodeState(state: Hands) = "${state.handNumber}:${state.scoring}"
override fun decodeState(json: String) = Hands(
handNumber = json.substringBefore(':').toInt(),
scoring = json.substringAfter(':').toBooleanStrict(),
)
override fun encodeMove(move: String) = move
override fun decodeMove(json: String) = json
}
private fun controller(): MatchController<Hands, String> {
val scope = CoroutineScope(Job() + Dispatchers.Default).also { scopes += it }
return MatchController(
rules = Rules,
ai = object : GameAi<Hands, String> {
override fun chooseMove(state: Hands, seat: Int, legal: List<String>) = legal.first()
},
config = TableConfig(
gameId = "scoreboard",
seats = listOf(PlayerSlot(0, "Me", PlayerKind.HUMAN_LOCAL, team = 0)),
optionsJson = "{}",
seed = 1L,
),
scope = scope,
role = MatchController.Role.HOST,
localSeats = setOf(0),
primarySeat = 0,
advanceIdle = { state ->
if (state.scoring) {
Hands(state.handNumber + 1, scoring = false)
} else {
null
}
},
aiThinkingMillis = { 0L },
awaitsConfirmation = { state -> state.scoring },
)
}
@Test
fun `the table stops on a scoreboard`() = runBlocking {
val table = controller()
table.startAsHost(emptyList())
settle()
assertFalse("nothing to read yet", table.awaitingConfirmation.value)
table.submit("play")
settle()
assertTrue("the scoreboard was skipped past", table.awaitingConfirmation.value)
assertEquals(Hands(0, scoring = true), table.state.value)
}
@Test
fun `confirming it deals the next hand`() = runBlocking {
val table = controller()
table.startAsHost(emptyList())
settle()
table.submit("play")
settle()
table.confirmAdvance()
settle()
assertEquals(
"the table is still sitting on the scoreboard it was told to leave",
Hands(1, scoring = false),
table.state.value,
)
assertFalse(table.awaitingConfirmation.value)
assertTrue("and the next hand is playable", table.legalMoves.value.isNotEmpty())
}
@Test
fun `every hand in the game stops and is released in turn`() = runBlocking {
val table = controller()
table.startAsHost(emptyList())
settle()
repeat(HANDS) { hand ->
table.submit("play")
settle()
assertTrue("hand $hand did not stop to be read", table.awaitingConfirmation.value)
table.confirmAdvance()
settle()
}
assertTrue(table.finished.value)
assertEquals(HANDS, table.state.value?.handNumber)
}
private suspend fun settle() = delay(SETTLE_MILLIS)
private companion object {
const val HANDS = 3
const val SETTLE_MILLIS = 250L
}
}
@@ -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,
)
}
}
}
+118
View File
@@ -0,0 +1,118 @@
# Cribbage — implemented rules
This is the ruleset the engine enforces. Anything marked **toggle** is in the
game setup screen.
## Table
- 2, 3 or 4 players — **toggle**.
- **Two** play head to head, six cards each, laying two away.
- **Three** play singly, five cards each, laying one away — and a fourth card
is dealt straight into the crib, which is the only way three hands can fill
it.
- **Four** play in partnerships, sitting across from each other. Five cards
each, one away, and **the two partners peg on one score**: your partner's
crib is your crib, and their points are yours.
- One 52-card deck. The deal moves one seat to the left each hand.
- Game to **121** — twice round the board — or **61** — once — **toggle**.
Whatever the table, everybody keeps four cards and the crib holds four.
## The cut
Once everyone has laid away, the starter is cut and turned up. It belongs to
every hand and to the crib at the show.
**A jack turned is two for his heels**, and they are the dealer's however the
cut fell.
## The play
Starting at the dealer's left, cards are laid down one at a time and the count
runs up towards **thirty-one, which it may never pass**. A card that would take
it past cannot be played.
Scored as the card lands:
| | |
| --- | --- |
| Count reaches fifteen | 2 |
| Count reaches thirty-one | 2 |
| Pair | 2 |
| Third of a rank (pair royal) | 6 |
| Fourth of a rank (double pair royal) | 12 |
| Run of three or more | 1 a card |
Pairs and runs are read off the **end of the cards on the table**, so a pair
split by somebody else's card is not a pair, and a run counts however jumbled
the order it was laid in — 5, 3, 4 is a run of three.
**A player who cannot play says go**, and the table plays past them. When
nobody can play at all, whoever laid the last card takes **one for the last
card**, the count is cleared, and the seat after them starts the next one.
Thirty-one has already paid two, so no extra point is taken for it.
A go is called by the engine rather than tapped: the count only ever rises
within a series, so a hand that cannot play now cannot play later in it either,
and there is nothing to decide.
## The show
The hands are counted **from the dealer's left, round to the dealer, and the
dealer's crib last of all**. Each hand is its four cards plus the starter.
| | |
| --- | --- |
| Each combination of cards adding to fifteen | 2 |
| Each pair | 2 |
| Each run of three or more | 1 a card |
| Four cards of one suit in hand | 4 |
| …with the starter matching | 5 |
| Jack in hand of the starter's suit (his nob) | 1 |
Duplicated ranks multiply runs rather than lengthening them: 4-5-5-6 is **two
runs of three**, not a run of four.
**A crib takes nothing for four of a suit.** It scores a flush only when all
five cards, starter included, share one — the one rule that makes counting a
crib different from counting a hand.
The best hand in the game is twenty-nine: three fives and the jack of the suit
that is cut, with the fourth five as the starter.
## Winning
**The game ends on the point that reaches the target, mid-count if that is
where it falls.** A non-dealer who can count out has won before the dealer's
hand and crib are ever counted, and the table says so — hands the game never
got to are shown with what they would have been worth, greyed out rather than
added on.
**Skunks** — **toggle**, on by default. A loser left on the second street is
skunked, and one who never left the first is double skunked. In a game to 121
those lines are 91 and 61. It is a way of describing the result, not extra
points.
## What is not here
- **Muggins.** Points missed at the show cannot be claimed by an opponent,
because they are never missed: the engine counts every hand in full. The rule
exists to punish bad arithmetic, and there is none to punish.
- **The five-card game.** The old two-handed game — five cards each, a crib of
four, three for last, game to 61 — is a different game rather than an option,
and this is the six-card one.
- **Cutting for the deal.** Seat 0 deals the first hand and the deal moves left
from there.
## What the computer opponent does
The lay-away is worked out rather than guessed. For every way of keeping four,
it scores that hand against **all forty-six cards that could still be cut** and
compares the averages. What goes to the crib is valued separately and then
added or subtracted depending on whose crib it is — a five, a pair or a fifteen
laid away is a gift to your own crib and a present to the other side's. At four
players your partner's crib counts as your own.
In the play it takes the points on offer, and otherwise tries not to leave the
count at five or twenty-one, where any of the sixteen ten-cards collects fifteen
or thirty-one. It will not lead a five for the same reason.