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