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:
@@ -9,7 +9,7 @@ The app shell is complete and every game in the catalogue is playable end to end
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **Cards** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s |
|
||||
| **Cards** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s, Cribbage |
|
||||
| **Board** | Sequence, Chess, Checkers, Backgammon, Mastermind, Nine Men's Morris, Pirates and Bulgars, Ta Yü |
|
||||
|
||||
Nothing is marked *Coming soon* at the moment, because nothing is waiting. The
|
||||
@@ -17,8 +17,8 @@ machinery for it is still there and still tested: a game listed in `GameCatalog`
|
||||
with `available = false` appears in the menu marked *Coming soon* **on dev builds
|
||||
only**, so a production release lists just what can actually be played and nobody
|
||||
installs it and taps into a dead end. The menu decides this from the installed
|
||||
APK's own version name, so it follows the build rather than the channel selected
|
||||
for future updates.
|
||||
APK's own version name — the same source the updater uses to know which channel
|
||||
it is on.
|
||||
|
||||
Adding a game means writing a `GameRules` implementation, a `GameAi` and a
|
||||
screen, then flipping `available` — the menu, lobby, networking, settings and OTA
|
||||
@@ -47,7 +47,7 @@ them.
|
||||
## Scorekeeper
|
||||
|
||||
**Scorekeeper** on the main menu is a pencil for games the app does not deal —
|
||||
darts, cribbage, whatever is actually on the table. It is laid out as the paper
|
||||
darts, rummy, whatever is actually on the table. It is laid out as the paper
|
||||
it replaces: a column per player with their name at the head, a row per round
|
||||
running down the page, and the running total ruled off at the foot.
|
||||
|
||||
@@ -91,13 +91,20 @@ it: every rule above is a pure function and unit-tested on the JVM.
|
||||
- **Sound** on/off.
|
||||
- **Animation speed**, 0.5×–2×. One multiplier drives card movement and how long
|
||||
the computer appears to think.
|
||||
- **Updates** — check on launch, or check on demand, from either of two
|
||||
channels:
|
||||
- **Production** — stable builds, published from `main`.
|
||||
- **Dev** — preview builds, published from `dev` on every green CI run.
|
||||
- **Updates** — check on launch, or check on demand. **There is no channel to
|
||||
choose**: a build updates from its own channel and no other. A production
|
||||
install is offered production builds from `main`; a dev install is offered dev
|
||||
builds from `dev`. The settings screen states which one is running rather than
|
||||
offering a switch.
|
||||
|
||||
The two install as separate apps, so both can be kept on one device — see
|
||||
[Both channels on one device](#both-channels-on-one-device).
|
||||
The check on launch **only ever speaks when there is a build to install**. It
|
||||
runs behind the splash and waits for it to clear, and an app that is already
|
||||
current — or a phone that cannot reach GitHub — says nothing, because opening
|
||||
the app was not a question. The button in Settings is a question, so it
|
||||
answers every time, including "you're on the latest" and whatever went wrong.
|
||||
|
||||
Wanting both means installing both — they are separate apps and each updates
|
||||
itself, see [Both channels on one device](#both-channels-on-one-device).
|
||||
|
||||
## Branding
|
||||
|
||||
@@ -184,8 +191,9 @@ the versioned copy, so an installed build can always be traced back to its tag.
|
||||
|
||||
There is no equivalent link for the dev channel: `/releases/latest` skips
|
||||
prereleases by design, and that is exactly what keeps a production install from
|
||||
being handed a dev build. Dev builds are reached through the in-app updater with
|
||||
the channel set to dev, or from the releases page.
|
||||
being handed a dev build. A dev build is reached by the in-app updater of a dev
|
||||
install — which looks at the prerelease list rather than that endpoint — or from
|
||||
the releases page.
|
||||
|
||||
### The version series
|
||||
|
||||
@@ -239,9 +247,16 @@ aimed elsewhere does nothing rather than shipping unreviewed code.
|
||||
|
||||
**The two channels have independent version sequences** — a build's `versionCode`
|
||||
is the run number of the workflow that produced it, and the two workflows count
|
||||
separately. Versions are therefore only ever compared *within* a channel; the app
|
||||
reads the `-dev` suffix in its own `versionName` to know which channel it is on,
|
||||
and treats a change of channel as an explicit switch rather than an upgrade.
|
||||
separately. Dev is on 69 while production is on 40, so the numbers say nothing
|
||||
across the divide.
|
||||
|
||||
That is why **an install only ever updates within its own channel**, and why
|
||||
there is no setting for it. The app reads the `-dev` suffix in its own
|
||||
`versionName` to know which line it is on, asks only that channel, and refuses
|
||||
anything that comes back belonging to the other one — checked again at the point
|
||||
of decision rather than trusted from the fetch. The rule is a pure function,
|
||||
`verdictFor` in `update/UpdatePolicy.kt`, with no Android in it, so it is
|
||||
unit-tested on the JVM like the rules engines are.
|
||||
|
||||
### Both channels on one device
|
||||
|
||||
@@ -272,9 +287,10 @@ reference `@color/tartan_field` rather than a literal, which is how one set of
|
||||
vectors serves both colourways. `BuildConfig.DEV_BUILD` carries the same flag
|
||||
into Kotlin, where `AppSett` picks the matching Compose sett for the splash.
|
||||
|
||||
Switching channels in Settings therefore installs the other channel's app
|
||||
alongside this one and leaves this one in place; the settings screen says so
|
||||
when the selected channel is not the installed one.
|
||||
Because they are separate apps, having both is a matter of installing both, and
|
||||
each then updates itself down its own line. Nothing in the app moves a phone
|
||||
from one channel to the other: the settings screen states which build is
|
||||
running and where its updates come from, and that is all it does.
|
||||
|
||||
The debug keystore is committed on purpose: every build is signed with the same
|
||||
key, so an OTA update installs over the previous one instead of being rejected
|
||||
@@ -292,6 +308,8 @@ game:
|
||||
- [Golf](docs/RULES-golf.md)
|
||||
- [Wizard](docs/RULES-wizard.md)
|
||||
- [Crazy 8s](docs/RULES-crazy8s.md)
|
||||
- [Cribbage](docs/RULES-cribbage.md) — two, three or four, and **note that four
|
||||
play in partnerships pegging on one score**
|
||||
- [Chess](docs/RULES-chess.md)
|
||||
- [Checkers](docs/RULES-checkers.md) — **note the caveat about flying kings**
|
||||
- [Backgammon](docs/RULES-backgammon.md) — **note that there is no doubling cube**
|
||||
@@ -318,6 +336,7 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested
|
||||
golf/ Golf model, scoring, rules, AI
|
||||
wizard/ Wizard model, rules, AI
|
||||
crazyeights/ Crazy 8s model, rules, AI
|
||||
cribbage/ Cribbage the crib, the play, the show, and a counting AI
|
||||
chess/ Chess model, FEN, move generation, search
|
||||
morris/ Nine Men's Morris board geometry, mills, rules, search
|
||||
checkers/ Checkers board, compulsory captures, crowning, search
|
||||
@@ -328,7 +347,7 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested
|
||||
net/ wire protocol, LAN transport, lobby, match driver
|
||||
score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store
|
||||
settings/ DataStore-backed preferences
|
||||
update/ GitHub Releases OTA updater
|
||||
update/ GitHub Releases OTA updater, and the channel rule — pure, tested
|
||||
ui/ Compose screens
|
||||
```
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ object GameCatalog {
|
||||
const val GOLF = "golf"
|
||||
const val WIZARD = "wizard"
|
||||
const val CRAZY_EIGHTS = "crazy8s"
|
||||
const val CRIBBAGE = "cribbage"
|
||||
const val SEQUENCE = "sequence"
|
||||
const val MASTERMIND = "mastermind"
|
||||
const val BACKGAMMON = "backgammon"
|
||||
@@ -103,6 +104,18 @@ object GameCatalog {
|
||||
blurb = "Match rank or suit, and eights change everything.",
|
||||
available = true,
|
||||
),
|
||||
GameDescriptor(
|
||||
id = CRIBBAGE,
|
||||
title = "Cribbage",
|
||||
category = GameCategory.CARD,
|
||||
minPlayers = 2,
|
||||
maxPlayers = 4,
|
||||
// Only at four, where the two pairs share a board between them.
|
||||
teamBased = true,
|
||||
blurb = "Fifteen two, fifteen four, and one for his nob. Peg your way " +
|
||||
"twice round the board to 121.",
|
||||
available = true,
|
||||
),
|
||||
GameDescriptor(
|
||||
id = SEQUENCE,
|
||||
title = "Sequence",
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package org.prolibertate.games.game.cribbage
|
||||
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Decks
|
||||
import org.prolibertate.games.game.cards.Rank
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* A cribbage opponent that counts rather than guesses.
|
||||
*
|
||||
* The lay-away is the decision that decides most hands, so it is the one part
|
||||
* that is worked out exactly: every way of keeping four is scored against all
|
||||
* forty-six cards that could still be cut, and the average is compared. What
|
||||
* goes to the crib is then valued separately and added or subtracted depending
|
||||
* on whose crib it is — the same two cards are a gift to your own crib and a
|
||||
* present to the other side's.
|
||||
*
|
||||
* The play is a heuristic: take the points on offer, and try not to leave the
|
||||
* count where a ten-card collects fifteen or thirty-one.
|
||||
*/
|
||||
class CribbageAi : GameAi<CribbageState, CribbageMove> {
|
||||
|
||||
override fun chooseMove(
|
||||
state: CribbageState,
|
||||
seat: Int,
|
||||
legal: List<CribbageMove>,
|
||||
): CribbageMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
val layAways = legal.filterIsInstance<LayAway>()
|
||||
if (layAways.isNotEmpty()) return chooseLayAway(state, seat, layAways)
|
||||
return choosePeg(state, legal.filterIsInstance<PegCard>())
|
||||
}
|
||||
|
||||
private fun chooseLayAway(
|
||||
state: CribbageState,
|
||||
seat: Int,
|
||||
choices: List<LayAway>,
|
||||
): LayAway {
|
||||
val hand = state.hands[seat]
|
||||
val held = hand.toSet()
|
||||
val unseen = Decks.standard52().filter { it !in held }
|
||||
// A partner's crib is your crib. That is the whole of four-handed play.
|
||||
val ownCrib = state.options.teamOf(seat) == state.options.teamOf(state.dealer)
|
||||
|
||||
return choices.maxByOrNull { choice ->
|
||||
val given = choice.cards.toSet()
|
||||
val keep = hand.filter { it !in given }
|
||||
val expected = unseen.sumOf { starter ->
|
||||
CribbageScoring.show(keep, starter, isCrib = false).sumOf { it.points }
|
||||
}.toDouble() / unseen.size
|
||||
expected + cribValue(choice.cards) * (if (ownCrib) 1.0 else -1.0)
|
||||
} ?: choices.first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Roughly what a lay-away is worth to whoever owns the crib.
|
||||
*
|
||||
* A five is the card of the game — every one of the sixteen ten-cards makes
|
||||
* fifteen with it — so it is never given away cheaply, and a pair or a
|
||||
* fifteen laid away together is two points the crib scores whatever is cut.
|
||||
*/
|
||||
private fun cribValue(cards: List<Card>): Double {
|
||||
var value = cards.sumOf { card ->
|
||||
when (card.rank) {
|
||||
Rank.FIVE -> 2.0
|
||||
Rank.JACK -> 0.6
|
||||
Rank.TWO, Rank.THREE -> 0.3
|
||||
Rank.ACE, Rank.QUEEN, Rank.KING -> 0.1
|
||||
else -> 0.0
|
||||
}
|
||||
}
|
||||
if (cards.size == 2) {
|
||||
val first = cards[0]
|
||||
val second = cards[1]
|
||||
if (first.rank == second.rank) value += 2.0
|
||||
if (pipValue(first) + pipValue(second) == 15) value += 2.0
|
||||
when (abs(runOrder(first) - runOrder(second))) {
|
||||
1 -> value += 1.0
|
||||
2 -> value += 0.5
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private fun choosePeg(state: CribbageState, choices: List<PegCard>): PegCard {
|
||||
val count = state.count
|
||||
val onTheTable = state.series.map { it.card }
|
||||
|
||||
return choices.maxByOrNull { choice ->
|
||||
val scored = CribbageScoring.peg(onTheTable + choice.card).sumOf { it.points }
|
||||
val after = count + pipValue(choice.card)
|
||||
var value = scored * 10.0
|
||||
// Five and twenty-one are the two counts to avoid leaving: any of
|
||||
// the sixteen ten-cards takes fifteen or thirty-one off them.
|
||||
if (after == 5 || after == 21) value -= 3.0
|
||||
// Leading a five is the same present, offered first.
|
||||
if (count == 0 && choice.card.rank == Rank.FIVE) value -= 4.0
|
||||
// Big cards early. Low ones are what let you keep playing at the
|
||||
// awkward end of a series, where the last card is worth a point.
|
||||
value + pipValue(choice.card) * 0.1
|
||||
} ?: choices.first()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package org.prolibertate.games.game.cribbage
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Rank
|
||||
|
||||
@Serializable
|
||||
data class CribbageOptions(
|
||||
val playerCount: Int = 2,
|
||||
val pointsToWin: Int = 121,
|
||||
/** Say so at the finish when the loser never got off the second street. */
|
||||
val countSkunks: Boolean = true,
|
||||
) {
|
||||
init {
|
||||
require(playerCount in 2..4) { "Cribbage seats two to four" }
|
||||
require(pointsToWin == 61 || pointsToWin == 121) {
|
||||
"Cribbage is played once round the board to 61 or twice to 121"
|
||||
}
|
||||
}
|
||||
|
||||
/** Six cards each head to head, five otherwise. */
|
||||
val dealSize: Int get() = if (playerCount == 2) 6 else 5
|
||||
|
||||
/** Two players lay away two cards each; three or four lay away one. */
|
||||
val layAwaySize: Int get() = if (playerCount == 2) 2 else 1
|
||||
|
||||
/**
|
||||
* Three hands laying away one card apiece leave the crib a card short, so
|
||||
* one is dealt straight into it off the top of the pack.
|
||||
*/
|
||||
val cribFromDeck: Int get() = if (playerCount == 3) 1 else 0
|
||||
|
||||
/** Four play in partnerships across the table; two or three play singly. */
|
||||
val teamCount: Int get() = if (playerCount == 4) 2 else playerCount
|
||||
|
||||
fun teamOf(seat: Int): Int = if (playerCount == 4) seat % 2 else seat
|
||||
}
|
||||
|
||||
/** Everyone holds four cards once the crib is laid away, whatever the table. */
|
||||
const val CRIBBAGE_HAND_SIZE = 4
|
||||
|
||||
/** The count never passes this, and reaching it exactly is worth two. */
|
||||
const val CRIBBAGE_LIMIT = 31
|
||||
|
||||
/**
|
||||
* What a card is worth to the count and to a fifteen: aces are one, and every
|
||||
* court card is ten.
|
||||
*/
|
||||
fun pipValue(card: Card): Int = when (card.rank) {
|
||||
Rank.ACE -> 1
|
||||
Rank.JACK, Rank.QUEEN, Rank.KING -> 10
|
||||
else -> card.rank.order
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a card sits in a run. The ace is low here — A-2-3 is a run and Q-K-A is
|
||||
* not — which is why this cannot be [Rank.order], where the ace is high.
|
||||
*/
|
||||
fun runOrder(card: Card): Int = if (card.rank == Rank.ACE) 1 else card.rank.order
|
||||
|
||||
/** One card laid on the table during the play, and by whom. */
|
||||
@Serializable
|
||||
data class PeggedCard(val seat: Int, val card: Card)
|
||||
|
||||
/** One thing a hand scored for, in the words a player would use for it. */
|
||||
@Serializable
|
||||
data class ScoreLine(val label: String, val points: Int)
|
||||
|
||||
/**
|
||||
* A hand or a crib as it was counted at the show.
|
||||
*
|
||||
* [counted] is false for a hand the game never got to: somebody pegged out
|
||||
* ahead of it in the counting order, and the rest of the table shows what it
|
||||
* would have been worth rather than pretending it was scored.
|
||||
*/
|
||||
@Serializable
|
||||
data class ShowScore(
|
||||
val seat: Int,
|
||||
val isCrib: Boolean,
|
||||
val cards: List<Card>,
|
||||
val lines: List<ScoreLine>,
|
||||
val counted: Boolean = true,
|
||||
) {
|
||||
val total: Int get() = lines.sumOf { it.points }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class CribbagePhase {
|
||||
/** Everyone is choosing what to give the crib. */
|
||||
DISCARD,
|
||||
|
||||
/** Pegging: cards on the table one at a time, count to thirty-one. */
|
||||
PLAY,
|
||||
|
||||
/** The hands are counted and on screen, waiting to be read. */
|
||||
SHOW,
|
||||
|
||||
GAME_OVER,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class CribbageState(
|
||||
val options: CribbageOptions,
|
||||
val seed: Long,
|
||||
val handNumber: Int,
|
||||
val dealer: Int,
|
||||
/**
|
||||
* What each seat was dealt, less what it laid away. Cards stay here once
|
||||
* they have been pegged — [remaining] is what is still in the hand, and the
|
||||
* whole four are wanted again at the show.
|
||||
*/
|
||||
val hands: List<List<Card>>,
|
||||
/** Cards still held per seat, which survives redaction where [hands] does not. */
|
||||
val handCounts: List<Int>,
|
||||
val crib: List<Card>,
|
||||
val cribCount: Int,
|
||||
/** What is left of the pack. Never sent to anyone. */
|
||||
val deck: List<Card>,
|
||||
val starter: Card?,
|
||||
/** Everything pegged this hand, in order. */
|
||||
val played: List<PeggedCard>,
|
||||
/** The cards of the series being counted now, which is cleared at thirty-one or a go. */
|
||||
val series: List<PeggedCard>,
|
||||
val saidGo: Set<Int>,
|
||||
val turn: Int,
|
||||
val lastToPlay: Int?,
|
||||
/** By team. Two or three play singly, so there a team is a seat. */
|
||||
val scores: List<Int>,
|
||||
/** Where the back peg stands: each side's score before its last award. */
|
||||
val previousScores: List<Int>,
|
||||
val show: List<ShowScore>,
|
||||
val phase: CribbagePhase,
|
||||
val winner: Int?,
|
||||
val log: List<String>,
|
||||
) {
|
||||
/** The running count of the series in play. Never above [CRIBBAGE_LIMIT]. */
|
||||
val count: Int get() = series.sumOf { pipValue(it.card) }
|
||||
|
||||
/** What [seat] has not pegged yet. Empty for a seat whose hand is redacted. */
|
||||
fun remaining(seat: Int): List<Card> {
|
||||
val gone = played.filter { it.seat == seat }.map { it.card }.toSet()
|
||||
return hands[seat].filter { it !in gone }
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed interface CribbageMove
|
||||
|
||||
/**
|
||||
* Cards given to the crib. Two of them head to head, one otherwise.
|
||||
*
|
||||
* The cards are held in a fixed order rather than the order they were tapped
|
||||
* in, because the host compares a move against the list it generated itself —
|
||||
* a client that picked the same two cards the other way round is making the
|
||||
* same move and must not be refused for it.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("layaway")
|
||||
data class LayAway(val cards: List<Card>) : CribbageMove {
|
||||
companion object {
|
||||
fun of(cards: Collection<Card>): LayAway = LayAway(cards.sortedWith(cribbageCardOrder))
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@SerialName("peg")
|
||||
data class PegCard(val card: Card) : CribbageMove
|
||||
|
||||
/** An arbitrary but stable order, used only to make a lay-away comparable. */
|
||||
val cribbageCardOrder: Comparator<Card> =
|
||||
compareBy({ it.suit.ordinal }, { it.rank.ordinal })
|
||||
@@ -0,0 +1,421 @@
|
||||
package org.prolibertate.games.game.cribbage
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Decks
|
||||
import org.prolibertate.games.game.cards.Rank
|
||||
import org.prolibertate.games.game.cards.shuffledWith
|
||||
import org.prolibertate.games.game.engine.GameRules
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Cribbage as a pure state machine, for two, three or four. See RULES-cribbage.md.
|
||||
*
|
||||
* One thing shapes the whole of this file: a game of cribbage is over the
|
||||
* instant a peg reaches the target, in the middle of a count if that is where
|
||||
* it happens. So every award goes through [award], which stops the game there
|
||||
* and refuses everything after it, rather than the hand being scored in full
|
||||
* and the total compared afterwards.
|
||||
*/
|
||||
object CribbageRules : GameRules<CribbageState, CribbageMove> {
|
||||
|
||||
override val gameId: String = GameCatalog.CRIBBAGE
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
override fun initialState(config: TableConfig): CribbageState {
|
||||
val options = json.decodeFromString<CribbageOptions>(config.optionsJson)
|
||||
require(config.seats.size == options.playerCount) {
|
||||
"Seat count ${config.seats.size} does not match ${options.playerCount} players"
|
||||
}
|
||||
return deal(
|
||||
options = options,
|
||||
seed = config.seed,
|
||||
handNumber = 0,
|
||||
dealer = 0,
|
||||
scores = List(options.teamCount) { 0 },
|
||||
log = listOf("Hand 1. Seat 0 deals."),
|
||||
)
|
||||
}
|
||||
|
||||
private fun deal(
|
||||
options: CribbageOptions,
|
||||
seed: Long,
|
||||
handNumber: Int,
|
||||
dealer: Int,
|
||||
scores: List<Int>,
|
||||
log: List<String>,
|
||||
): CribbageState {
|
||||
val random = Random(seed + handNumber * 7919L)
|
||||
var pack = Decks.standard52().shuffledWith(random)
|
||||
|
||||
val hands = MutableList(options.playerCount) { emptyList<Card>() }
|
||||
// Dealt round the table from the dealer's left, as they would be.
|
||||
for (step in 1..options.playerCount) {
|
||||
val seat = (dealer + step) % options.playerCount
|
||||
hands[seat] = pack.take(options.dealSize)
|
||||
pack = pack.drop(options.dealSize)
|
||||
}
|
||||
val crib = pack.take(options.cribFromDeck)
|
||||
pack = pack.drop(options.cribFromDeck)
|
||||
|
||||
return CribbageState(
|
||||
options = options,
|
||||
seed = seed,
|
||||
handNumber = handNumber,
|
||||
dealer = dealer,
|
||||
hands = hands,
|
||||
handCounts = hands.map { it.size },
|
||||
crib = crib,
|
||||
cribCount = crib.size,
|
||||
deck = pack,
|
||||
starter = null,
|
||||
played = emptyList(),
|
||||
series = emptyList(),
|
||||
saidGo = emptySet(),
|
||||
turn = (dealer + 1) % options.playerCount,
|
||||
lastToPlay = null,
|
||||
scores = scores,
|
||||
previousScores = scores,
|
||||
show = emptyList(),
|
||||
phase = CribbagePhase.DISCARD,
|
||||
winner = null,
|
||||
log = log,
|
||||
)
|
||||
}
|
||||
|
||||
/** Advances from [CribbagePhase.SHOW] into the next deal, the deal moving on one seat. */
|
||||
fun nextHand(state: CribbageState): CribbageState {
|
||||
check(state.phase == CribbagePhase.SHOW) { "The hand is not over" }
|
||||
val dealer = (state.dealer + 1) % state.options.playerCount
|
||||
return deal(
|
||||
options = state.options,
|
||||
seed = state.seed,
|
||||
handNumber = state.handNumber + 1,
|
||||
dealer = dealer,
|
||||
scores = state.scores,
|
||||
log = state.log + "Hand ${state.handNumber + 2}. Seat $dealer deals.",
|
||||
)
|
||||
}
|
||||
|
||||
override fun currentSeat(state: CribbageState): Int? = when (state.phase) {
|
||||
CribbagePhase.DISCARD, CribbagePhase.PLAY -> state.turn
|
||||
CribbagePhase.SHOW, CribbagePhase.GAME_OVER -> null
|
||||
}
|
||||
|
||||
override fun legalMoves(state: CribbageState, seat: Int): List<CribbageMove> {
|
||||
if (currentSeat(state) != seat) return emptyList()
|
||||
return when (state.phase) {
|
||||
CribbagePhase.DISCARD ->
|
||||
combinations(state.hands[seat], state.options.layAwaySize).map { LayAway.of(it) }
|
||||
|
||||
CribbagePhase.PLAY -> state.remaining(seat)
|
||||
.filter { pipValue(it) + state.count <= CRIBBAGE_LIMIT }
|
||||
.map { PegCard(it) }
|
||||
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
override fun applyMove(state: CribbageState, seat: Int, move: CribbageMove): CribbageState {
|
||||
require(currentSeat(state) == seat) { "Not seat $seat's turn" }
|
||||
require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" }
|
||||
return when (move) {
|
||||
is LayAway -> applyLayAway(state, seat, move)
|
||||
is PegCard -> applyPeg(state, seat, move)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The crib
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun applyLayAway(
|
||||
state: CribbageState,
|
||||
seat: Int,
|
||||
move: LayAway,
|
||||
): CribbageState {
|
||||
val hands = state.hands.toMutableList()
|
||||
hands[seat] = hands[seat] - move.cards.toSet()
|
||||
val crib = state.crib + move.cards
|
||||
|
||||
val next = state.copy(
|
||||
hands = hands,
|
||||
handCounts = hands.map { it.size },
|
||||
crib = crib,
|
||||
cribCount = crib.size,
|
||||
turn = (seat + 1) % state.options.playerCount,
|
||||
log = state.log + "Seat $seat lays ${move.cards.size} away to seat ${state.dealer}'s crib.",
|
||||
)
|
||||
|
||||
val everyoneHasLaidAway = next.hands.all { it.size == CRIBBAGE_HAND_SIZE }
|
||||
return if (everyoneHasLaidAway) cutTheStarter(next) else next
|
||||
}
|
||||
|
||||
/**
|
||||
* The cut, which is the top of what is left of a pack already shuffled — so
|
||||
* cutting it anywhere gives the same distribution as cutting it here.
|
||||
*/
|
||||
private fun cutTheStarter(state: CribbageState): CribbageState {
|
||||
val starter = state.deck.first()
|
||||
val cut = state.copy(
|
||||
starter = starter,
|
||||
deck = state.deck.drop(1),
|
||||
turn = (state.dealer + 1) % state.options.playerCount,
|
||||
phase = CribbagePhase.PLAY,
|
||||
log = state.log + "The starter is ${starter.label}.",
|
||||
)
|
||||
// Two for his heels, and it is the dealer's whoever cut it.
|
||||
return if (starter.rank == Rank.JACK) {
|
||||
award(cut, state.dealer, 2, "his heels")
|
||||
} else {
|
||||
cut
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The play
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun applyPeg(state: CribbageState, seat: Int, move: PegCard): CribbageState {
|
||||
val series = state.series + PeggedCard(seat, move.card)
|
||||
var next = state.copy(
|
||||
played = state.played + PeggedCard(seat, move.card),
|
||||
series = series,
|
||||
lastToPlay = seat,
|
||||
)
|
||||
next = next.copy(
|
||||
handCounts = handCounts(next),
|
||||
log = next.log + "Seat $seat plays ${move.card.label} for ${next.count}.",
|
||||
)
|
||||
|
||||
CribbageScoring.peg(series.map { it.card }).forEach { line ->
|
||||
next = award(next, seat, line.points, line.label.lowercase())
|
||||
}
|
||||
if (next.winner != null) return next
|
||||
|
||||
return advancePlay(next, seat)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the play to the next seat that can actually lay a card.
|
||||
*
|
||||
* A seat that cannot play says go — the count only ever rises within a
|
||||
* series, so a seat that cannot play now cannot play later in it either,
|
||||
* and this walks past it rather than stopping the table on a move with one
|
||||
* possible answer. When nobody at all can play, the series is closed.
|
||||
*/
|
||||
private fun advancePlay(state: CribbageState, justPlayed: Int): CribbageState {
|
||||
if (state.count == CRIBBAGE_LIMIT) {
|
||||
// Thirty-one has already paid two; there is no point for the go on top.
|
||||
return closeSeries(state, justPlayed, payForTheGo = false)
|
||||
}
|
||||
|
||||
var next = state
|
||||
val seats = next.options.playerCount
|
||||
for (step in 1..seats) {
|
||||
val seat = (justPlayed + step) % seats
|
||||
val held = next.remaining(seat)
|
||||
if (held.isEmpty()) continue
|
||||
if (held.any { pipValue(it) + next.count <= CRIBBAGE_LIMIT }) {
|
||||
return next.copy(turn = seat)
|
||||
}
|
||||
if (seat !in next.saidGo) {
|
||||
next = next.copy(
|
||||
saidGo = next.saidGo + seat,
|
||||
log = next.log + "Seat $seat says go.",
|
||||
)
|
||||
}
|
||||
}
|
||||
return closeSeries(next, justPlayed, payForTheGo = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the count after a thirty-one or a go, and pays for the last card.
|
||||
*
|
||||
* The seat after whoever played that last card leads the next series, which
|
||||
* is why a hand can turn on being left holding cards nobody can follow.
|
||||
*/
|
||||
private fun closeSeries(
|
||||
state: CribbageState,
|
||||
lastPlayer: Int,
|
||||
payForTheGo: Boolean,
|
||||
): CribbageState {
|
||||
var next = state
|
||||
if (payForTheGo) next = award(next, lastPlayer, 1, "the last card")
|
||||
if (next.winner != null) return next
|
||||
|
||||
val dealt = next.options.playerCount * CRIBBAGE_HAND_SIZE
|
||||
if (next.played.size == dealt) return theShow(next)
|
||||
|
||||
val seats = next.options.playerCount
|
||||
val lead = (1..seats)
|
||||
.map { (lastPlayer + it) % seats }
|
||||
.first { next.remaining(it).isNotEmpty() }
|
||||
|
||||
return next.copy(series = emptyList(), saidGo = emptySet(), turn = lead)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The show
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Counts the hands in order — the dealer's left first, round to the dealer,
|
||||
* and the crib last of all.
|
||||
*
|
||||
* The order is the game: a non-dealer who can count out gets there before
|
||||
* the dealer's hand and crib are ever counted, and that is what makes being
|
||||
* behind on the dealer's turn worse than the numbers alone suggest.
|
||||
*/
|
||||
private fun theShow(state: CribbageState): CribbageState {
|
||||
val starter = requireNotNull(state.starter) { "The show without a starter" }
|
||||
val seats = state.options.playerCount
|
||||
|
||||
val entries = (1..seats).map { step ->
|
||||
val seat = (state.dealer + step) % seats
|
||||
ShowScore(
|
||||
seat = seat,
|
||||
isCrib = false,
|
||||
cards = state.hands[seat],
|
||||
lines = CribbageScoring.show(state.hands[seat], starter, isCrib = false),
|
||||
)
|
||||
} + ShowScore(
|
||||
seat = state.dealer,
|
||||
isCrib = true,
|
||||
cards = state.crib,
|
||||
lines = CribbageScoring.show(state.crib, starter, isCrib = true),
|
||||
)
|
||||
|
||||
var next = state
|
||||
val counted = mutableListOf<ShowScore>()
|
||||
for (entry in entries) {
|
||||
if (next.winner != null) {
|
||||
counted += entry.copy(counted = false)
|
||||
continue
|
||||
}
|
||||
counted += entry
|
||||
entry.lines.forEach { line ->
|
||||
next = award(next, entry.seat, line.points, line.label.lowercase())
|
||||
}
|
||||
}
|
||||
|
||||
return next.copy(
|
||||
show = counted,
|
||||
phase = if (next.winner != null) CribbagePhase.GAME_OVER else CribbagePhase.SHOW,
|
||||
)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Pegging points
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Moves [seat]'s side up the board, and ends the game there if that reaches
|
||||
* the target.
|
||||
*
|
||||
* Everything scored anywhere in the game comes through here, so the game
|
||||
* cannot be carried past its own finish by the rest of a count.
|
||||
*/
|
||||
private fun award(
|
||||
state: CribbageState,
|
||||
seat: Int,
|
||||
points: Int,
|
||||
reason: String,
|
||||
): CribbageState {
|
||||
if (points <= 0 || state.winner != null) return state
|
||||
val team = state.options.teamOf(seat)
|
||||
|
||||
val scores = state.scores.toMutableList()
|
||||
val previous = state.previousScores.toMutableList()
|
||||
previous[team] = scores[team]
|
||||
scores[team] = scores[team] + points
|
||||
|
||||
val won = scores[team] >= state.options.pointsToWin
|
||||
return state.copy(
|
||||
scores = scores,
|
||||
previousScores = previous,
|
||||
winner = if (won) team else null,
|
||||
phase = if (won) CribbagePhase.GAME_OVER else state.phase,
|
||||
log = state.log + "Seat $seat pegs $points for $reason.",
|
||||
)
|
||||
}
|
||||
|
||||
private fun handCounts(state: CribbageState): List<Int> =
|
||||
(0 until state.options.playerCount).map { seat ->
|
||||
state.hands[seat].size - state.played.count { it.seat == seat }
|
||||
}
|
||||
|
||||
/** Every way of choosing [size] cards, in the order they are held. */
|
||||
private fun combinations(cards: List<Card>, size: Int): List<List<Card>> {
|
||||
if (size == 0) return listOf(emptyList())
|
||||
if (cards.size < size) return emptyList()
|
||||
|
||||
val chosen = mutableListOf<List<Card>>()
|
||||
fun take(from: Int, soFar: List<Card>) {
|
||||
if (soFar.size == size) {
|
||||
chosen += soFar
|
||||
return
|
||||
}
|
||||
for (index in from until cards.size) take(index + 1, soFar + cards[index])
|
||||
}
|
||||
take(0, emptyList())
|
||||
return chosen
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Engine plumbing
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
override fun isFinished(state: CribbageState): Boolean =
|
||||
state.phase == CribbagePhase.GAME_OVER
|
||||
|
||||
override fun summary(state: CribbageState): String {
|
||||
val board = state.scores.mapIndexed { team, score -> "T$team:$score" }.joinToString(" ")
|
||||
val winner = state.winner ?: return board
|
||||
val trailing = state.scores
|
||||
.filterIndexed { team, _ -> team != winner }
|
||||
.maxOrNull() ?: 0
|
||||
return "Team $winner wins ($board)" + skunk(state.options, trailing)
|
||||
}
|
||||
|
||||
/**
|
||||
* A loser left on the second street is skunked, and one who never left the
|
||||
* first is double skunked. In a game to 121 those lines are 91 and 61.
|
||||
*/
|
||||
private fun skunk(options: CribbageOptions, trailing: Int): String {
|
||||
if (!options.countSkunks) return ""
|
||||
return when {
|
||||
trailing <= options.pointsToWin / 2 -> " — a double skunk"
|
||||
trailing < options.pointsToWin - options.pointsToWin / 4 -> " — a skunk"
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands and the crib are hidden until the show, and the rest of the pack
|
||||
* always: the starter is cut from it, so a client that could see it would
|
||||
* know the cut before it happened.
|
||||
*/
|
||||
override fun viewFor(state: CribbageState, seat: Int): CribbageState {
|
||||
val revealed = state.phase == CribbagePhase.SHOW || state.phase == CribbagePhase.GAME_OVER
|
||||
return state.copy(
|
||||
hands = state.hands.mapIndexed { index, hand ->
|
||||
if (revealed || index == seat) hand else emptyList()
|
||||
},
|
||||
crib = if (revealed) state.crib else emptyList(),
|
||||
deck = emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun encodeState(state: CribbageState): String = json.encodeToString(state)
|
||||
override fun decodeState(json: String): CribbageState = this.json.decodeFromString(json)
|
||||
override fun encodeMove(move: CribbageMove): String = json.encodeToString(move)
|
||||
override fun decodeMove(json: String): CribbageMove = this.json.decodeFromString(json)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package org.prolibertate.games.game.cribbage
|
||||
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Rank
|
||||
|
||||
/**
|
||||
* Everything cribbage counts, as pure arithmetic over cards.
|
||||
*
|
||||
* Both halves of the game are here: [show] counts a hand against the starter,
|
||||
* and [peg] counts what the card just laid on the table scored. They are kept
|
||||
* apart because they genuinely differ — a flush and his nob are worth nothing
|
||||
* during the play, and the count reaching fifteen is worth nothing at the show.
|
||||
*/
|
||||
object CribbageScoring {
|
||||
|
||||
/**
|
||||
* A hand of four counted with the [starter].
|
||||
*
|
||||
* [isCrib] tightens one rule only: a crib scores a flush only when all five
|
||||
* cards share a suit, where a hand takes four for its own four.
|
||||
*/
|
||||
fun show(hand: List<Card>, starter: Card, isCrib: Boolean): List<ScoreLine> {
|
||||
val all = hand + starter
|
||||
val lines = mutableListOf<ScoreLine>()
|
||||
|
||||
val fifteens = countFifteens(all)
|
||||
if (fifteens > 0) {
|
||||
lines += ScoreLine(
|
||||
label = if (fifteens == 1) "Fifteen" else "$fifteens fifteens",
|
||||
points = fifteens * 2,
|
||||
)
|
||||
}
|
||||
|
||||
lines += pairs(all)
|
||||
lines += runs(all)
|
||||
flush(hand, starter, isCrib)?.let { lines += it }
|
||||
|
||||
if (hand.any { it.rank == Rank.JACK && it.suit == starter.suit }) {
|
||||
lines += ScoreLine("His nob", 1)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* What the last card of [series] scored as it landed.
|
||||
*
|
||||
* The series is the run of cards since the count was last cleared, so
|
||||
* everything here is read off its tail: only cards still on the table
|
||||
* between one thirty-one and the next can pair or make a run.
|
||||
*/
|
||||
fun peg(series: List<Card>): List<ScoreLine> {
|
||||
if (series.isEmpty()) return emptyList()
|
||||
val lines = mutableListOf<ScoreLine>()
|
||||
val count = series.sumOf { pipValue(it) }
|
||||
|
||||
if (count == 15) lines += ScoreLine("Fifteen", 2)
|
||||
if (count == CRIBBAGE_LIMIT) lines += ScoreLine("Thirty-one", 2)
|
||||
|
||||
// Pairs peg only off the end: a pair split by somebody else's card is
|
||||
// not a pair, which is the whole point of playing one to break it up.
|
||||
val last = series.last()
|
||||
var matching = 0
|
||||
for (card in series.asReversed()) {
|
||||
if (card.rank != last.rank) break
|
||||
matching++
|
||||
}
|
||||
if (matching >= 2) lines += ScoreLine(pairLabel(matching), matching * (matching - 1))
|
||||
|
||||
// The longest tail that is a run, in any order — 5-3-4 is a run of three.
|
||||
for (length in series.size downTo 3) {
|
||||
val tail = series.takeLast(length).map { runOrder(it) }
|
||||
if (tail.distinct().size == length && tail.max() - tail.min() == length - 1) {
|
||||
lines += ScoreLine("Run of $length", length)
|
||||
break
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/** Every combination adding to fifteen, of any size. */
|
||||
private fun countFifteens(cards: List<Card>): Int {
|
||||
var found = 0
|
||||
for (mask in 1 until (1 shl cards.size)) {
|
||||
var sum = 0
|
||||
for (index in cards.indices) {
|
||||
if ((mask shr index) and 1 == 1) sum += pipValue(cards[index])
|
||||
}
|
||||
if (sum == 15) found++
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
private fun pairs(cards: List<Card>): List<ScoreLine> = cards
|
||||
.groupBy { it.rank }
|
||||
.values
|
||||
.filter { it.size >= 2 }
|
||||
.sortedBy { runOrder(it.first()) }
|
||||
.map { group ->
|
||||
ScoreLine(
|
||||
label = "${pairLabel(group.size)} of ${group.first().rank.short}s",
|
||||
points = group.size * (group.size - 1),
|
||||
)
|
||||
}
|
||||
|
||||
private fun pairLabel(size: Int): String = when (size) {
|
||||
2 -> "Pair"
|
||||
3 -> "Pair royal"
|
||||
else -> "Double pair royal"
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs, with duplicated ranks multiplying rather than adding.
|
||||
*
|
||||
* 4-5-5-6 is two runs of three and not a run of four: the two fives each
|
||||
* make their own run with the four and the six, which is why the length is
|
||||
* multiplied by how many ways each rank in it can be filled.
|
||||
*/
|
||||
private fun runs(cards: List<Card>): List<ScoreLine> {
|
||||
val copies = cards.groupingBy { runOrder(it) }.eachCount()
|
||||
val ordered = copies.keys.sorted()
|
||||
val lines = mutableListOf<ScoreLine>()
|
||||
|
||||
var index = 0
|
||||
while (index < ordered.size) {
|
||||
var end = index
|
||||
while (end + 1 < ordered.size && ordered[end + 1] == ordered[end] + 1) end++
|
||||
val length = end - index + 1
|
||||
if (length >= 3) {
|
||||
val ways = (index..end).fold(1) { acc, at -> acc * copies.getValue(ordered[at]) }
|
||||
lines += ScoreLine(
|
||||
label = if (ways == 1) "Run of $length" else "$ways runs of $length",
|
||||
points = length * ways,
|
||||
)
|
||||
}
|
||||
index = end + 1
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
private fun flush(hand: List<Card>, starter: Card, isCrib: Boolean): ScoreLine? {
|
||||
val suit = hand.firstOrNull()?.suit ?: return null
|
||||
if (hand.any { it.suit != suit }) return null
|
||||
val withStarter = starter.suit == suit
|
||||
// A crib takes nothing for four of a suit — it has to be all five.
|
||||
if (isCrib && !withStarter) return null
|
||||
val size = hand.size + if (withStarter) 1 else 0
|
||||
return ScoreLine("Flush of $size", size)
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,18 @@ class MatchController<S : Any, M : Any>(
|
||||
private val lock = Mutex()
|
||||
private var sequence = 0
|
||||
|
||||
/**
|
||||
* A scoreboard a player has read and released, waiting to be let past.
|
||||
*
|
||||
* The hold belongs to a particular scoreboard rather than to the phase, and
|
||||
* releasing it has to be spent: [awaitsConfirmation] is asked about the
|
||||
* state, which has not changed while the players were reading it, so
|
||||
* without something consumable here the same state would stop the table
|
||||
* again the instant [confirmAdvance] restarted the loop — and the next hand
|
||||
* would never be dealt.
|
||||
*/
|
||||
private var released = false
|
||||
|
||||
private val peers = mutableMapOf<String, Connection>()
|
||||
private var hostLink: Connection? = null
|
||||
|
||||
@@ -288,8 +300,15 @@ class MatchController<S : Any, M : Any>(
|
||||
if (role == Role.HOST && awaitsConfirmation(current)) {
|
||||
// Hold here until a player has read the scoreboard. Only the
|
||||
// host waits; clients simply render what they were sent.
|
||||
_awaitingConfirmation.value = true
|
||||
return
|
||||
val letThrough = lock.withLock {
|
||||
val granted = released
|
||||
released = false
|
||||
granted
|
||||
}
|
||||
if (!letThrough) {
|
||||
_awaitingConfirmation.value = true
|
||||
return
|
||||
}
|
||||
}
|
||||
delay(aiThinkingMillis() * 2)
|
||||
val advanced = advanceIdle(current)
|
||||
@@ -398,7 +417,10 @@ class MatchController<S : Any, M : Any>(
|
||||
if (!_awaitingConfirmation.value) return
|
||||
_awaitingConfirmation.value = false
|
||||
if (role != Role.HOST) return
|
||||
scope.launch { driveIdleSeats() }
|
||||
scope.launch {
|
||||
lock.withLock { released = true }
|
||||
driveIdleSeats()
|
||||
}
|
||||
}
|
||||
|
||||
/** Lets a player go on looking at a table they have been told is over. */
|
||||
|
||||
@@ -10,7 +10,6 @@ import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.prolibertate.games.update.UpdateChannel
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
||||
|
||||
@@ -32,8 +31,6 @@ data class Settings(
|
||||
* snapped back before a real name could be typed.
|
||||
*/
|
||||
val playerName: String = "",
|
||||
/** Which builds the updater offers. Production unless deliberately changed. */
|
||||
val updateChannel: UpdateChannel = UpdateChannel.PRODUCTION,
|
||||
) {
|
||||
/**
|
||||
* The name other players actually see. This is where the default lives, so
|
||||
@@ -60,7 +57,11 @@ class SettingsRepository(private val context: Context) {
|
||||
val SPEED = floatPreferencesKey("animation_speed")
|
||||
val UPDATE_ON_LAUNCH = booleanPreferencesKey("check_updates_on_launch")
|
||||
val NAME = stringPreferencesKey("player_name")
|
||||
val CHANNEL = stringPreferencesKey("update_channel")
|
||||
// There is deliberately no update-channel key. The channel is a
|
||||
// property of the installed APK, not a preference — an install that
|
||||
// could be pointed at the other channel's builds is exactly the mix-up
|
||||
// that separate applicationIds exist to prevent. Anything an older
|
||||
// version left under "update_channel" is simply never read again.
|
||||
}
|
||||
|
||||
val settings: Flow<Settings> = context.dataStore.data.map { prefs ->
|
||||
@@ -69,16 +70,9 @@ class SettingsRepository(private val context: Context) {
|
||||
animationSpeed = prefs[Keys.SPEED] ?: 1.0f,
|
||||
checkForUpdatesOnLaunch = prefs[Keys.UPDATE_ON_LAUNCH] ?: true,
|
||||
playerName = prefs[Keys.NAME] ?: "",
|
||||
updateChannel = prefs[Keys.CHANNEL]
|
||||
?.let { stored -> runCatching { UpdateChannel.valueOf(stored) }.getOrNull() }
|
||||
?: UpdateChannel.PRODUCTION,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun setUpdateChannel(channel: UpdateChannel) {
|
||||
context.dataStore.edit { it[Keys.CHANNEL] = channel.name }
|
||||
}
|
||||
|
||||
suspend fun setSoundEnabled(enabled: Boolean) {
|
||||
context.dataStore.edit { it[Keys.SOUND] = enabled }
|
||||
}
|
||||
|
||||
@@ -129,6 +129,11 @@ fun AppRoot(env: AppEnv) {
|
||||
if (!splashDone) {
|
||||
SplashScreen(onFinished = { splashDone = true })
|
||||
}
|
||||
|
||||
// Looks while the splash is up and waits until it has gone before
|
||||
// saying anything, so the check costs nothing in time and cannot
|
||||
// interrupt the opening of the app.
|
||||
LaunchUpdateCheck(env = env, visible = splashDone)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import org.prolibertate.games.game.checkers.CheckersOptions
|
||||
import org.prolibertate.games.game.chess.ChessLevel
|
||||
import org.prolibertate.games.game.chess.ChessOptions
|
||||
import org.prolibertate.games.game.crazyeights.CrazyEightsOptions
|
||||
import org.prolibertate.games.game.cribbage.CribbageOptions
|
||||
import org.prolibertate.games.game.euchre.EuchreOptions
|
||||
import org.prolibertate.games.game.golf.GolfOptions
|
||||
import org.prolibertate.games.game.kaiser.KaiserOptions
|
||||
@@ -78,6 +79,7 @@ fun GameSetupScreen(
|
||||
var golf by remember { mutableStateOf(GolfOptions()) }
|
||||
var kaiser by remember { mutableStateOf(KaiserOptions()) }
|
||||
var crazyEights by remember { mutableStateOf(CrazyEightsOptions()) }
|
||||
var cribbage by remember { mutableStateOf(CribbageOptions()) }
|
||||
var wizard by remember { mutableStateOf(WizardOptions()) }
|
||||
var chess by remember { mutableStateOf(ChessOptions()) }
|
||||
var tayu by remember { mutableStateOf(TayuOptions()) }
|
||||
@@ -100,6 +102,7 @@ fun GameSetupScreen(
|
||||
GameCatalog.GOLF -> setupJson.encodeToString(golf)
|
||||
GameCatalog.KAISER -> setupJson.encodeToString(kaiser)
|
||||
GameCatalog.CRAZY_EIGHTS -> setupJson.encodeToString(crazyEights)
|
||||
GameCatalog.CRIBBAGE -> setupJson.encodeToString(cribbage)
|
||||
GameCatalog.WIZARD -> setupJson.encodeToString(wizard)
|
||||
GameCatalog.CHESS -> setupJson.encodeToString(chess)
|
||||
GameCatalog.TAYU -> setupJson.encodeToString(tayu)
|
||||
@@ -132,6 +135,8 @@ fun GameSetupScreen(
|
||||
crazyEights = it
|
||||
}
|
||||
|
||||
GameCatalog.CRIBBAGE -> CribbageOptionsEditor(cribbage) { cribbage = it }
|
||||
|
||||
GameCatalog.WIZARD -> WizardOptionsEditor(wizard) { wizard = it }
|
||||
GameCatalog.CHESS -> ChessOptionsEditor(
|
||||
options = chess,
|
||||
@@ -204,7 +209,7 @@ private fun offlineConfig(
|
||||
seat = seat,
|
||||
name = if (seat == localSeat) playerName else "Computer $seat",
|
||||
kind = if (seat == localSeat) PlayerKind.HUMAN_LOCAL else PlayerKind.AI,
|
||||
team = teamForSeat(gameId, seat),
|
||||
team = teamForSeat(gameId, seat, seatCount),
|
||||
)
|
||||
},
|
||||
optionsJson = optionsJson,
|
||||
@@ -236,6 +241,9 @@ fun seatCountFor(gameId: String, optionsJson: String): Int {
|
||||
GameCatalog.CRAZY_EIGHTS ->
|
||||
setupJson.decodeFromString<CrazyEightsOptions>(optionsJson).playerCount
|
||||
|
||||
GameCatalog.CRIBBAGE ->
|
||||
setupJson.decodeFromString<CribbageOptions>(optionsJson).playerCount
|
||||
|
||||
GameCatalog.WIZARD ->
|
||||
setupJson.decodeFromString<WizardOptions>(optionsJson).playerCount
|
||||
|
||||
@@ -250,11 +258,20 @@ fun seatCountFor(gameId: String, optionsJson: String): Int {
|
||||
}.getOrDefault(fallback)
|
||||
}
|
||||
|
||||
fun teamForSeat(gameId: String, seat: Int): Int = when (gameId) {
|
||||
/**
|
||||
* Which side a seat is on.
|
||||
*
|
||||
* [seatCount] is here for the games that are only a partnership game at some
|
||||
* table sizes: cribbage seats two, three or four, and only the four-handed game
|
||||
* is played in pairs — reading `seat % 2` at a table of three would put the
|
||||
* first and third players on the same side, which is not a game anybody plays.
|
||||
*/
|
||||
fun teamForSeat(gameId: String, seat: Int, seatCount: Int): Int = when (gameId) {
|
||||
// Partners sit opposite each other. In Ta Yü that is also which pair of
|
||||
// edges you are running your rivers to: even seats north and south, odd
|
||||
// seats east and west.
|
||||
GameCatalog.EUCHRE, GameCatalog.KAISER, GameCatalog.SEQUENCE, GameCatalog.TAYU -> seat % 2
|
||||
GameCatalog.CRIBBAGE -> if (seatCount == 4) seat % 2 else seat
|
||||
// Everyone else plays for themselves.
|
||||
else -> seat
|
||||
}
|
||||
@@ -502,6 +519,50 @@ private fun CrazyEightsOptionsEditor(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CribbageOptionsEditor(options: CribbageOptions, onChange: (CribbageOptions) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Players",
|
||||
values = listOf(2, 3, 4),
|
||||
selected = options.playerCount,
|
||||
display = { count ->
|
||||
when (count) {
|
||||
2 -> "2, head to head"
|
||||
3 -> "3, every hand for itself"
|
||||
else -> "4, in partnerships"
|
||||
}
|
||||
},
|
||||
onSelect = { onChange(options.copy(playerCount = it)) },
|
||||
)
|
||||
ChipRow(
|
||||
label = "Game to",
|
||||
values = listOf(61, 121),
|
||||
selected = options.pointsToWin,
|
||||
display = { if (it == 61) "61, once round" else "121, twice round" },
|
||||
onSelect = { onChange(options.copy(pointsToWin = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Call the skunk",
|
||||
subtitle = "Say so at the finish when the loser never got off the second street",
|
||||
checked = options.countSkunks,
|
||||
onChange = { onChange(options.copy(countSkunks = it)) },
|
||||
)
|
||||
val shortCrib = if (options.playerCount == 3) {
|
||||
", with a fourth dealt straight into it"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
Text(
|
||||
text = "${options.dealSize} cards each, and everyone lays " +
|
||||
"${options.layAwaySize} away to the dealer's crib$shortCrib. Hands are " +
|
||||
"counted from the dealer's left, so a game can be won before the " +
|
||||
"dealer counts at all.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WizardOptionsEditor(options: WizardOptions, onChange: (WizardOptions) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
|
||||
@@ -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,
|
||||
hostName = playerName,
|
||||
seatCount = seatCount,
|
||||
teamOf = { seat -> teamForSeat(route.gameId, seat) },
|
||||
teamOf = { seat -> teamForSeat(route.gameId, seat, seatCount) },
|
||||
)
|
||||
} else {
|
||||
env.lobby.startDiscovery()
|
||||
|
||||
@@ -16,6 +16,11 @@ import org.prolibertate.games.game.crazyeights.CrazyEightsMove
|
||||
import org.prolibertate.games.game.crazyeights.CrazyEightsPhase
|
||||
import org.prolibertate.games.game.crazyeights.CrazyEightsRules
|
||||
import org.prolibertate.games.game.crazyeights.CrazyEightsState
|
||||
import org.prolibertate.games.game.cribbage.CribbageAi
|
||||
import org.prolibertate.games.game.cribbage.CribbageMove
|
||||
import org.prolibertate.games.game.cribbage.CribbagePhase
|
||||
import org.prolibertate.games.game.cribbage.CribbageRules
|
||||
import org.prolibertate.games.game.cribbage.CribbageState
|
||||
import org.prolibertate.games.game.euchre.EuchreAi
|
||||
import org.prolibertate.games.game.euchre.EuchreMove
|
||||
import org.prolibertate.games.game.euchre.EuchrePhase
|
||||
@@ -75,6 +80,7 @@ import org.prolibertate.games.ui.game.BackgammonScreen
|
||||
import org.prolibertate.games.ui.game.CheckersScreen
|
||||
import org.prolibertate.games.ui.game.ChessScreen
|
||||
import org.prolibertate.games.ui.game.CrazyEightsScreen
|
||||
import org.prolibertate.games.ui.game.CribbageScreen
|
||||
import org.prolibertate.games.ui.game.EuchreScreen
|
||||
import org.prolibertate.games.ui.game.GolfScreen
|
||||
import org.prolibertate.games.ui.game.KaiserScreen
|
||||
@@ -308,6 +314,33 @@ fun PlayScreen(
|
||||
CrazyEightsScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||
}
|
||||
|
||||
GameCatalog.CRIBBAGE -> {
|
||||
val controller = remember(route) {
|
||||
MatchController<CribbageState, CribbageMove>(
|
||||
rules = CribbageRules,
|
||||
ai = CribbageAi(),
|
||||
config = route.config,
|
||||
scope = scope,
|
||||
role = role,
|
||||
localSeats = setOf(route.localSeat),
|
||||
primarySeat = route.localSeat,
|
||||
advanceIdle = { state ->
|
||||
if (state.phase == CribbagePhase.SHOW) {
|
||||
CribbageRules.nextHand(state)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
// The show is the half of cribbage that is read rather than
|
||||
// played, so the table stops on it until somebody says go on.
|
||||
awaitsConfirmation = { state -> state.phase == CribbagePhase.SHOW },
|
||||
aiThinkingMillis = { settings.scaled(700L) },
|
||||
)
|
||||
}
|
||||
StartMatch(env, controller, route)
|
||||
CribbageScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||
}
|
||||
|
||||
GameCatalog.TAYU -> {
|
||||
val controller = remember(route) {
|
||||
MatchController<TayuState, TayuMove>(
|
||||
|
||||
@@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Slider
|
||||
@@ -26,8 +25,9 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import org.prolibertate.games.settings.Settings
|
||||
import org.prolibertate.games.update.Release
|
||||
import org.prolibertate.games.update.UpdateChannel
|
||||
import org.prolibertate.games.update.Updater
|
||||
import org.prolibertate.games.update.UpdateVerdict
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
@@ -42,7 +42,7 @@ fun SettingsScreen(
|
||||
// caret jumped and a cleared field refilled itself.
|
||||
var editedName by remember { mutableStateOf<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 ->
|
||||
Column(
|
||||
@@ -111,45 +111,32 @@ fun SettingsScreen(
|
||||
Divider()
|
||||
|
||||
Text("Updates", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
text = "Installed: v${env.updater.installedVersionName()} " +
|
||||
"(${env.updater.installedChannel().label} channel).",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
// The channel is not a setting. It is read off the running APK, and
|
||||
// this build updates down its own line and no other — so it is
|
||||
// stated here rather than offered.
|
||||
val channel = env.updater.installedChannel()
|
||||
Column {
|
||||
Text("Update channel", fontWeight = FontWeight.Bold)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
UpdateChannel.entries.forEach { channel ->
|
||||
FilterChip(
|
||||
selected = settings.updateChannel == channel,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
env.settingsRepository.setUpdateChannel(channel)
|
||||
}
|
||||
updateStatus = null
|
||||
pendingRelease = null
|
||||
},
|
||||
label = { Text(channel.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = settings.updateChannel.blurb + ".",
|
||||
text = "Installed: v${env.updater.installedVersionName()} — " +
|
||||
"${channel.label} channel.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Text(
|
||||
text = "${channel.blurb}. This build updates from the " +
|
||||
"${channel.label} channel only.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Text(
|
||||
text = if (channel == UpdateChannel.DEV) {
|
||||
"The production app is a separate install with its own updates. " +
|
||||
"Install it from the releases page if you want both."
|
||||
} else {
|
||||
"Dev builds are a separate install with their own updates, from " +
|
||||
"the releases page."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
if (settings.updateChannel != env.updater.installedChannel()) {
|
||||
// Each channel installs under its own package, so the other
|
||||
// one arrives as a second app rather than replacing this
|
||||
// one — no downgrade for Android to refuse.
|
||||
Text(
|
||||
text = "You're on a ${env.updater.installedChannel().label} build. " +
|
||||
"The two channels install as separate apps, so this adds the " +
|
||||
"${settings.updateChannel.label} build alongside this one and " +
|
||||
"leaves it in place.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow(
|
||||
@@ -166,26 +153,20 @@ fun SettingsScreen(
|
||||
|
||||
PrimaryAction(text = "Check for updates now") {
|
||||
scope.launch {
|
||||
updateStatus = "Checking the ${settings.updateChannel.label} channel…"
|
||||
when (val result = env.updater.check(settings.updateChannel)) {
|
||||
is Updater.Result.UpToDate -> {
|
||||
updateStatus =
|
||||
"You're on the latest ${settings.updateChannel.label} build."
|
||||
updateStatus = "Checking the ${channel.label} channel…"
|
||||
when (val verdict = env.updater.check()) {
|
||||
is UpdateVerdict.UpToDate -> {
|
||||
updateStatus = "You're on the latest ${channel.label} build."
|
||||
pendingRelease = null
|
||||
}
|
||||
|
||||
is Updater.Result.Available -> {
|
||||
pendingRelease = result.release
|
||||
updateStatus = if (result.isChannelSwitch) {
|
||||
"${result.release.tag} is the current " +
|
||||
"${result.release.channel.label} build."
|
||||
} else {
|
||||
"Version ${result.release.tag} is available."
|
||||
}
|
||||
is UpdateVerdict.Install -> {
|
||||
pendingRelease = verdict.release
|
||||
updateStatus = "Version ${verdict.release.tag} is available."
|
||||
}
|
||||
|
||||
is Updater.Result.Failed -> {
|
||||
updateStatus = result.reason
|
||||
is UpdateVerdict.Refused -> {
|
||||
updateStatus = verdict.reason
|
||||
pendingRelease = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.URL
|
||||
|
||||
/**
|
||||
* Which builds the updater offers.
|
||||
*
|
||||
* The two channels are published by different workflows and so have entirely
|
||||
* separate version sequences — a dev build's versionCode is its CI run number,
|
||||
* a production build's is its release run number, and the two are unrelated.
|
||||
* Versions are therefore only ever compared *within* a channel; moving between
|
||||
* them is treated as an explicit switch rather than an upgrade.
|
||||
*
|
||||
* The two also carry different applicationIds — a dev build installs as
|
||||
* `…games.dev` — so a switch installs the other channel beside this one instead
|
||||
* of over it, and both can be kept on the same device.
|
||||
*/
|
||||
enum class UpdateChannel(val label: String, val blurb: String) {
|
||||
PRODUCTION("Production", "Stable builds released from main"),
|
||||
DEV("Dev", "Preview builds from the dev branch — newer, less tested"),
|
||||
}
|
||||
|
||||
/** Marks a version name as belonging to the dev channel. */
|
||||
private const val DEV_SUFFIX = "-dev"
|
||||
|
||||
/**
|
||||
* Over-the-air updates from the repository's GitHub Releases.
|
||||
*
|
||||
@@ -42,50 +21,29 @@ private const val DEV_SUFFIX = "-dev"
|
||||
* number, so comparing the two is enough to tell whether a newer build exists.
|
||||
* The repository's releases must be publicly downloadable — no token is
|
||||
* embedded in the app, and GitHub blocks anonymous access to private assets.
|
||||
*
|
||||
* There is nothing to choose here. The channel comes from the running APK, so
|
||||
* a dev install looks only at dev builds and a production install only at
|
||||
* production ones — see [verdictFor], where that rule lives and is tested.
|
||||
*/
|
||||
class Updater(private val activity: Activity) {
|
||||
|
||||
data class Release(
|
||||
val tag: String,
|
||||
val versionCode: Int,
|
||||
val apkUrl: String,
|
||||
val apkName: String,
|
||||
val channel: UpdateChannel,
|
||||
)
|
||||
|
||||
sealed interface Result {
|
||||
/**
|
||||
* [isChannelSwitch] means this build is from a different channel than
|
||||
* the one installed, so it is an explicit move rather than a newer
|
||||
* version of the same line — and, because the channels have separate
|
||||
* applicationIds, it lands as a second app rather than an upgrade.
|
||||
*/
|
||||
data class Available(val release: Release, val isChannelSwitch: Boolean) : Result
|
||||
|
||||
data object UpToDate : Result
|
||||
data class Failed(val reason: String) : Result
|
||||
}
|
||||
|
||||
suspend fun check(channel: UpdateChannel): Result = withContext(Dispatchers.IO) {
|
||||
/**
|
||||
* Looks for a newer build of whatever is installed.
|
||||
*
|
||||
* Takes no channel: asking for one would be asking which line of builds
|
||||
* this phone is on, and the phone already knows.
|
||||
*/
|
||||
suspend fun check(): UpdateVerdict = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val latest = fetchLatestRelease(channel)
|
||||
?: return@withContext Result.Failed(
|
||||
"No ${channel.label.lowercase()} build has been published yet."
|
||||
)
|
||||
|
||||
when {
|
||||
// Different channel: version numbers are not comparable, so
|
||||
// always offer it and let the user decide.
|
||||
latest.channel != installedChannel() ->
|
||||
Result.Available(latest, isChannelSwitch = true)
|
||||
|
||||
latest.versionCode > installedVersionCode() ->
|
||||
Result.Available(latest, isChannelSwitch = false)
|
||||
|
||||
else -> Result.UpToDate
|
||||
}
|
||||
val channel = installedChannel()
|
||||
verdictFor(
|
||||
latest = fetchLatestRelease(channel),
|
||||
installedChannel = channel,
|
||||
installedVersionCode = installedVersionCode(),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Result.Failed(e.message ?: "Update check failed.")
|
||||
UpdateVerdict.Refused(e.message ?: "Update check failed.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,19 +70,8 @@ class Updater(private val activity: Activity) {
|
||||
|
||||
private fun toRelease(json: JSONObject): Release? {
|
||||
val tag = json.optString("tag_name").ifBlank { return null }
|
||||
// Tags are v<series>.<n>, with -dev on a prerelease. Only the trailing
|
||||
// run number is read: the series is a name for people and changing it
|
||||
// must not make an older build look newer.
|
||||
val versionCode = tag.substringAfterLast('.')
|
||||
.removeSuffix(DEV_SUFFIX)
|
||||
.toIntOrNull()
|
||||
?: return null
|
||||
val channel =
|
||||
if (tag.endsWith(DEV_SUFFIX) || json.optBoolean("prerelease")) {
|
||||
UpdateChannel.DEV
|
||||
} else {
|
||||
UpdateChannel.PRODUCTION
|
||||
}
|
||||
val versionCode = versionCodeOfTag(tag) ?: return null
|
||||
val channel = channelOfTag(tag, json.optBoolean("prerelease"))
|
||||
|
||||
val assets = json.optJSONArray("assets") ?: return null
|
||||
val apks = (0 until assets.length())
|
||||
@@ -178,9 +125,7 @@ class Updater(private val activity: Activity) {
|
||||
}
|
||||
|
||||
/** Read back from the installed APK's own version name. */
|
||||
fun installedChannel(): UpdateChannel =
|
||||
if (installedVersionName().endsWith(DEV_SUFFIX)) UpdateChannel.DEV
|
||||
else UpdateChannel.PRODUCTION
|
||||
fun installedChannel(): UpdateChannel = channelOfVersionName(installedVersionName())
|
||||
|
||||
/** Downloads the APK and hands it to the system installer. */
|
||||
suspend fun downloadAndInstall(release: Release): String? = withContext(Dispatchers.IO) {
|
||||
|
||||
@@ -70,6 +70,7 @@ class CatalogTest {
|
||||
GameCatalog.GOLF,
|
||||
GameCatalog.KAISER,
|
||||
GameCatalog.CRAZY_EIGHTS,
|
||||
GameCatalog.CRIBBAGE,
|
||||
GameCatalog.WIZARD,
|
||||
GameCatalog.CHESS,
|
||||
GameCatalog.TAYU,
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
package org.prolibertate.games.game.cribbage
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Rank
|
||||
import org.prolibertate.games.game.cards.Suit
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import kotlin.random.Random
|
||||
|
||||
class CribbageRulesTest {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private fun config(
|
||||
options: CribbageOptions = CribbageOptions(),
|
||||
seed: Long = 21L,
|
||||
) = TableConfig(
|
||||
gameId = "cribbage",
|
||||
seats = (0 until options.playerCount).map {
|
||||
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = options.teamOf(it))
|
||||
},
|
||||
optionsJson = json.encodeToString(options),
|
||||
seed = seed,
|
||||
)
|
||||
|
||||
private fun c(rank: Rank, suit: Suit) = Card(rank, suit)
|
||||
|
||||
/** A table part-way through the play, built rather than dealt. */
|
||||
private fun playing(
|
||||
hands: List<List<Card>>,
|
||||
options: CribbageOptions = CribbageOptions(playerCount = hands.size),
|
||||
dealer: Int = hands.size - 1,
|
||||
turn: Int = 0,
|
||||
starter: Card = c(Rank.NINE, Suit.CLUBS),
|
||||
played: List<PeggedCard> = emptyList(),
|
||||
series: List<PeggedCard> = played,
|
||||
crib: List<Card> = emptyList(),
|
||||
scores: List<Int> = List(options.teamCount) { 0 },
|
||||
) = CribbageState(
|
||||
options = options,
|
||||
seed = 1L,
|
||||
handNumber = 0,
|
||||
dealer = dealer,
|
||||
hands = hands,
|
||||
handCounts = hands.indices.map { seat ->
|
||||
hands[seat].size - played.count { it.seat == seat }
|
||||
},
|
||||
crib = crib,
|
||||
cribCount = crib.size,
|
||||
deck = emptyList(),
|
||||
starter = starter,
|
||||
played = played,
|
||||
series = series,
|
||||
saidGo = emptySet(),
|
||||
turn = turn,
|
||||
lastToPlay = played.lastOrNull()?.seat,
|
||||
scores = scores,
|
||||
previousScores = scores,
|
||||
show = emptyList(),
|
||||
phase = CribbagePhase.PLAY,
|
||||
winner = null,
|
||||
log = emptyList(),
|
||||
)
|
||||
|
||||
/** Lays away for every seat in turn, leaving the table cut and ready to play. */
|
||||
private fun dealAndLayAway(state: CribbageState): CribbageState {
|
||||
var next = state
|
||||
while (next.phase == CribbagePhase.DISCARD) {
|
||||
val seat = CribbageRules.currentSeat(next)!!
|
||||
next = CribbageRules.applyMove(next, seat, CribbageRules.legalMoves(next, seat).first())
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// -- The deal ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `two players get six cards each and three or four get five`() {
|
||||
assertEquals(6, CribbageOptions(playerCount = 2).dealSize)
|
||||
assertEquals(5, CribbageOptions(playerCount = 3).dealSize)
|
||||
assertEquals(5, CribbageOptions(playerCount = 4).dealSize)
|
||||
assertEquals(2, CribbageOptions(playerCount = 2).layAwaySize)
|
||||
assertEquals(1, CribbageOptions(playerCount = 4).layAwaySize)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the crib is four cards at every table size`() {
|
||||
listOf(2, 3, 4).forEach { players ->
|
||||
val state = dealAndLayAway(
|
||||
CribbageRules.initialState(config(CribbageOptions(playerCount = players)))
|
||||
)
|
||||
assertEquals("$players players", CRIBBAGE_HAND_SIZE, state.crib.size)
|
||||
assertTrue(
|
||||
"$players players kept the wrong number",
|
||||
state.hands.all { it.size == CRIBBAGE_HAND_SIZE },
|
||||
)
|
||||
// No card is in two places at once.
|
||||
val all = state.hands.flatten() + state.crib + state.starter!!
|
||||
assertEquals(all.size, all.distinct().size)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `three-handed deals the odd card straight into the crib`() {
|
||||
val state = CribbageRules.initialState(config(CribbageOptions(playerCount = 3)))
|
||||
assertEquals(1, state.crib.size)
|
||||
assertEquals(1, state.cribCount)
|
||||
assertEquals(0, CribbageRules.initialState(config(CribbageOptions(playerCount = 2))).crib.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the dealer's left lays away first and the dealer last`() {
|
||||
val state = CribbageRules.initialState(config(CribbageOptions(playerCount = 3)))
|
||||
assertEquals(0, state.dealer)
|
||||
assertEquals(1, CribbageRules.currentSeat(state))
|
||||
val afterOne = CribbageRules.applyMove(
|
||||
state, 1, CribbageRules.legalMoves(state, 1).first()
|
||||
)
|
||||
assertEquals(2, CribbageRules.currentSeat(afterOne))
|
||||
val afterTwo = CribbageRules.applyMove(
|
||||
afterOne, 2, CribbageRules.legalMoves(afterOne, 2).first()
|
||||
)
|
||||
assertEquals(0, CribbageRules.currentSeat(afterTwo))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a lay-away means the same cards whichever order they were picked in`() {
|
||||
val one = c(Rank.FIVE, Suit.SPADES)
|
||||
val two = c(Rank.KING, Suit.HEARTS)
|
||||
assertEquals(LayAway.of(listOf(one, two)), LayAway.of(listOf(two, one)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `heads-up offers every way of laying two away`() {
|
||||
val state = CribbageRules.initialState(config())
|
||||
// Fifteen ways of choosing two cards out of six.
|
||||
assertEquals(15, CribbageRules.legalMoves(state, 1).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the starter is cut once everybody has laid away`() {
|
||||
val dealt = CribbageRules.initialState(config())
|
||||
assertNull(dealt.starter)
|
||||
val ready = dealAndLayAway(dealt)
|
||||
assertNotNull(ready.starter)
|
||||
assertEquals(CribbagePhase.PLAY, ready.phase)
|
||||
// The non-dealer leads.
|
||||
assertEquals(1, ready.turn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a jack cut is two for his heels and they are the dealer's`() {
|
||||
var found = 0
|
||||
for (seed in 0L until 200L) {
|
||||
val ready = dealAndLayAway(CribbageRules.initialState(config(seed = seed)))
|
||||
if (ready.starter?.rank != Rank.JACK) {
|
||||
assertEquals("seed $seed scored something for a plain cut", 0, ready.scores.sum())
|
||||
continue
|
||||
}
|
||||
found++
|
||||
assertEquals("seed $seed", 2, ready.scores[ready.options.teamOf(ready.dealer)])
|
||||
assertEquals(0, ready.scores[1 - ready.options.teamOf(ready.dealer)])
|
||||
}
|
||||
assertTrue("no jack was ever cut in two hundred deals", found > 0)
|
||||
}
|
||||
|
||||
// -- The play ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `you may not take the count past thirty-one`() {
|
||||
val state = playing(
|
||||
hands = listOf(
|
||||
listOf(
|
||||
c(Rank.KING, Suit.SPADES), c(Rank.NINE, Suit.DIAMONDS),
|
||||
c(Rank.TWO, Suit.HEARTS), c(Rank.SEVEN, Suit.CLUBS),
|
||||
),
|
||||
listOf(
|
||||
c(Rank.QUEEN, Suit.CLUBS), c(Rank.JACK, Suit.SPADES),
|
||||
c(Rank.TEN, Suit.DIAMONDS), c(Rank.THREE, Suit.HEARTS),
|
||||
),
|
||||
),
|
||||
played = listOf(
|
||||
PeggedCard(0, c(Rank.KING, Suit.SPADES)),
|
||||
PeggedCard(1, c(Rank.QUEEN, Suit.CLUBS)),
|
||||
PeggedCard(0, c(Rank.NINE, Suit.DIAMONDS)),
|
||||
),
|
||||
turn = 0,
|
||||
)
|
||||
// Twenty-nine on the table: of the two cards left, only the two fits.
|
||||
assertEquals(29, state.count)
|
||||
assertEquals(
|
||||
listOf<CribbageMove>(PegCard(c(Rank.TWO, Suit.HEARTS))),
|
||||
CribbageRules.legalMoves(state, 0),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the last card is worth one and the next series starts beyond it`() {
|
||||
val state = playing(
|
||||
hands = listOf(
|
||||
listOf(
|
||||
c(Rank.KING, Suit.SPADES), c(Rank.TEN, Suit.HEARTS),
|
||||
c(Rank.NINE, Suit.DIAMONDS), c(Rank.EIGHT, Suit.CLUBS),
|
||||
),
|
||||
listOf(
|
||||
c(Rank.QUEEN, Suit.CLUBS), c(Rank.JACK, Suit.SPADES),
|
||||
c(Rank.TEN, Suit.DIAMONDS), c(Rank.NINE, Suit.HEARTS),
|
||||
),
|
||||
),
|
||||
played = listOf(
|
||||
PeggedCard(0, c(Rank.KING, Suit.SPADES)),
|
||||
PeggedCard(1, c(Rank.QUEEN, Suit.CLUBS)),
|
||||
),
|
||||
turn = 0,
|
||||
)
|
||||
assertEquals(20, state.count)
|
||||
|
||||
val after = CribbageRules.applyMove(state, 0, PegCard(c(Rank.TEN, Suit.HEARTS)))
|
||||
|
||||
// Thirty on the table and nobody holds a card small enough, so seat 0
|
||||
// takes one for the last card and the count is cleared.
|
||||
assertEquals(1, after.scores[0])
|
||||
assertEquals(0, after.scores[1])
|
||||
assertEquals(0, after.count)
|
||||
assertTrue(after.series.isEmpty())
|
||||
assertTrue(after.saidGo.isEmpty())
|
||||
assertEquals("the seat after the last card leads", 1, after.turn)
|
||||
assertTrue(after.log.any { it.contains("says go") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `thirty-one pays two and nothing more for the go`() {
|
||||
val state = playing(
|
||||
hands = listOf(
|
||||
listOf(
|
||||
c(Rank.FIVE, Suit.SPADES), c(Rank.TWO, Suit.HEARTS),
|
||||
c(Rank.THREE, Suit.DIAMONDS), c(Rank.FOUR, Suit.CLUBS),
|
||||
),
|
||||
listOf(
|
||||
c(Rank.KING, Suit.CLUBS), c(Rank.QUEEN, Suit.SPADES),
|
||||
c(Rank.SIX, Suit.HEARTS), c(Rank.SEVEN, Suit.DIAMONDS),
|
||||
),
|
||||
),
|
||||
played = listOf(
|
||||
PeggedCard(0, c(Rank.FOUR, Suit.CLUBS)),
|
||||
PeggedCard(1, c(Rank.KING, Suit.CLUBS)),
|
||||
PeggedCard(0, c(Rank.TWO, Suit.HEARTS)),
|
||||
PeggedCard(1, c(Rank.QUEEN, Suit.SPADES)),
|
||||
),
|
||||
turn = 0,
|
||||
)
|
||||
assertEquals(26, state.count)
|
||||
|
||||
val after = CribbageRules.applyMove(state, 0, PegCard(c(Rank.FIVE, Suit.SPADES)))
|
||||
|
||||
assertEquals("two for thirty-one, and no point for the go on top", 2, after.scores[0])
|
||||
assertEquals(0, after.count)
|
||||
assertEquals(1, after.turn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pegging out ends the game in the middle of the play`() {
|
||||
val state = playing(
|
||||
hands = listOf(
|
||||
listOf(
|
||||
c(Rank.SEVEN, Suit.SPADES), c(Rank.SEVEN, Suit.HEARTS),
|
||||
c(Rank.NINE, Suit.DIAMONDS), c(Rank.EIGHT, Suit.CLUBS),
|
||||
),
|
||||
listOf(
|
||||
c(Rank.SEVEN, Suit.CLUBS), c(Rank.QUEEN, Suit.SPADES),
|
||||
c(Rank.TEN, Suit.DIAMONDS), c(Rank.NINE, Suit.HEARTS),
|
||||
),
|
||||
),
|
||||
played = listOf(
|
||||
PeggedCard(0, c(Rank.SEVEN, Suit.SPADES)),
|
||||
PeggedCard(1, c(Rank.SEVEN, Suit.CLUBS)),
|
||||
),
|
||||
scores = listOf(119, 60),
|
||||
turn = 0,
|
||||
)
|
||||
val after = CribbageRules.applyMove(state, 0, PegCard(c(Rank.SEVEN, Suit.HEARTS)))
|
||||
|
||||
assertEquals("pair royal", 125, after.scores[0])
|
||||
assertEquals(0, after.winner)
|
||||
assertTrue(CribbageRules.isFinished(after))
|
||||
assertNull(CribbageRules.currentSeat(after))
|
||||
assertTrue("the hands were never counted", after.show.isEmpty())
|
||||
}
|
||||
|
||||
// -- The show ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the hands are counted from the dealer's left with the crib last`() {
|
||||
var state = dealAndLayAway(
|
||||
CribbageRules.initialState(config(CribbageOptions(playerCount = 3), seed = 4L))
|
||||
)
|
||||
while (state.phase == CribbagePhase.PLAY) {
|
||||
val seat = CribbageRules.currentSeat(state)!!
|
||||
state = CribbageRules.applyMove(state, seat, CribbageRules.legalMoves(state, seat).first())
|
||||
}
|
||||
|
||||
assertEquals(CribbagePhase.SHOW, state.phase)
|
||||
assertEquals(4, state.show.size)
|
||||
assertEquals(listOf(1, 2, 0, 0), state.show.map { it.seat })
|
||||
assertEquals(listOf(false, false, false, true), state.show.map { it.isCrib })
|
||||
assertTrue(state.show.all { it.counted })
|
||||
assertEquals(state.crib, state.show.last().cards)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a hand counted out wins before the dealer ever counts`() {
|
||||
// Seat 0 is holding the twenty-nine hand and counts first.
|
||||
val state = playing(
|
||||
hands = listOf(
|
||||
listOf(
|
||||
c(Rank.FIVE, Suit.SPADES), c(Rank.FIVE, Suit.HEARTS),
|
||||
c(Rank.FIVE, Suit.DIAMONDS), c(Rank.JACK, Suit.CLUBS),
|
||||
),
|
||||
listOf(
|
||||
c(Rank.TWO, Suit.SPADES), c(Rank.THREE, Suit.HEARTS),
|
||||
c(Rank.FOUR, Suit.DIAMONDS), c(Rank.SIX, Suit.CLUBS),
|
||||
),
|
||||
),
|
||||
dealer = 1,
|
||||
starter = c(Rank.FIVE, Suit.CLUBS),
|
||||
crib = listOf(
|
||||
c(Rank.KING, Suit.SPADES), c(Rank.QUEEN, Suit.HEARTS),
|
||||
c(Rank.NINE, Suit.DIAMONDS), c(Rank.EIGHT, Suit.SPADES),
|
||||
),
|
||||
played = listOf(
|
||||
PeggedCard(0, c(Rank.FIVE, Suit.SPADES)),
|
||||
PeggedCard(1, c(Rank.TWO, Suit.SPADES)),
|
||||
PeggedCard(0, c(Rank.FIVE, Suit.HEARTS)),
|
||||
PeggedCard(1, c(Rank.THREE, Suit.HEARTS)),
|
||||
PeggedCard(0, c(Rank.FIVE, Suit.DIAMONDS)),
|
||||
PeggedCard(1, c(Rank.FOUR, Suit.DIAMONDS)),
|
||||
PeggedCard(1, c(Rank.SIX, Suit.CLUBS)),
|
||||
),
|
||||
scores = listOf(100, 100),
|
||||
turn = 0,
|
||||
).let { it.copy(series = listOf(PeggedCard(1, c(Rank.SIX, Suit.CLUBS)))) }
|
||||
|
||||
// Seat 0's last card ends the play and takes the table to the show.
|
||||
val after = CribbageRules.applyMove(state, 0, PegCard(c(Rank.JACK, Suit.CLUBS)))
|
||||
|
||||
assertEquals(CribbagePhase.GAME_OVER, after.phase)
|
||||
assertEquals(0, after.winner)
|
||||
assertEquals(3, after.show.size)
|
||||
assertTrue("the non-dealer counted", after.show[0].counted)
|
||||
assertFalse("the dealer never got to count", after.show[1].counted)
|
||||
assertFalse("nor did the crib", after.show[2].counted)
|
||||
assertEquals(29, after.show[0].total)
|
||||
}
|
||||
|
||||
// -- Whole games ---------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `random legal play always finishes, at every table size`() {
|
||||
listOf(2, 3, 4).forEach { players ->
|
||||
repeat(6) { iteration ->
|
||||
val random = Random(iteration.toLong())
|
||||
var state = CribbageRules.initialState(
|
||||
config(
|
||||
CribbageOptions(playerCount = players, pointsToWin = 61),
|
||||
seed = 700L + iteration,
|
||||
)
|
||||
)
|
||||
var guard = 0
|
||||
while (!CribbageRules.isFinished(state) && guard++ < 20000) {
|
||||
val seat = CribbageRules.currentSeat(state)
|
||||
if (seat == null) {
|
||||
state = CribbageRules.nextHand(state)
|
||||
continue
|
||||
}
|
||||
val legal = CribbageRules.legalMoves(state, seat)
|
||||
assertTrue("$players players: seat $seat had no legal move", legal.isNotEmpty())
|
||||
state = CribbageRules.applyMove(state, seat, legal[random.nextInt(legal.size)])
|
||||
}
|
||||
assertTrue("$players players, game $iteration hung", CribbageRules.isFinished(state))
|
||||
assertNotNull(state.winner)
|
||||
assertTrue(state.scores.max() >= 61)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nobody is ever carried past the target`() {
|
||||
// The game stops on the point that reaches it, so the winner's score is
|
||||
// the only one that may sit on or above the line.
|
||||
var state = CribbageRules.initialState(config(CribbageOptions(pointsToWin = 61), seed = 9L))
|
||||
val random = Random(9L)
|
||||
var guard = 0
|
||||
while (!CribbageRules.isFinished(state) && guard++ < 20000) {
|
||||
val seat = CribbageRules.currentSeat(state)
|
||||
if (seat == null) {
|
||||
state = CribbageRules.nextHand(state)
|
||||
continue
|
||||
}
|
||||
val legal = CribbageRules.legalMoves(state, seat)
|
||||
state = CribbageRules.applyMove(state, seat, legal[random.nextInt(legal.size)])
|
||||
}
|
||||
val winner = state.winner!!
|
||||
state.scores.forEachIndexed { team, score ->
|
||||
if (team != winner) assertTrue("team $team reached $score", score < 61)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the computer beats random play head to head`() {
|
||||
val ai = CribbageAi()
|
||||
var aiWins = 0
|
||||
var randomWins = 0
|
||||
|
||||
repeat(10) { iteration ->
|
||||
val random = Random(4000L + iteration)
|
||||
var state = CribbageRules.initialState(
|
||||
config(CribbageOptions(pointsToWin = 61), seed = 200L + iteration)
|
||||
)
|
||||
var guard = 0
|
||||
while (!CribbageRules.isFinished(state) && guard++ < 20000) {
|
||||
val seat = CribbageRules.currentSeat(state)
|
||||
if (seat == null) {
|
||||
state = CribbageRules.nextHand(state)
|
||||
continue
|
||||
}
|
||||
val legal = CribbageRules.legalMoves(state, seat)
|
||||
val move = if (seat == 0) {
|
||||
ai.chooseMove(state, seat, legal)
|
||||
} else {
|
||||
legal[random.nextInt(legal.size)]
|
||||
}
|
||||
assertTrue("the ai returned an illegal move $move", legal.contains(move))
|
||||
state = CribbageRules.applyMove(state, seat, move)
|
||||
}
|
||||
if (state.winner == 0) aiWins++ else randomWins++
|
||||
}
|
||||
|
||||
assertTrue("ai won $aiWins of ten against random play", aiWins > randomWins)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a partnership plays for its own crib and not just its own hand`() {
|
||||
// Four-handed, with the two AI seats facing each other. The lay-away is
|
||||
// where a partner's crib has to count as your own, so a side that
|
||||
// treated it as an opponent's would be giving away half its own cribs.
|
||||
val ai = CribbageAi()
|
||||
var aiWins = 0
|
||||
|
||||
repeat(8) { iteration ->
|
||||
val random = Random(900L + iteration)
|
||||
val options = CribbageOptions(playerCount = 4, pointsToWin = 61)
|
||||
var state = CribbageRules.initialState(config(options, seed = 500L + iteration))
|
||||
var guard = 0
|
||||
while (!CribbageRules.isFinished(state) && guard++ < 40000) {
|
||||
val seat = CribbageRules.currentSeat(state)
|
||||
if (seat == null) {
|
||||
state = CribbageRules.nextHand(state)
|
||||
continue
|
||||
}
|
||||
val legal = CribbageRules.legalMoves(state, seat)
|
||||
val move = if (options.teamOf(seat) == 0) {
|
||||
ai.chooseMove(state, seat, legal)
|
||||
} else {
|
||||
legal[random.nextInt(legal.size)]
|
||||
}
|
||||
state = CribbageRules.applyMove(state, seat, move)
|
||||
}
|
||||
if (state.winner == 0) aiWins++
|
||||
}
|
||||
|
||||
assertTrue("the ai partnership won $aiWins of eight", aiWins > 4)
|
||||
}
|
||||
|
||||
// -- Redaction and wire format ------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a seat view hides the other hands, the crib and the pack`() {
|
||||
val state = dealAndLayAway(CribbageRules.initialState(config(CribbageOptions(playerCount = 4))))
|
||||
val view = CribbageRules.viewFor(state, seat = 2)
|
||||
|
||||
assertEquals(state.hands[2], view.hands[2])
|
||||
assertTrue(view.hands.filterIndexed { seat, _ -> seat != 2 }.all { it.isEmpty() })
|
||||
assertTrue("the crib is nobody's business until the show", view.crib.isEmpty())
|
||||
assertTrue("the cut must not be knowable in advance", view.deck.isEmpty())
|
||||
assertEquals(CRIBBAGE_HAND_SIZE, view.cribCount)
|
||||
assertEquals(state.handCounts, view.handCounts)
|
||||
assertEquals(state.starter, view.starter)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `everything is turned face up at the show`() {
|
||||
var state = dealAndLayAway(CribbageRules.initialState(config(seed = 12L)))
|
||||
while (state.phase == CribbagePhase.PLAY) {
|
||||
val seat = CribbageRules.currentSeat(state)!!
|
||||
state = CribbageRules.applyMove(state, seat, CribbageRules.legalMoves(state, seat).first())
|
||||
}
|
||||
val view = CribbageRules.viewFor(state, seat = 0)
|
||||
assertEquals(state.hands, view.hands)
|
||||
assertEquals(state.crib, view.crib)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state and moves survive a round trip`() {
|
||||
val state = CribbageRules.initialState(config())
|
||||
assertEquals(state, CribbageRules.decodeState(CribbageRules.encodeState(state)))
|
||||
listOf(
|
||||
PegCard(c(Rank.KING, Suit.HEARTS)),
|
||||
LayAway.of(listOf(c(Rank.FIVE, Suit.SPADES), c(Rank.SIX, Suit.CLUBS))),
|
||||
).forEach {
|
||||
assertEquals(it, CribbageRules.decodeMove(CribbageRules.encodeMove(it)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `four play in partnerships and two or three play for themselves`() {
|
||||
val four = CribbageOptions(playerCount = 4)
|
||||
assertEquals(2, four.teamCount)
|
||||
assertEquals(listOf(0, 1, 0, 1), (0..3).map { four.teamOf(it) })
|
||||
|
||||
val three = CribbageOptions(playerCount = 3)
|
||||
assertEquals(3, three.teamCount)
|
||||
assertEquals(listOf(0, 1, 2), (0..2).map { three.teamOf(it) })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package org.prolibertate.games.game.cribbage
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Decks
|
||||
import org.prolibertate.games.game.cards.Rank
|
||||
import org.prolibertate.games.game.cards.Suit
|
||||
|
||||
class CribbageScoringTest {
|
||||
|
||||
private fun c(rank: Rank, suit: Suit) = Card(rank, suit)
|
||||
|
||||
private fun showTotal(hand: List<Card>, starter: Card, isCrib: Boolean = false): Int =
|
||||
CribbageScoring.show(hand, starter, isCrib).sumOf { it.points }
|
||||
|
||||
private fun pegTotal(vararg cards: Card): Int =
|
||||
CribbageScoring.peg(cards.toList()).sumOf { it.points }
|
||||
|
||||
// -- What a card is worth ------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `aces are one and every court card is ten`() {
|
||||
assertEquals(1, pipValue(c(Rank.ACE, Suit.SPADES)))
|
||||
assertEquals(10, pipValue(c(Rank.TEN, Suit.SPADES)))
|
||||
assertEquals(10, pipValue(c(Rank.JACK, Suit.SPADES)))
|
||||
assertEquals(10, pipValue(c(Rank.QUEEN, Suit.SPADES)))
|
||||
assertEquals(10, pipValue(c(Rank.KING, Suit.SPADES)))
|
||||
assertEquals(7, pipValue(c(Rank.SEVEN, Suit.SPADES)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the ace is low in a run and does not follow the king`() {
|
||||
assertEquals(1, runOrder(c(Rank.ACE, Suit.SPADES)))
|
||||
assertEquals(13, runOrder(c(Rank.KING, Suit.SPADES)))
|
||||
// A-2-3 is a run of three.
|
||||
val low = CribbageScoring.show(
|
||||
hand = listOf(
|
||||
c(Rank.ACE, Suit.SPADES),
|
||||
c(Rank.TWO, Suit.HEARTS),
|
||||
c(Rank.THREE, Suit.DIAMONDS),
|
||||
c(Rank.NINE, Suit.CLUBS),
|
||||
),
|
||||
starter = c(Rank.SEVEN, Suit.CLUBS),
|
||||
isCrib = false,
|
||||
)
|
||||
assertEquals(3, low.first { it.label == "Run of 3" }.points)
|
||||
|
||||
// Q-K-A is not.
|
||||
val high = CribbageScoring.show(
|
||||
hand = listOf(
|
||||
c(Rank.QUEEN, Suit.SPADES),
|
||||
c(Rank.KING, Suit.HEARTS),
|
||||
c(Rank.ACE, Suit.DIAMONDS),
|
||||
c(Rank.TWO, Suit.CLUBS),
|
||||
),
|
||||
starter = c(Rank.SEVEN, Suit.CLUBS),
|
||||
isCrib = false,
|
||||
)
|
||||
assertTrue(high.none { it.label.startsWith("Run") })
|
||||
}
|
||||
|
||||
// -- The show ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the best hand in cribbage is twenty-nine`() {
|
||||
// Three fives and the jack of the suit that is cut.
|
||||
val hand = listOf(
|
||||
c(Rank.FIVE, Suit.SPADES),
|
||||
c(Rank.FIVE, Suit.HEARTS),
|
||||
c(Rank.FIVE, Suit.DIAMONDS),
|
||||
c(Rank.JACK, Suit.CLUBS),
|
||||
)
|
||||
val lines = CribbageScoring.show(hand, c(Rank.FIVE, Suit.CLUBS), isCrib = false)
|
||||
assertEquals(29, lines.sumOf { it.points })
|
||||
assertEquals(16, lines.first { it.label.endsWith("fifteens") }.points)
|
||||
assertEquals(12, lines.first { it.label.startsWith("Double pair royal") }.points)
|
||||
assertEquals(1, lines.first { it.label == "His nob" }.points)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nineteen is no hand at all`() {
|
||||
val hand = listOf(
|
||||
c(Rank.KING, Suit.SPADES),
|
||||
c(Rank.QUEEN, Suit.HEARTS),
|
||||
c(Rank.EIGHT, Suit.DIAMONDS),
|
||||
c(Rank.SIX, Suit.CLUBS),
|
||||
)
|
||||
assertTrue(CribbageScoring.show(hand, c(Rank.TWO, Suit.CLUBS), false).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a doubled run counts twice over rather than lengthening`() {
|
||||
// 4-5-5-6 is two runs of three, not a run of four.
|
||||
val hand = listOf(
|
||||
c(Rank.FOUR, Suit.SPADES),
|
||||
c(Rank.FIVE, Suit.SPADES),
|
||||
c(Rank.FIVE, Suit.HEARTS),
|
||||
c(Rank.SIX, Suit.DIAMONDS),
|
||||
)
|
||||
val lines = CribbageScoring.show(hand, c(Rank.KING, Suit.CLUBS), isCrib = false)
|
||||
val run = lines.first { it.label.contains("runs of") }
|
||||
assertEquals("2 runs of 3", run.label)
|
||||
assertEquals(6, run.points)
|
||||
// Two 4-5-6 fifteens, two 5-king fifteens, and the pair of fives.
|
||||
assertEquals(16, lines.sumOf { it.points })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a hand takes four for its own suit and five with the starter`() {
|
||||
val flush = listOf(
|
||||
c(Rank.TWO, Suit.HEARTS),
|
||||
c(Rank.FOUR, Suit.HEARTS),
|
||||
c(Rank.NINE, Suit.HEARTS),
|
||||
c(Rank.KING, Suit.HEARTS),
|
||||
)
|
||||
assertEquals(
|
||||
ScoreLine("Flush of 4", 4),
|
||||
CribbageScoring.show(flush, c(Rank.SEVEN, Suit.SPADES), isCrib = false)
|
||||
.first { it.label.startsWith("Flush") },
|
||||
)
|
||||
assertEquals(
|
||||
ScoreLine("Flush of 5", 5),
|
||||
CribbageScoring.show(flush, c(Rank.SEVEN, Suit.HEARTS), isCrib = false)
|
||||
.first { it.label.startsWith("Flush") },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a crib takes nothing for four of a suit`() {
|
||||
val flush = listOf(
|
||||
c(Rank.TWO, Suit.HEARTS),
|
||||
c(Rank.FOUR, Suit.HEARTS),
|
||||
c(Rank.NINE, Suit.HEARTS),
|
||||
c(Rank.KING, Suit.HEARTS),
|
||||
)
|
||||
assertNull(
|
||||
CribbageScoring.show(flush, c(Rank.SEVEN, Suit.SPADES), isCrib = true)
|
||||
.firstOrNull { it.label.startsWith("Flush") }
|
||||
)
|
||||
assertEquals(
|
||||
ScoreLine("Flush of 5", 5),
|
||||
CribbageScoring.show(flush, c(Rank.SEVEN, Suit.HEARTS), isCrib = true)
|
||||
.first { it.label.startsWith("Flush") },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `his nob wants the jack in the hand and the suit that was cut`() {
|
||||
val withJack = listOf(
|
||||
c(Rank.JACK, Suit.HEARTS),
|
||||
c(Rank.TWO, Suit.SPADES),
|
||||
c(Rank.SEVEN, Suit.CLUBS),
|
||||
c(Rank.NINE, Suit.DIAMONDS),
|
||||
)
|
||||
assertEquals(
|
||||
ScoreLine("His nob", 1),
|
||||
CribbageScoring.show(withJack, c(Rank.THREE, Suit.HEARTS), isCrib = false)
|
||||
.first { it.label == "His nob" },
|
||||
)
|
||||
assertNull(
|
||||
CribbageScoring.show(withJack, c(Rank.THREE, Suit.SPADES), isCrib = false)
|
||||
.firstOrNull { it.label == "His nob" }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fifteens count every combination and not just pairs of cards`() {
|
||||
// 7-8 makes fifteen, and so does 2-3-4-6.
|
||||
val hand = listOf(
|
||||
c(Rank.SEVEN, Suit.SPADES),
|
||||
c(Rank.EIGHT, Suit.HEARTS),
|
||||
c(Rank.TWO, Suit.DIAMONDS),
|
||||
c(Rank.THREE, Suit.CLUBS),
|
||||
)
|
||||
val lines = CribbageScoring.show(hand, c(Rank.FOUR, Suit.CLUBS), isCrib = false)
|
||||
assertEquals(4, lines.first { it.label == "2 fifteens" }.points)
|
||||
assertEquals(3, lines.first { it.label == "Run of 3" }.points)
|
||||
assertEquals(7, lines.sumOf { it.points })
|
||||
}
|
||||
|
||||
// -- The play ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `fifteen and thirty-one are worth two apiece during the play`() {
|
||||
assertEquals(2, pegTotal(c(Rank.SEVEN, Suit.SPADES), c(Rank.EIGHT, Suit.HEARTS)))
|
||||
assertEquals(
|
||||
2,
|
||||
pegTotal(
|
||||
c(Rank.KING, Suit.SPADES),
|
||||
c(Rank.QUEEN, Suit.HEARTS),
|
||||
c(Rank.SIX, Suit.DIAMONDS),
|
||||
c(Rank.FIVE, Suit.CLUBS),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pairs peg off the end of the run and not through another card`() {
|
||||
assertEquals(2, pegTotal(c(Rank.SEVEN, Suit.SPADES), c(Rank.SEVEN, Suit.HEARTS)))
|
||||
assertEquals(
|
||||
6,
|
||||
pegTotal(
|
||||
c(Rank.SEVEN, Suit.SPADES),
|
||||
c(Rank.SEVEN, Suit.HEARTS),
|
||||
c(Rank.SEVEN, Suit.CLUBS),
|
||||
),
|
||||
)
|
||||
// Broken up by a three, so the sevens are no longer a pair.
|
||||
assertEquals(
|
||||
0,
|
||||
pegTotal(
|
||||
c(Rank.SEVEN, Suit.SPADES),
|
||||
c(Rank.THREE, Suit.HEARTS),
|
||||
c(Rank.SEVEN, Suit.CLUBS),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a run pegs in any order and lengthens as it is extended`() {
|
||||
assertEquals(
|
||||
3,
|
||||
pegTotal(
|
||||
c(Rank.FIVE, Suit.SPADES),
|
||||
c(Rank.THREE, Suit.HEARTS),
|
||||
c(Rank.FOUR, Suit.CLUBS),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
4,
|
||||
pegTotal(
|
||||
c(Rank.FIVE, Suit.SPADES),
|
||||
c(Rank.THREE, Suit.HEARTS),
|
||||
c(Rank.FOUR, Suit.CLUBS),
|
||||
c(Rank.SIX, Suit.DIAMONDS),
|
||||
),
|
||||
)
|
||||
// A repeated rank breaks the run: 5-3-4-4 is no run at all.
|
||||
assertEquals(
|
||||
2,
|
||||
pegTotal(
|
||||
c(Rank.FIVE, Suit.SPADES),
|
||||
c(Rank.THREE, Suit.HEARTS),
|
||||
c(Rank.FOUR, Suit.CLUBS),
|
||||
c(Rank.FOUR, Suit.DIAMONDS),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `twenty-nine really is the ceiling`() {
|
||||
// Exhaustive over a shortened pack — aces to fives and the jacks, which
|
||||
// is where every big hand lives. Nothing may beat 29, and 29 has to be
|
||||
// reachable, or the scoring has quietly stopped counting something.
|
||||
val pack = Decks.standard52().filter { runOrder(it) <= 5 || it.rank == Rank.JACK }
|
||||
var best = 0
|
||||
for (a in pack.indices) {
|
||||
for (b in a + 1 until pack.size) {
|
||||
for (d in b + 1 until pack.size) {
|
||||
for (e in d + 1 until pack.size) {
|
||||
val hand = listOf(pack[a], pack[b], pack[d], pack[e])
|
||||
for (starter in pack) {
|
||||
if (starter in hand) continue
|
||||
best = maxOf(best, showTotal(hand, starter))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(29, best)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package org.prolibertate.games.net
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
import org.prolibertate.games.game.engine.GameRules
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
|
||||
/**
|
||||
* The pause between hands, on a table with nobody but the local player at it.
|
||||
*
|
||||
* Cribbage's show, Golf's card and Wizard's bids-against-tricks all stop the
|
||||
* table on a scoreboard and wait to be released. The release has to be spent:
|
||||
* the state does not change while it is being read, so a controller that only
|
||||
* re-asks `awaitsConfirmation` about it stops on the same scoreboard again the
|
||||
* moment it is restarted, and the next hand is never dealt.
|
||||
*/
|
||||
class MatchControllerScoreboardTest {
|
||||
|
||||
private val scopes = mutableListOf<CoroutineScope>()
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
scopes.forEach { it.cancel() }
|
||||
}
|
||||
|
||||
/** A hand is one move long, and every hand ends on a scoreboard. */
|
||||
private data class Hands(val handNumber: Int, val scoring: Boolean)
|
||||
|
||||
private object Rules : GameRules<Hands, String> {
|
||||
override val gameId: String = "scoreboard"
|
||||
override fun initialState(config: TableConfig) = Hands(0, scoring = false)
|
||||
override fun currentSeat(state: Hands): Int? = if (state.scoring) null else 0
|
||||
override fun legalMoves(state: Hands, seat: Int): List<String> =
|
||||
if (state.scoring) emptyList() else listOf("play")
|
||||
|
||||
override fun applyMove(state: Hands, seat: Int, move: String) =
|
||||
state.copy(scoring = true)
|
||||
|
||||
override fun isFinished(state: Hands) = state.handNumber >= HANDS
|
||||
override fun summary(state: Hands) = state.toString()
|
||||
override fun viewFor(state: Hands, seat: Int) = state
|
||||
override fun encodeState(state: Hands) = "${state.handNumber}:${state.scoring}"
|
||||
override fun decodeState(json: String) = Hands(
|
||||
handNumber = json.substringBefore(':').toInt(),
|
||||
scoring = json.substringAfter(':').toBooleanStrict(),
|
||||
)
|
||||
|
||||
override fun encodeMove(move: String) = move
|
||||
override fun decodeMove(json: String) = json
|
||||
}
|
||||
|
||||
private fun controller(): MatchController<Hands, String> {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Default).also { scopes += it }
|
||||
return MatchController(
|
||||
rules = Rules,
|
||||
ai = object : GameAi<Hands, String> {
|
||||
override fun chooseMove(state: Hands, seat: Int, legal: List<String>) = legal.first()
|
||||
},
|
||||
config = TableConfig(
|
||||
gameId = "scoreboard",
|
||||
seats = listOf(PlayerSlot(0, "Me", PlayerKind.HUMAN_LOCAL, team = 0)),
|
||||
optionsJson = "{}",
|
||||
seed = 1L,
|
||||
),
|
||||
scope = scope,
|
||||
role = MatchController.Role.HOST,
|
||||
localSeats = setOf(0),
|
||||
primarySeat = 0,
|
||||
advanceIdle = { state ->
|
||||
if (state.scoring) {
|
||||
Hands(state.handNumber + 1, scoring = false)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
aiThinkingMillis = { 0L },
|
||||
awaitsConfirmation = { state -> state.scoring },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the table stops on a scoreboard`() = runBlocking {
|
||||
val table = controller()
|
||||
table.startAsHost(emptyList())
|
||||
settle()
|
||||
|
||||
assertFalse("nothing to read yet", table.awaitingConfirmation.value)
|
||||
table.submit("play")
|
||||
settle()
|
||||
|
||||
assertTrue("the scoreboard was skipped past", table.awaitingConfirmation.value)
|
||||
assertEquals(Hands(0, scoring = true), table.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirming it deals the next hand`() = runBlocking {
|
||||
val table = controller()
|
||||
table.startAsHost(emptyList())
|
||||
settle()
|
||||
table.submit("play")
|
||||
settle()
|
||||
|
||||
table.confirmAdvance()
|
||||
settle()
|
||||
|
||||
assertEquals(
|
||||
"the table is still sitting on the scoreboard it was told to leave",
|
||||
Hands(1, scoring = false),
|
||||
table.state.value,
|
||||
)
|
||||
assertFalse(table.awaitingConfirmation.value)
|
||||
assertTrue("and the next hand is playable", table.legalMoves.value.isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every hand in the game stops and is released in turn`() = runBlocking {
|
||||
val table = controller()
|
||||
table.startAsHost(emptyList())
|
||||
settle()
|
||||
|
||||
repeat(HANDS) { hand ->
|
||||
table.submit("play")
|
||||
settle()
|
||||
assertTrue("hand $hand did not stop to be read", table.awaitingConfirmation.value)
|
||||
table.confirmAdvance()
|
||||
settle()
|
||||
}
|
||||
|
||||
assertTrue(table.finished.value)
|
||||
assertEquals(HANDS, table.state.value?.handNumber)
|
||||
}
|
||||
|
||||
private suspend fun settle() = delay(SETTLE_MILLIS)
|
||||
|
||||
private companion object {
|
||||
const val HANDS = 3
|
||||
const val SETTLE_MILLIS = 250L
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user