Merge pull request #19: Yahtzee and Klondike
A dice game and the first card solitaire, both playable. The ace-low convention patience needs is now shared in game.solitaire rather than defined per game. Verified by ci.yml runs 90, 92, 93 and 94, all green.
This commit is contained in:
@@ -35,13 +35,11 @@ object GameCatalog {
|
||||
const val CRAZY_EIGHTS = "crazy8s"
|
||||
const val CRIBBAGE = "cribbage"
|
||||
|
||||
/**
|
||||
* Hearts has a rules engine and an AI but no screen yet, so it is an id
|
||||
* without a [GameDescriptor]: nothing to tap, and nothing to crash. It joins
|
||||
* [all] when its screen lands.
|
||||
*/
|
||||
const val HEARTS = "hearts"
|
||||
const val PEG_SOLITAIRE = "pegsolitaire"
|
||||
|
||||
const val YAHTZEE = "yahtzee"
|
||||
const val KLONDIKE = "klondike"
|
||||
const val SEQUENCE = "sequence"
|
||||
const val MASTERMIND = "mastermind"
|
||||
const val BACKGAMMON = "backgammon"
|
||||
@@ -135,6 +133,17 @@ object GameCatalog {
|
||||
"of spades costs thirteen, and taking all of them costs everybody else.",
|
||||
available = true,
|
||||
),
|
||||
GameDescriptor(
|
||||
id = KLONDIKE,
|
||||
title = "Klondike",
|
||||
category = GameCategory.CARD,
|
||||
minPlayers = 1,
|
||||
maxPlayers = 1,
|
||||
teamBased = false,
|
||||
blurb = "Patience, as everyone means it. Seven columns down to the aces, " +
|
||||
"and a pack that decides most of it before you start.",
|
||||
available = true,
|
||||
),
|
||||
GameDescriptor(
|
||||
id = SEQUENCE,
|
||||
title = "Sequence",
|
||||
@@ -209,6 +218,17 @@ object GameCatalog {
|
||||
"take the one you passed, and try to finish with one standing.",
|
||||
available = true,
|
||||
),
|
||||
GameDescriptor(
|
||||
id = YAHTZEE,
|
||||
title = "Yahtzee",
|
||||
category = GameCategory.BOARD,
|
||||
minPlayers = 1,
|
||||
maxPlayers = 6,
|
||||
teamBased = false,
|
||||
blurb = "Five dice, three throws, thirteen boxes to fill. Every box can be " +
|
||||
"written in once, so the hard part is what to give up on.",
|
||||
available = true,
|
||||
),
|
||||
GameDescriptor(
|
||||
id = PIRATES,
|
||||
title = "Pirates and Bulgars",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.prolibertate.games.game.klondike
|
||||
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
* Plays the deal the way a person does when they are not thinking hard.
|
||||
*
|
||||
* Nothing asks it to during an ordinary game — the only seat belongs to the
|
||||
* player — but a seat has to be playable by something, and this is what a
|
||||
* "suggest a move" button would offer.
|
||||
*
|
||||
* The order matters more than the numbers. Sending a card home always wins,
|
||||
* turning one up comes next, and aimless shuffling between columns scores below
|
||||
* drawing so it is only reached when there is nothing else at all. That last
|
||||
* ordering is what stops it walking a run back and forth between two columns for
|
||||
* ever.
|
||||
*/
|
||||
object KlondikeAi : GameAi<KlondikeState, KlondikeMove> {
|
||||
|
||||
override fun chooseMove(state: KlondikeState, seat: Int, legal: List<KlondikeMove>): KlondikeMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
return legal.maxBy { worth(state, it) }
|
||||
}
|
||||
|
||||
private fun worth(state: KlondikeState, move: KlondikeMove): Int = when (move) {
|
||||
Draw -> 30
|
||||
Redeal -> 20
|
||||
is MoveCards -> when {
|
||||
move.to.kind == SpotKind.FOUNDATION -> 100
|
||||
// Taking a card back off a foundation is occasionally right and
|
||||
// usually not, so it sits below doing nothing much at all.
|
||||
move.from.kind == SpotKind.FOUNDATION -> -50
|
||||
move.from.kind == SpotKind.TABLEAU && turnsACardUp(state, move) -> 80
|
||||
move.from.kind == SpotKind.TABLEAU && emptiesAColumn(state, move) -> 60
|
||||
move.from.kind == SpotKind.WASTE -> 40
|
||||
else -> 15
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the move strips a column back to its buried cards. */
|
||||
private fun turnsACardUp(state: KlondikeState, move: MoveCards): Boolean {
|
||||
val pile = state.tableau[move.from.index]
|
||||
return move.count == pile.faceUp.size && pile.faceDown.isNotEmpty()
|
||||
}
|
||||
|
||||
private fun emptiesAColumn(state: KlondikeState, move: MoveCards): Boolean {
|
||||
val pile = state.tableau[move.from.index]
|
||||
return move.count == pile.faceUp.size && pile.faceDown.isEmpty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package org.prolibertate.games.game.klondike
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
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.solitaire.buildsDownAlternating
|
||||
|
||||
const val TABLEAU_PILES: Int = 7
|
||||
const val FOUNDATIONS: Int = 4
|
||||
|
||||
/**
|
||||
* Where a card can be, and which one of them.
|
||||
*
|
||||
* A kind and an index rather than a sealed hierarchy: every spot in this game is
|
||||
* one of four things and at most one number identifies it, so a pair says
|
||||
* everything a subclass per kind would, and serialises as two plain fields.
|
||||
*/
|
||||
@Serializable
|
||||
enum class SpotKind { STOCK, WASTE, TABLEAU, FOUNDATION }
|
||||
|
||||
@Serializable
|
||||
data class Spot(val kind: SpotKind, val index: Int = 0) {
|
||||
companion object {
|
||||
val stock = Spot(SpotKind.STOCK)
|
||||
val waste = Spot(SpotKind.WASTE)
|
||||
fun tableau(index: Int) = Spot(SpotKind.TABLEAU, index)
|
||||
fun foundation(index: Int) = Spot(SpotKind.FOUNDATION, index)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A tableau pile: what is still turned down, and the run turned up on top of it.
|
||||
*
|
||||
* Kept as two lists because the split is the whole tension of the game. A pile
|
||||
* is only worth what has been turned up, and the buried part is the thing being
|
||||
* played for.
|
||||
*/
|
||||
@Serializable
|
||||
data class TableauPile(
|
||||
val faceDown: List<Card> = emptyList(),
|
||||
val faceUp: List<Card> = emptyList(),
|
||||
) {
|
||||
val isEmpty: Boolean get() = faceDown.isEmpty() && faceUp.isEmpty()
|
||||
val top: Card? get() = faceUp.lastOrNull()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class KlondikeOptions(
|
||||
/**
|
||||
* One card off the stock at a time, or three. Three is the harder game and
|
||||
* the older one; one is what most people mean by patience now.
|
||||
*/
|
||||
val drawCount: Int = 1,
|
||||
/**
|
||||
* How many times the waste may be turned back into the stock. Null is as
|
||||
* often as you like; zero is a single pass through the pack, which is the
|
||||
* gambling version.
|
||||
*/
|
||||
val redealLimit: Int? = null,
|
||||
/**
|
||||
* Only a king may be moved into an empty column. Turning this off is a
|
||||
* common kindness that makes far more deals winnable.
|
||||
*/
|
||||
val kingsOnlyInSpaces: Boolean = true,
|
||||
) {
|
||||
init {
|
||||
require(drawCount in 1..3) { "Draw one to three" }
|
||||
require(redealLimit == null || redealLimit >= 0) { "redealLimit cannot be negative" }
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class KlondikeState(
|
||||
val options: KlondikeOptions,
|
||||
val seed: Long,
|
||||
/** Face down, dealt from the end. */
|
||||
val stock: List<Card>,
|
||||
/** Face up; the last card is the one in play. */
|
||||
val waste: List<Card>,
|
||||
/** One per suit, in [Suit] order, each running ace upwards. */
|
||||
val foundations: List<List<Card>>,
|
||||
val tableau: List<TableauPile>,
|
||||
val redealsUsed: Int,
|
||||
val moves: Int,
|
||||
val log: List<String>,
|
||||
) {
|
||||
/** Every foundation full is the game won. */
|
||||
val isWon: Boolean get() = foundations.all { it.size == Rank.standard.size }
|
||||
|
||||
val canRedeal: Boolean
|
||||
get() = stock.isEmpty() &&
|
||||
waste.isNotEmpty() &&
|
||||
(options.redealLimit == null || redealsUsed < options.redealLimit)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed interface KlondikeMove
|
||||
|
||||
/** Turns the next card or three off the stock and onto the waste. */
|
||||
@Serializable
|
||||
@SerialName("draw")
|
||||
data object Draw : KlondikeMove
|
||||
|
||||
/** Turns the waste back over to make a new stock, once the stock is out. */
|
||||
@Serializable
|
||||
@SerialName("redeal")
|
||||
data object Redeal : KlondikeMove
|
||||
|
||||
/**
|
||||
* Moves [count] cards from one spot to another.
|
||||
*
|
||||
* [count] is only ever more than one when moving between tableau columns, where
|
||||
* a whole descending run travels together.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("move")
|
||||
data class MoveCards(val from: Spot, val to: Spot, val count: Int = 1) : KlondikeMove
|
||||
|
||||
/** Foundations are indexed by suit, so a card always knows which one it wants. */
|
||||
fun foundationFor(card: Card): Int = card.suit.ordinal
|
||||
|
||||
/**
|
||||
* Down by one and in the other colour.
|
||||
*
|
||||
* Named here because it is what this game builds with, but the rule itself is
|
||||
* shared: FreeCell builds the same way, Spider builds in suit, and all of them
|
||||
* need the same ace-low ordering underneath.
|
||||
*/
|
||||
fun buildsDown(lower: Card, onto: Card): Boolean = buildsDownAlternating(lower, onto)
|
||||
|
||||
/**
|
||||
* Whether [cards] is a run that can travel as one: each card a step below the
|
||||
* one before it and in the opposite colour.
|
||||
*/
|
||||
fun isMovableRun(cards: List<Card>): Boolean =
|
||||
cards.isNotEmpty() && cards.zipWithNext().all { (upper, lower) -> buildsDown(lower, upper) }
|
||||
|
||||
/** What an empty column will take. */
|
||||
fun acceptsInSpace(card: Card, kingsOnly: Boolean): Boolean =
|
||||
!kingsOnly || card.rank == Rank.KING
|
||||
|
||||
/** Suit order is the foundation order, so this names them for the screen. */
|
||||
fun foundationSuit(index: Int): Suit = Suit.entries[index]
|
||||
@@ -0,0 +1,250 @@
|
||||
package org.prolibertate.games.game.klondike
|
||||
|
||||
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.shuffledWith
|
||||
import org.prolibertate.games.game.engine.GameRules
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import org.prolibertate.games.game.solitaire.acceptsOnFoundation
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Klondike as a pure state machine. See RULES-klondike.md.
|
||||
*
|
||||
* One seat, like peg solitaire: the player is not playing against anybody, only
|
||||
* against the deal.
|
||||
*/
|
||||
object KlondikeRules : GameRules<KlondikeState, KlondikeMove> {
|
||||
|
||||
override val gameId: String = GameCatalog.KLONDIKE
|
||||
|
||||
const val SOLO_SEAT: Int = 0
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
override fun initialState(config: TableConfig): KlondikeState {
|
||||
val options = json.decodeFromString<KlondikeOptions>(config.optionsJson)
|
||||
require(config.seats.size == 1) { "Klondike seats one, not ${config.seats.size}" }
|
||||
return deal(options, config.seed)
|
||||
}
|
||||
|
||||
private fun deal(options: KlondikeOptions, seed: Long): KlondikeState {
|
||||
val deck = Decks.standard52().shuffledWith(Random(seed)).toMutableList()
|
||||
val piles = (0 until TABLEAU_PILES).map { index ->
|
||||
// Column n gets n+1 cards, and only the last of them is turned up.
|
||||
val taken = List(index + 1) { deck.removeAt(deck.size - 1) }
|
||||
TableauPile(faceDown = taken.dropLast(1), faceUp = listOf(taken.last()))
|
||||
}
|
||||
return KlondikeState(
|
||||
options = options,
|
||||
seed = seed,
|
||||
stock = deck.toList(),
|
||||
waste = emptyList(),
|
||||
foundations = List(FOUNDATIONS) { emptyList() },
|
||||
tableau = piles,
|
||||
redealsUsed = 0,
|
||||
moves = 0,
|
||||
log = listOf("Dealt. Drawing ${options.drawCount} at a time."),
|
||||
)
|
||||
}
|
||||
|
||||
override fun currentSeat(state: KlondikeState): Int? =
|
||||
if (isFinished(state)) null else SOLO_SEAT
|
||||
|
||||
override fun legalMoves(state: KlondikeState, seat: Int): List<KlondikeMove> {
|
||||
if (seat != SOLO_SEAT || state.isWon) return emptyList()
|
||||
val moves = mutableListOf<KlondikeMove>()
|
||||
if (state.stock.isNotEmpty()) moves += Draw
|
||||
if (state.canRedeal) moves += Redeal
|
||||
|
||||
state.waste.lastOrNull()?.let { card ->
|
||||
val foundation = foundationFor(card)
|
||||
if (acceptsOnFoundation(state.foundations[foundation], card)) {
|
||||
moves += MoveCards(Spot.waste, Spot.foundation(foundation))
|
||||
}
|
||||
state.tableau.indices.forEach { pile ->
|
||||
if (tableauAccepts(state, pile, card)) {
|
||||
moves += MoveCards(Spot.waste, Spot.tableau(pile))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.tableau.forEachIndexed { index, pile ->
|
||||
pile.top?.let { card ->
|
||||
val foundation = foundationFor(card)
|
||||
if (acceptsOnFoundation(state.foundations[foundation], card)) {
|
||||
moves += MoveCards(Spot.tableau(index), Spot.foundation(foundation))
|
||||
}
|
||||
}
|
||||
for (count in 1..pile.faceUp.size) {
|
||||
val run = pile.faceUp.takeLast(count)
|
||||
if (!isMovableRun(run)) continue
|
||||
val head = run.first()
|
||||
state.tableau.indices.forEach { target ->
|
||||
if (target == index || !tableauAccepts(state, target, head)) return@forEach
|
||||
// Shifting a whole column into an empty one gains nothing and
|
||||
// would let a game shuffle back and forth for ever, so it is
|
||||
// not offered as a move at all.
|
||||
val wholeColumn = pile.faceDown.isEmpty() && count == pile.faceUp.size
|
||||
if (wholeColumn && state.tableau[target].isEmpty) return@forEach
|
||||
moves += MoveCards(Spot.tableau(index), Spot.tableau(target), count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.foundations.forEachIndexed { index, foundation ->
|
||||
foundation.lastOrNull()?.let { card ->
|
||||
state.tableau.indices.forEach { pile ->
|
||||
if (tableauAccepts(state, pile, card)) {
|
||||
moves += MoveCards(Spot.foundation(index), Spot.tableau(pile))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return moves
|
||||
}
|
||||
|
||||
/** Whether column [index] will take [card] on top of it. */
|
||||
fun tableauAccepts(state: KlondikeState, index: Int, card: Card): Boolean {
|
||||
val pile = state.tableau[index]
|
||||
val top = pile.top ?: return acceptsInSpace(card, state.options.kingsOnlyInSpaces)
|
||||
return buildsDown(card, top)
|
||||
}
|
||||
|
||||
override fun applyMove(state: KlondikeState, seat: Int, move: KlondikeMove): KlondikeState {
|
||||
require(seat == SOLO_SEAT) { "Klondike has one seat" }
|
||||
require(legalMoves(state, seat).contains(move)) { "$move is not legal here" }
|
||||
val next = when (move) {
|
||||
Draw -> draw(state)
|
||||
Redeal -> redeal(state)
|
||||
is MoveCards -> moveCards(state, move)
|
||||
}
|
||||
val won = next.isWon
|
||||
return next.copy(
|
||||
moves = state.moves + 1,
|
||||
log = if (won) next.log + "Out, in ${next.moves + 1} moves." else next.log,
|
||||
)
|
||||
}
|
||||
|
||||
private fun draw(state: KlondikeState): KlondikeState {
|
||||
val count = minOf(state.options.drawCount, state.stock.size)
|
||||
val taken = state.stock.takeLast(count)
|
||||
return state.copy(
|
||||
stock = state.stock.dropLast(count),
|
||||
// Turning a packet over reverses it, so the card that was deepest of
|
||||
// the three is the one left showing.
|
||||
waste = state.waste + taken.reversed(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun redeal(state: KlondikeState): KlondikeState = state.copy(
|
||||
// The waste is turned face down as one, so its top card ends up at the
|
||||
// bottom of the new stock.
|
||||
stock = state.waste.reversed(),
|
||||
waste = emptyList(),
|
||||
redealsUsed = state.redealsUsed + 1,
|
||||
log = state.log + "Waste turned back over.",
|
||||
)
|
||||
|
||||
private fun moveCards(state: KlondikeState, move: MoveCards): KlondikeState {
|
||||
val cards = cardsAt(state, move.from, move.count)
|
||||
val without = removeFrom(state, move.from, move.count)
|
||||
return addTo(without, move.to, cards)
|
||||
}
|
||||
|
||||
private fun cardsAt(state: KlondikeState, from: Spot, count: Int): List<Card> = when (from.kind) {
|
||||
SpotKind.WASTE -> state.waste.takeLast(count)
|
||||
SpotKind.TABLEAU -> state.tableau[from.index].faceUp.takeLast(count)
|
||||
SpotKind.FOUNDATION -> state.foundations[from.index].takeLast(count)
|
||||
SpotKind.STOCK -> throw IllegalArgumentException("Cards are not taken from the stock by hand")
|
||||
}
|
||||
|
||||
private fun removeFrom(state: KlondikeState, from: Spot, count: Int): KlondikeState =
|
||||
when (from.kind) {
|
||||
SpotKind.WASTE -> state.copy(waste = state.waste.dropLast(count))
|
||||
|
||||
SpotKind.TABLEAU -> {
|
||||
val pile = state.tableau[from.index]
|
||||
val left = pile.faceUp.dropLast(count)
|
||||
// Emptying the turned-up run exposes whatever was under it, and
|
||||
// turning that card is the point of most of the game.
|
||||
val flipped = if (left.isEmpty() && pile.faceDown.isNotEmpty()) {
|
||||
TableauPile(
|
||||
faceDown = pile.faceDown.dropLast(1),
|
||||
faceUp = listOf(pile.faceDown.last()),
|
||||
)
|
||||
} else {
|
||||
pile.copy(faceUp = left)
|
||||
}
|
||||
state.copy(tableau = state.tableau.replacing(from.index, flipped))
|
||||
}
|
||||
|
||||
SpotKind.FOUNDATION -> state.copy(
|
||||
foundations = state.foundations.replacing(
|
||||
from.index,
|
||||
state.foundations[from.index].dropLast(count),
|
||||
),
|
||||
)
|
||||
|
||||
SpotKind.STOCK -> throw IllegalArgumentException("Nothing is taken off the stock by hand")
|
||||
}
|
||||
|
||||
private fun addTo(state: KlondikeState, to: Spot, cards: List<Card>): KlondikeState =
|
||||
when (to.kind) {
|
||||
SpotKind.TABLEAU -> {
|
||||
val pile = state.tableau[to.index]
|
||||
state.copy(
|
||||
tableau = state.tableau.replacing(
|
||||
to.index,
|
||||
pile.copy(faceUp = pile.faceUp + cards),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
SpotKind.FOUNDATION -> state.copy(
|
||||
foundations = state.foundations.replacing(
|
||||
to.index,
|
||||
state.foundations[to.index] + cards,
|
||||
),
|
||||
)
|
||||
|
||||
SpotKind.WASTE, SpotKind.STOCK ->
|
||||
throw IllegalArgumentException("Nothing is put back on the ${to.kind}")
|
||||
}
|
||||
|
||||
private fun <T> List<T>.replacing(index: Int, value: T): List<T> =
|
||||
toMutableList().also { it[index] = value }
|
||||
|
||||
/**
|
||||
* Won, or with nothing left to try.
|
||||
*
|
||||
* A game can dry up entirely: no stock, no redeal left, and not a card that
|
||||
* will go anywhere. That is a finish as much as winning is, and the screen
|
||||
* needs to be able to say so rather than leaving the player hunting.
|
||||
*/
|
||||
override fun isFinished(state: KlondikeState): Boolean =
|
||||
state.isWon || legalMoves(state, SOLO_SEAT).isEmpty()
|
||||
|
||||
override fun summary(state: KlondikeState): String = when {
|
||||
state.isWon -> "Out in ${state.moves} moves"
|
||||
isFinished(state) -> "Blocked, ${state.foundations.sumOf { it.size }} cards home"
|
||||
else -> "${state.foundations.sumOf { it.size }} of 52 home"
|
||||
}
|
||||
|
||||
/** One seat, and it is the player's, so there is nothing to strip. */
|
||||
override fun viewFor(state: KlondikeState, seat: Int): KlondikeState = state
|
||||
|
||||
override fun encodeState(state: KlondikeState): String = json.encodeToString(state)
|
||||
override fun decodeState(json: String): KlondikeState = this.json.decodeFromString(json)
|
||||
override fun encodeMove(move: KlondikeMove): String = json.encodeToString(move)
|
||||
override fun decodeMove(json: String): KlondikeMove = this.json.decodeFromString(json)
|
||||
|
||||
/** Deals the same pack again, so a lost game can be replayed from the start. */
|
||||
fun restart(state: KlondikeState): KlondikeState = deal(state.options, state.seed)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.prolibertate.games.game.solitaire
|
||||
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Rank
|
||||
|
||||
/**
|
||||
* Where a rank sits when the ace is low.
|
||||
*
|
||||
* [Rank.order] puts the ace at fourteen, which is right for the games this deck
|
||||
* was built for: in Euchre, Kaiser, Wizard and the rest it is the highest card
|
||||
* there is. Every patience in this app counts it the other way — foundations run
|
||||
* ace upwards, columns run down to an ace, and Pyramid needs it to be worth one
|
||||
* when a pair has to make thirteen.
|
||||
*
|
||||
* It lives here rather than on [Rank] because ace high is correct everywhere
|
||||
* outside these games, and it is shared rather than copied because four
|
||||
* separate definitions of "the ace is low" would be four chances to disagree.
|
||||
*/
|
||||
val Rank.patienceOrder: Int get() = if (this == Rank.ACE) 1 else order
|
||||
|
||||
/** Down by one, and in the other colour: the ordinary tableau build. */
|
||||
fun buildsDownAlternating(lower: Card, onto: Card): Boolean =
|
||||
lower.rank.patienceOrder == onto.rank.patienceOrder - 1 &&
|
||||
lower.suit.isRed != onto.suit.isRed
|
||||
|
||||
/** Down by one in the same suit, which is what Spider pays for. */
|
||||
fun buildsDownInSuit(lower: Card, onto: Card): Boolean =
|
||||
lower.rank.patienceOrder == onto.rank.patienceOrder - 1 && lower.suit == onto.suit
|
||||
|
||||
/** Ace first, then strictly upwards in the same suit. */
|
||||
fun acceptsOnFoundation(foundation: List<Card>, card: Card): Boolean {
|
||||
val top = foundation.lastOrNull() ?: return card.rank == Rank.ACE
|
||||
return card.suit == top.suit && card.rank.patienceOrder == top.rank.patienceOrder + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [cards] is a run that travels as one, by whatever rule the game
|
||||
* builds with.
|
||||
*/
|
||||
fun isRun(cards: List<Card>, builds: (Card, Card) -> Boolean): Boolean =
|
||||
cards.isNotEmpty() && cards.zipWithNext().all { (upper, lower) -> builds(lower, upper) }
|
||||
@@ -0,0 +1,86 @@
|
||||
package org.prolibertate.games.game.yahtzee
|
||||
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
* Plays the dice by eye rather than by arithmetic.
|
||||
*
|
||||
* It deliberately does not try each way of keeping and look at what came back.
|
||||
* The throw is a pure function of the state and what is held, so an AI that
|
||||
* enumerated the thirty-two subsets and applied them would be reading the roll
|
||||
* before making it — beating the player by seeing the future rather than by
|
||||
* playing well. Everything below decides from the dice already on the table.
|
||||
*/
|
||||
object YahtzeeAi : GameAi<YahtzeeState, YahtzeeMove> {
|
||||
|
||||
override fun chooseMove(state: YahtzeeState, seat: Int, legal: List<YahtzeeMove>): YahtzeeMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
val rolls = legal.filterIsInstance<RollDice>()
|
||||
val writes = legal.filterIsInstance<ScoreIn>()
|
||||
|
||||
// Nothing on the table yet, so there is only one thing to do.
|
||||
if (!state.hasRolled) return rolls.firstOrNull() ?: writes.first()
|
||||
|
||||
val best = writes.maxByOrNull { scoreOf(it.category, state.dice) }
|
||||
val bestScore = best?.let { scoreOf(it.category, state.dice) } ?: 0
|
||||
|
||||
// A hand already worth taking, or no rolls left, ends the turn.
|
||||
if (rolls.isEmpty()) return best ?: writes.first()
|
||||
if (bestScore >= WORTH_KEEPING) return best!!
|
||||
|
||||
val keep = whatToKeep(state.dice)
|
||||
return rolls.firstOrNull { it.keep.sorted() == keep.sorted() }
|
||||
?: rolls.first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Good enough to stop throwing. Set at a full house, which is the point
|
||||
* where a hand is worth more than an average reroll of it.
|
||||
*/
|
||||
private const val WORTH_KEEPING = 25
|
||||
|
||||
/**
|
||||
* What to hold on to: a run if one is nearly there, otherwise the face there
|
||||
* is most of. Holding the biggest group is what turns three of a kind into
|
||||
* four, and it is the whole of ordinary dice play.
|
||||
*/
|
||||
private fun whatToKeep(dice: List<Int>): List<Int> {
|
||||
val run = runIndices(dice)
|
||||
if (run.size >= 4) return run
|
||||
|
||||
val counts = countsOf(dice)
|
||||
val most = counts.entries
|
||||
.sortedWith(compareByDescending<Map.Entry<Int, Int>> { it.value }.thenByDescending { it.key })
|
||||
.first()
|
||||
if (most.value >= 2) {
|
||||
return dice.indices.filter { dice[it] == most.key }
|
||||
}
|
||||
// Five different faces and no run worth keeping: hold the big ones and
|
||||
// throw the rest.
|
||||
return dice.indices.filter { dice[it] >= 5 }
|
||||
}
|
||||
|
||||
/** Positions making up the longest run of consecutive faces, one die per face. */
|
||||
private fun runIndices(dice: List<Int>): List<Int> {
|
||||
val faces = dice.toSortedSet().toList()
|
||||
var bestStart = 0
|
||||
var bestLength = 0
|
||||
var start = 0
|
||||
for (i in faces.indices) {
|
||||
if (i > 0 && faces[i] != faces[i - 1] + 1) start = i
|
||||
val length = i - start + 1
|
||||
if (length > bestLength) {
|
||||
bestLength = length
|
||||
bestStart = start
|
||||
}
|
||||
}
|
||||
if (bestLength < 4) return emptyList()
|
||||
val wanted = faces.subList(bestStart, bestStart + bestLength).toSet()
|
||||
// One die per face: a second five adds nothing to a straight.
|
||||
val taken = mutableSetOf<Int>()
|
||||
return dice.indices.filter { index ->
|
||||
val face = dice[index]
|
||||
face in wanted && taken.add(face)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package org.prolibertate.games.game.yahtzee
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** Five dice, thirteen boxes, and thirteen turns to fill them. */
|
||||
const val DICE_COUNT: Int = 5
|
||||
const val ROLLS_PER_TURN: Int = 3
|
||||
|
||||
@Serializable
|
||||
enum class YahtzeeSection { UPPER, LOWER }
|
||||
|
||||
/**
|
||||
* The thirteen boxes on the card, in the order they are printed.
|
||||
*
|
||||
* The upper six score only the die they name, which is what the bonus is for:
|
||||
* three of each is exactly sixty-three, so the bonus rewards a card that did not
|
||||
* give up on any of them.
|
||||
*/
|
||||
@Serializable
|
||||
enum class YahtzeeCategory(val label: String, val section: YahtzeeSection) {
|
||||
ONES("Ones", YahtzeeSection.UPPER),
|
||||
TWOS("Twos", YahtzeeSection.UPPER),
|
||||
THREES("Threes", YahtzeeSection.UPPER),
|
||||
FOURS("Fours", YahtzeeSection.UPPER),
|
||||
FIVES("Fives", YahtzeeSection.UPPER),
|
||||
SIXES("Sixes", YahtzeeSection.UPPER),
|
||||
THREE_OF_A_KIND("Three of a kind", YahtzeeSection.LOWER),
|
||||
FOUR_OF_A_KIND("Four of a kind", YahtzeeSection.LOWER),
|
||||
FULL_HOUSE("Full house", YahtzeeSection.LOWER),
|
||||
SMALL_STRAIGHT("Small straight", YahtzeeSection.LOWER),
|
||||
LARGE_STRAIGHT("Large straight", YahtzeeSection.LOWER),
|
||||
YAHTZEE("Yahtzee", YahtzeeSection.LOWER),
|
||||
CHANCE("Chance", YahtzeeSection.LOWER),
|
||||
;
|
||||
|
||||
/** The face this box counts, for the upper six. */
|
||||
val face: Int? get() = if (section == YahtzeeSection.UPPER) ordinal + 1 else null
|
||||
}
|
||||
|
||||
/** Three of each of the upper six, which is what the bonus asks for. */
|
||||
const val UPPER_BONUS_AT: Int = 63
|
||||
const val UPPER_BONUS: Int = 35
|
||||
const val YAHTZEE_SCORE: Int = 50
|
||||
const val YAHTZEE_BONUS: Int = 100
|
||||
|
||||
@Serializable
|
||||
data class YahtzeeOptions(
|
||||
/** One plays it as a puzzle against the card; up to six round a table. */
|
||||
val playerCount: Int = 2,
|
||||
/**
|
||||
* A second Yahtzee is worth a hundred on top, once the Yahtzee box itself
|
||||
* holds fifty. Switched off, extra Yahtzees are only worth whatever box they
|
||||
* are written into.
|
||||
*/
|
||||
val yahtzeeBonus: Boolean = true,
|
||||
) {
|
||||
init {
|
||||
require(playerCount in 1..6) { "Yahtzee seats one to six" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One player's card.
|
||||
*
|
||||
* Boxes are held as a list indexed by [YahtzeeCategory.ordinal] rather than a
|
||||
* map, so what crosses the wire is thirteen slots in a fixed order and a box
|
||||
* that has never been filled is plainly null rather than absent.
|
||||
*/
|
||||
@Serializable
|
||||
data class YahtzeeCard(
|
||||
val boxes: List<Int?> = List(YahtzeeCategory.entries.size) { null },
|
||||
) {
|
||||
operator fun get(category: YahtzeeCategory): Int? = boxes[category.ordinal]
|
||||
|
||||
fun isFilled(category: YahtzeeCategory): Boolean = this[category] != null
|
||||
|
||||
val isComplete: Boolean get() = boxes.none { it == null }
|
||||
|
||||
fun with(category: YahtzeeCategory, score: Int): YahtzeeCard {
|
||||
require(!isFilled(category)) { "${category.label} is already written in" }
|
||||
return copy(boxes = boxes.toMutableList().also { it[category.ordinal] = score })
|
||||
}
|
||||
|
||||
val upperSubtotal: Int
|
||||
get() = YahtzeeCategory.entries
|
||||
.filter { it.section == YahtzeeSection.UPPER }
|
||||
.sumOf { this[it] ?: 0 }
|
||||
|
||||
val upperBonus: Int get() = if (upperSubtotal >= UPPER_BONUS_AT) UPPER_BONUS else 0
|
||||
|
||||
val lowerSubtotal: Int
|
||||
get() = YahtzeeCategory.entries
|
||||
.filter { it.section == YahtzeeSection.LOWER }
|
||||
.sumOf { this[it] ?: 0 }
|
||||
|
||||
/** Does not include Yahtzee bonuses, which are held on the state. */
|
||||
val total: Int get() = upperSubtotal + upperBonus + lowerSubtotal
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class YahtzeeState(
|
||||
val options: YahtzeeOptions,
|
||||
val seed: Long,
|
||||
/** Zero-based. Thirteen rounds fill thirteen boxes. */
|
||||
val round: Int,
|
||||
val turn: Int,
|
||||
/** Empty until the seat has rolled; five dice after that. */
|
||||
val dice: List<Int>,
|
||||
val rollsUsed: Int,
|
||||
val cards: List<YahtzeeCard>,
|
||||
/** Hundreds earned for extra Yahtzees, counted per seat. */
|
||||
val yahtzeeBonuses: List<Int>,
|
||||
val log: List<String>,
|
||||
) {
|
||||
val rollsLeft: Int get() = ROLLS_PER_TURN - rollsUsed
|
||||
|
||||
/** Nothing can be written in until the dice have been thrown at least once. */
|
||||
val hasRolled: Boolean get() = rollsUsed > 0
|
||||
|
||||
fun totalFor(seat: Int): Int = cards[seat].total + yahtzeeBonuses[seat]
|
||||
|
||||
val isOver: Boolean get() = cards.all { it.isComplete }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed interface YahtzeeMove
|
||||
|
||||
/**
|
||||
* Throws the dice, keeping the ones at [keep].
|
||||
*
|
||||
* Positions rather than faces: two fives are two different dice, and a player
|
||||
* who keeps one of them and rerolls the other is doing something a set of faces
|
||||
* could not express.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("roll")
|
||||
data class RollDice(val keep: List<Int> = emptyList()) : YahtzeeMove
|
||||
|
||||
@Serializable
|
||||
@SerialName("score")
|
||||
data class ScoreIn(val category: YahtzeeCategory) : YahtzeeMove
|
||||
|
||||
/** How many of each face, indexed one to six. */
|
||||
fun countsOf(dice: List<Int>): Map<Int, Int> = dice.groupingBy { it }.eachCount()
|
||||
|
||||
/** The longest run of consecutive faces present, however many of each. */
|
||||
fun longestRun(dice: List<Int>): Int {
|
||||
val present = dice.toSortedSet()
|
||||
var best = 0
|
||||
var run = 0
|
||||
var previous: Int? = null
|
||||
for (face in present) {
|
||||
run = if (previous != null && face == previous + 1) run + 1 else 1
|
||||
best = maxOf(best, run)
|
||||
previous = face
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* What [category] is worth for [dice].
|
||||
*
|
||||
* A full house is strictly three of one and two of another, so five of a kind
|
||||
* does not pay for it — that is the printed rule, and the joker variations that
|
||||
* change it are not played here.
|
||||
*/
|
||||
fun scoreOf(category: YahtzeeCategory, dice: List<Int>): Int {
|
||||
val counts = countsOf(dice)
|
||||
val sum = dice.sum()
|
||||
return when (category) {
|
||||
YahtzeeCategory.ONES, YahtzeeCategory.TWOS, YahtzeeCategory.THREES,
|
||||
YahtzeeCategory.FOURS, YahtzeeCategory.FIVES, YahtzeeCategory.SIXES,
|
||||
-> {
|
||||
val face = category.face!!
|
||||
face * (counts[face] ?: 0)
|
||||
}
|
||||
|
||||
YahtzeeCategory.THREE_OF_A_KIND -> if (counts.values.any { it >= 3 }) sum else 0
|
||||
YahtzeeCategory.FOUR_OF_A_KIND -> if (counts.values.any { it >= 4 }) sum else 0
|
||||
YahtzeeCategory.FULL_HOUSE ->
|
||||
if (counts.values.sorted() == listOf(2, 3)) 25 else 0
|
||||
|
||||
YahtzeeCategory.SMALL_STRAIGHT -> if (longestRun(dice) >= 4) 30 else 0
|
||||
YahtzeeCategory.LARGE_STRAIGHT -> if (longestRun(dice) >= 5) 40 else 0
|
||||
YahtzeeCategory.YAHTZEE -> if (counts.values.any { it == DICE_COUNT }) YAHTZEE_SCORE else 0
|
||||
YahtzeeCategory.CHANCE -> sum
|
||||
}
|
||||
}
|
||||
|
||||
fun isYahtzee(dice: List<Int>): Boolean =
|
||||
dice.size == DICE_COUNT && dice.toSet().size == 1
|
||||
@@ -0,0 +1,175 @@
|
||||
package org.prolibertate.games.game.yahtzee
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.engine.GameRules
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Yahtzee as a pure state machine. See RULES-yahtzee.md.
|
||||
*
|
||||
* The dice are thrown from the state rather than from a generator held on the
|
||||
* side: [applyMove] has to return the same thing every time it is handed the
|
||||
* same state and move, or the host and its clients would part company on the
|
||||
* first reroll. Seeding on the round, the seat, the rolls used and what was kept
|
||||
* gives a throw that is unpredictable to play against and identical to replay.
|
||||
*/
|
||||
object YahtzeeRules : GameRules<YahtzeeState, YahtzeeMove> {
|
||||
|
||||
override val gameId: String = GameCatalog.YAHTZEE
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
override fun initialState(config: TableConfig): YahtzeeState {
|
||||
val options = json.decodeFromString<YahtzeeOptions>(config.optionsJson)
|
||||
require(config.seats.size == options.playerCount) {
|
||||
"Seat count ${config.seats.size} does not match ${options.playerCount} players"
|
||||
}
|
||||
return YahtzeeState(
|
||||
options = options,
|
||||
seed = config.seed,
|
||||
round = 0,
|
||||
turn = 0,
|
||||
dice = emptyList(),
|
||||
rollsUsed = 0,
|
||||
cards = List(options.playerCount) { YahtzeeCard() },
|
||||
yahtzeeBonuses = List(options.playerCount) { 0 },
|
||||
log = listOf("Round 1."),
|
||||
)
|
||||
}
|
||||
|
||||
override fun currentSeat(state: YahtzeeState): Int? =
|
||||
if (state.isOver) null else state.turn
|
||||
|
||||
override fun legalMoves(state: YahtzeeState, seat: Int): List<YahtzeeMove> {
|
||||
if (state.isOver || state.turn != seat) return emptyList()
|
||||
val moves = mutableListOf<YahtzeeMove>()
|
||||
if (state.rollsUsed < ROLLS_PER_TURN) {
|
||||
// The first throw of a turn is all five dice: there is nothing on
|
||||
// the table yet to keep.
|
||||
moves += if (!state.hasRolled) {
|
||||
listOf(RollDice(emptyList()))
|
||||
} else {
|
||||
keepSubsets().map { RollDice(it) }
|
||||
}
|
||||
}
|
||||
if (state.hasRolled) {
|
||||
moves += YahtzeeCategory.entries
|
||||
.filterNot { state.cards[seat].isFilled(it) }
|
||||
.map { ScoreIn(it) }
|
||||
}
|
||||
return moves
|
||||
}
|
||||
|
||||
/** Every set of dice positions a player might hold back — thirty-two of them. */
|
||||
private fun keepSubsets(): List<List<Int>> =
|
||||
(0 until (1 shl DICE_COUNT)).map { mask ->
|
||||
(0 until DICE_COUNT).filter { mask and (1 shl it) != 0 }
|
||||
}
|
||||
|
||||
override fun applyMove(state: YahtzeeState, seat: Int, move: YahtzeeMove): YahtzeeState {
|
||||
require(!state.isOver) { "The game is over" }
|
||||
require(state.turn == seat) { "Seat $seat is not on the clock" }
|
||||
return when (move) {
|
||||
is RollDice -> applyRoll(state, move)
|
||||
is ScoreIn -> applyScore(state, seat, move)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyRoll(state: YahtzeeState, move: RollDice): YahtzeeState {
|
||||
require(state.rollsUsed < ROLLS_PER_TURN) { "No rolls left this turn" }
|
||||
require(move.keep.distinct().size == move.keep.size) { "Cannot keep a die twice" }
|
||||
require(move.keep.all { it in 0 until DICE_COUNT }) { "No such die" }
|
||||
require(state.hasRolled || move.keep.isEmpty()) { "Nothing to keep before the first roll" }
|
||||
return state.copy(
|
||||
dice = throwDice(state, move.keep),
|
||||
rollsUsed = state.rollsUsed + 1,
|
||||
)
|
||||
}
|
||||
|
||||
private fun throwDice(state: YahtzeeState, keep: List<Int>): List<Int> {
|
||||
val random = Random(
|
||||
state.seed +
|
||||
state.round * 10_000L +
|
||||
state.turn * 1_000L +
|
||||
state.rollsUsed * 100L +
|
||||
keep.sorted().hashCode(),
|
||||
)
|
||||
return (0 until DICE_COUNT).map { index ->
|
||||
if (keep.contains(index) && index < state.dice.size) {
|
||||
state.dice[index]
|
||||
} else {
|
||||
random.nextInt(1, 7)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyScore(state: YahtzeeState, seat: Int, move: ScoreIn): YahtzeeState {
|
||||
require(state.hasRolled) { "Roll before writing anything in" }
|
||||
val card = state.cards[seat]
|
||||
require(!card.isFilled(move.category)) { "${move.category.label} is already written in" }
|
||||
|
||||
val written = scoreOf(move.category, state.dice)
|
||||
// A second Yahtzee pays a hundred on top, but only once the box itself
|
||||
// holds the fifty — a zero written there earlier forfeits the bonus, as
|
||||
// the printed rule has it.
|
||||
val earnsBonus = state.options.yahtzeeBonus &&
|
||||
isYahtzee(state.dice) &&
|
||||
card[YahtzeeCategory.YAHTZEE] == YAHTZEE_SCORE
|
||||
val bonuses = if (!earnsBonus) {
|
||||
state.yahtzeeBonuses
|
||||
} else {
|
||||
state.yahtzeeBonuses.toMutableList().also { it[seat] = it[seat] + YAHTZEE_BONUS }
|
||||
}
|
||||
|
||||
val cards = state.cards.toMutableList().also { it[seat] = card.with(move.category, written) }
|
||||
val nextTurn = (seat + 1) % state.options.playerCount
|
||||
val wrapped = nextTurn == 0
|
||||
val round = if (wrapped) state.round + 1 else state.round
|
||||
val notes = buildList {
|
||||
add("Seat $seat wrote $written in ${move.category.label}.")
|
||||
if (earnsBonus) add("Seat $seat rolled another Yahtzee: $YAHTZEE_BONUS more.")
|
||||
if (cards.all { it.isComplete }) add("Cards full.") else if (wrapped) add("Round ${round + 1}.")
|
||||
}
|
||||
return state.copy(
|
||||
round = round,
|
||||
turn = nextTurn,
|
||||
// The dice go back in the cup for whoever is next.
|
||||
dice = emptyList(),
|
||||
rollsUsed = 0,
|
||||
cards = cards,
|
||||
yahtzeeBonuses = bonuses,
|
||||
log = state.log + notes,
|
||||
)
|
||||
}
|
||||
|
||||
override fun isFinished(state: YahtzeeState): Boolean = state.isOver
|
||||
|
||||
override fun summary(state: YahtzeeState): String = when {
|
||||
state.isOver -> {
|
||||
val totals = state.cards.indices.map { state.totalFor(it) }
|
||||
val best = totals.max()
|
||||
"Winner: seat ${totals.indexOf(best)} on $best"
|
||||
}
|
||||
|
||||
!state.hasRolled -> "Seat ${state.turn} to throw"
|
||||
else -> "Seat ${state.turn}, ${state.rollsLeft} rolls left"
|
||||
}
|
||||
|
||||
/**
|
||||
* Nothing is hidden. The dice are on the table and every card is face up —
|
||||
* Yahtzee is a game of what you do with a throw everyone watched, so there
|
||||
* is no private state to strip.
|
||||
*/
|
||||
override fun viewFor(state: YahtzeeState, seat: Int): YahtzeeState = state
|
||||
|
||||
override fun encodeState(state: YahtzeeState): String = json.encodeToString(state)
|
||||
override fun decodeState(json: String): YahtzeeState = this.json.decodeFromString(json)
|
||||
override fun encodeMove(move: YahtzeeMove): String = json.encodeToString(move)
|
||||
override fun decodeMove(json: String): YahtzeeMove = this.json.decodeFromString(json)
|
||||
}
|
||||
@@ -41,6 +41,8 @@ import org.prolibertate.games.game.cribbage.CribbageOptions
|
||||
import org.prolibertate.games.game.euchre.EuchreOptions
|
||||
import org.prolibertate.games.game.golf.GolfOptions
|
||||
import org.prolibertate.games.game.hearts.HeartsOptions
|
||||
import org.prolibertate.games.game.klondike.KlondikeOptions
|
||||
import org.prolibertate.games.game.yahtzee.YahtzeeOptions
|
||||
import org.prolibertate.games.game.pegsolitaire.PegBoard
|
||||
import org.prolibertate.games.game.pegsolitaire.PegGoal
|
||||
import org.prolibertate.games.game.pegsolitaire.PegSolitaireOptions
|
||||
@@ -94,6 +96,8 @@ fun GameSetupScreen(
|
||||
var pirates by remember { mutableStateOf(PiratesOptions()) }
|
||||
var hearts by remember { mutableStateOf(HeartsOptions()) }
|
||||
var pegs by remember { mutableStateOf(PegSolitaireOptions()) }
|
||||
var yahtzee by remember { mutableStateOf(YahtzeeOptions()) }
|
||||
var klondike by remember { mutableStateOf(KlondikeOptions()) }
|
||||
// Not an engine option: which seat the local player takes. Seat 0 is
|
||||
// always White, so choosing Black means sitting in seat 1.
|
||||
var playWhite by remember { mutableStateOf(true) }
|
||||
@@ -119,6 +123,8 @@ fun GameSetupScreen(
|
||||
GameCatalog.PIRATES -> setupJson.encodeToString(pirates)
|
||||
GameCatalog.HEARTS -> setupJson.encodeToString(hearts)
|
||||
GameCatalog.PEG_SOLITAIRE -> setupJson.encodeToString(pegs)
|
||||
GameCatalog.YAHTZEE -> setupJson.encodeToString(yahtzee)
|
||||
GameCatalog.KLONDIKE -> setupJson.encodeToString(klondike)
|
||||
else -> "{}"
|
||||
}
|
||||
val seatCount = seatCountFor(descriptor.id, optionsJson)
|
||||
@@ -174,6 +180,10 @@ fun GameSetupScreen(
|
||||
|
||||
GameCatalog.PEG_SOLITAIRE -> PegSolitaireOptionsEditor(pegs) { pegs = it }
|
||||
|
||||
GameCatalog.YAHTZEE -> YahtzeeOptionsEditor(yahtzee) { yahtzee = it }
|
||||
|
||||
GameCatalog.KLONDIKE -> KlondikeOptionsEditor(klondike) { klondike = it }
|
||||
|
||||
else -> Text("No options yet for this game.")
|
||||
}
|
||||
|
||||
@@ -272,6 +282,9 @@ fun seatCountFor(gameId: String, optionsJson: String): Int {
|
||||
GameCatalog.CHESS, GameCatalog.MORRIS, GameCatalog.CHECKERS,
|
||||
GameCatalog.BACKGAMMON, GameCatalog.MASTERMIND, GameCatalog.PIRATES -> 2
|
||||
|
||||
GameCatalog.YAHTZEE ->
|
||||
setupJson.decodeFromString<YahtzeeOptions>(optionsJson).playerCount
|
||||
|
||||
GameCatalog.TAYU ->
|
||||
setupJson.decodeFromString<TayuOptions>(optionsJson).playerCount
|
||||
|
||||
@@ -1012,3 +1025,56 @@ private fun PegSolitaireOptionsEditor(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun YahtzeeOptionsEditor(options: YahtzeeOptions, onChange: (YahtzeeOptions) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Players",
|
||||
values = (1..6).toList(),
|
||||
selected = options.playerCount,
|
||||
display = { if (it == 1) "On your own" else "$it" },
|
||||
onSelect = { onChange(options.copy(playerCount = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Second Yahtzee scores a hundred",
|
||||
subtitle = "Only once the Yahtzee box holds the fifty. A zero written there " +
|
||||
"earlier forfeits it, as the printed rule has it.",
|
||||
checked = options.yahtzeeBonus,
|
||||
onChange = { onChange(options.copy(yahtzeeBonus = it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KlondikeOptionsEditor(options: KlondikeOptions, onChange: (KlondikeOptions) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Turn from the stock",
|
||||
values = listOf(1, 3),
|
||||
selected = options.drawCount,
|
||||
display = { "$it at a time" },
|
||||
onSelect = { onChange(options.copy(drawCount = it)) },
|
||||
)
|
||||
ChipRow(
|
||||
label = "Turning the waste back over",
|
||||
values = listOf(null, 0, 1, 2),
|
||||
selected = options.redealLimit,
|
||||
display = {
|
||||
when (it) {
|
||||
null -> "As often as you like"
|
||||
0 -> "Never — one pass"
|
||||
else -> "$it times"
|
||||
}
|
||||
},
|
||||
onSelect = { onChange(options.copy(redealLimit = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Only a king fills a space",
|
||||
subtitle = "Turning this off lets any card into an empty column, which makes " +
|
||||
"far more deals winnable.",
|
||||
checked = options.kingsOnlyInSpaces,
|
||||
onChange = { onChange(options.copy(kingsOnlyInSpaces = it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,14 @@ import org.prolibertate.games.game.euchre.EuchreMove
|
||||
import org.prolibertate.games.game.euchre.EuchrePhase
|
||||
import org.prolibertate.games.game.euchre.EuchreRules
|
||||
import org.prolibertate.games.game.euchre.EuchreState
|
||||
import org.prolibertate.games.game.klondike.KlondikeAi
|
||||
import org.prolibertate.games.game.klondike.KlondikeMove
|
||||
import org.prolibertate.games.game.klondike.KlondikeRules
|
||||
import org.prolibertate.games.game.klondike.KlondikeState
|
||||
import org.prolibertate.games.game.yahtzee.YahtzeeAi
|
||||
import org.prolibertate.games.game.yahtzee.YahtzeeMove
|
||||
import org.prolibertate.games.game.yahtzee.YahtzeeRules
|
||||
import org.prolibertate.games.game.yahtzee.YahtzeeState
|
||||
import org.prolibertate.games.game.hearts.HeartsAi
|
||||
import org.prolibertate.games.game.hearts.HeartsMove
|
||||
import org.prolibertate.games.game.hearts.HeartsPhase
|
||||
@@ -94,12 +102,14 @@ import org.prolibertate.games.ui.game.ChessScreen
|
||||
import org.prolibertate.games.ui.game.CrazyEightsScreen
|
||||
import org.prolibertate.games.ui.game.CribbageScreen
|
||||
import org.prolibertate.games.ui.game.HeartsScreen
|
||||
import org.prolibertate.games.ui.game.KlondikeScreen
|
||||
import org.prolibertate.games.ui.game.EuchreScreen
|
||||
import org.prolibertate.games.ui.game.GolfScreen
|
||||
import org.prolibertate.games.ui.game.KaiserScreen
|
||||
import org.prolibertate.games.ui.game.MastermindScreen
|
||||
import org.prolibertate.games.ui.game.MorrisScreen
|
||||
import org.prolibertate.games.ui.game.PegSolitaireScreen
|
||||
import org.prolibertate.games.ui.game.YahtzeeScreen
|
||||
import org.prolibertate.games.ui.game.PiratesScreen
|
||||
import org.prolibertate.games.ui.game.PresidentScreen
|
||||
import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS
|
||||
@@ -505,6 +515,50 @@ fun PlayScreen(
|
||||
)
|
||||
}
|
||||
|
||||
GameCatalog.YAHTZEE -> {
|
||||
val controller = remember(route) {
|
||||
MatchController<YahtzeeState, YahtzeeMove>(
|
||||
rules = YahtzeeRules,
|
||||
ai = YahtzeeAi,
|
||||
config = route.config,
|
||||
scope = scope,
|
||||
role = role,
|
||||
localSeats = setOf(route.localSeat),
|
||||
primarySeat = route.localSeat,
|
||||
// Long enough to watch the dice land, short enough that a
|
||||
// six-handed game is not an evening of waiting.
|
||||
aiThinkingMillis = { settings.scaled(500L) },
|
||||
)
|
||||
}
|
||||
StartMatch(env, controller, route)
|
||||
YahtzeeScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||
}
|
||||
|
||||
GameCatalog.KLONDIKE -> {
|
||||
// Dealing again rebuilds the controller, for the same reason peg
|
||||
// solitaire does: a blocked deal is genuinely finished, so
|
||||
// driveIdleSeats has already stopped and advanceIdle never runs.
|
||||
var generation by remember(route) { mutableIntStateOf(0) }
|
||||
val controller = remember(route, generation) {
|
||||
MatchController<KlondikeState, KlondikeMove>(
|
||||
rules = KlondikeRules,
|
||||
// Only ever asked for a hint; the seat is the player's.
|
||||
ai = KlondikeAi,
|
||||
config = route.config,
|
||||
scope = scope,
|
||||
role = role,
|
||||
localSeats = setOf(route.localSeat),
|
||||
primarySeat = route.localSeat,
|
||||
)
|
||||
}
|
||||
StartMatch(env, controller, route)
|
||||
KlondikeScreen(
|
||||
controller = controller,
|
||||
onRestart = { generation++ },
|
||||
onExit = onExit,
|
||||
)
|
||||
}
|
||||
|
||||
GameCatalog.MORRIS -> {
|
||||
val controller = remember(route) {
|
||||
MatchController<MorrisState, MorrisMove>(
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.prolibertate.games.game.klondike.Draw
|
||||
import org.prolibertate.games.game.klondike.KlondikeMove
|
||||
import org.prolibertate.games.game.klondike.KlondikeState
|
||||
import org.prolibertate.games.game.klondike.MoveCards
|
||||
import org.prolibertate.games.game.klondike.Redeal
|
||||
import org.prolibertate.games.game.klondike.Spot
|
||||
import org.prolibertate.games.game.klondike.foundationSuit
|
||||
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.Parchment
|
||||
import org.prolibertate.games.ui.theme.WallaceGold
|
||||
|
||||
private const val TITLE = "Klondike"
|
||||
private val CARD_WIDTH: Dp = 40.dp
|
||||
|
||||
/**
|
||||
* The patience table.
|
||||
*
|
||||
* Play is tap-to-take then tap-to-put, rather than dragging: on a phone a drag
|
||||
* over seven columns of overlapping cards is mostly a test of aim. Tapping a
|
||||
* face-up card takes it and everything sitting on it, so a run travels the way
|
||||
* it would on a table.
|
||||
*
|
||||
* Which taps do anything is read straight off the legal moves the rules offer,
|
||||
* so the screen never has to know why a move is allowed — only that it is.
|
||||
*/
|
||||
@Composable
|
||||
fun KlondikeScreen(
|
||||
controller: MatchController<KlondikeState, KlondikeMove>,
|
||||
onRestart: () -> Unit,
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val legal by controller.legalMoves.collectAsState()
|
||||
val abandoned by controller.abandoned.collectAsState()
|
||||
|
||||
var held by remember { mutableStateOf<Pair<Spot, Int>?>(null) }
|
||||
var confirmingEnd by remember { mutableStateOf(false) }
|
||||
|
||||
val endGameAction: @Composable () -> Unit = {
|
||||
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
|
||||
}
|
||||
|
||||
val current = state
|
||||
if (current == null) {
|
||||
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
|
||||
abandoned?.let { LeftTableDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) EndTableDialog(onExit) { confirmingEnd = false }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val moves = legal.filterIsInstance<MoveCards>()
|
||||
val canDraw = legal.contains(Draw)
|
||||
val canRedeal = legal.contains(Redeal)
|
||||
val finished = legal.isEmpty()
|
||||
|
||||
// Where the cards in hand could go, and which spots could be picked up.
|
||||
val targets = held?.let { (from, count) ->
|
||||
moves.filter { it.from == from && it.count == count }.map { it.to }.toSet()
|
||||
}.orEmpty()
|
||||
val sources = moves.map { it.from to it.count }.toSet()
|
||||
|
||||
fun take(spot: Spot, count: Int) {
|
||||
val inHand = held
|
||||
held = when {
|
||||
inHand != null && targets.contains(spot) -> {
|
||||
controller.submit(MoveCards(inHand.first, spot, inHand.second))
|
||||
null
|
||||
}
|
||||
|
||||
inHand?.first == spot -> null
|
||||
sources.contains(spot to count) -> spot to count
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||
Column(
|
||||
modifier = modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = when {
|
||||
current.isWon -> "Out, in ${current.moves} moves."
|
||||
finished -> "Blocked — nothing left to try."
|
||||
held != null -> "Tap where it goes."
|
||||
else -> "${current.foundations.sumOf { it.size }} of 52 home."
|
||||
},
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
// Stock, waste, and the four homes across the top.
|
||||
Slot(
|
||||
highlighted = false,
|
||||
onClick = { if (canDraw) controller.submit(Draw) else if (canRedeal) controller.submit(Redeal) },
|
||||
) {
|
||||
if (current.stock.isNotEmpty()) {
|
||||
CardBackView(width = CARD_WIDTH)
|
||||
} else {
|
||||
Empty(label = if (canRedeal) "↻" else "")
|
||||
}
|
||||
}
|
||||
Slot(
|
||||
highlighted = held?.first == Spot.waste,
|
||||
onClick = { take(Spot.waste, 1) },
|
||||
) {
|
||||
current.waste.lastOrNull()
|
||||
?.let { PlayingCardView(card = it, width = CARD_WIDTH) }
|
||||
?: Empty()
|
||||
}
|
||||
current.foundations.forEachIndexed { index, pile ->
|
||||
Slot(
|
||||
highlighted = targets.contains(Spot.foundation(index)),
|
||||
onClick = { take(Spot.foundation(index), 1) },
|
||||
) {
|
||||
pile.lastOrNull()
|
||||
?.let { PlayingCardView(card = it, width = CARD_WIDTH) }
|
||||
?: Empty(label = foundationSuit(index).symbol)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
current.tableau.forEachIndexed { index, pile ->
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
if (pile.isEmpty) {
|
||||
Slot(
|
||||
highlighted = targets.contains(Spot.tableau(index)),
|
||||
onClick = { take(Spot.tableau(index), 1) },
|
||||
) { Empty() }
|
||||
}
|
||||
pile.faceDown.forEach { CardBackView(width = CARD_WIDTH) }
|
||||
pile.faceUp.forEachIndexed { depth, card ->
|
||||
// Taking a card takes everything resting on it, so
|
||||
// the count is however many are below it in the run.
|
||||
val count = pile.faceUp.size - depth
|
||||
Slot(
|
||||
highlighted = held == (Spot.tableau(index) to count) ||
|
||||
(depth == pile.faceUp.lastIndex && targets.contains(Spot.tableau(index))),
|
||||
onClick = { take(Spot.tableau(index), count) },
|
||||
) { PlayingCardView(card = card, width = CARD_WIDTH) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (finished) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = onRestart) { Text("Deal again") }
|
||||
TextButton(onClick = onExit) { Text("Leave") }
|
||||
}
|
||||
}
|
||||
|
||||
current.log.takeLast(2).forEach {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
|
||||
abandoned?.let { LeftTableDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) EndTableDialog(onExit) { confirmingEnd = false }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Slot(highlighted: Boolean, onClick: () -> Unit, content: @Composable () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(CARD_WIDTH)
|
||||
.border(
|
||||
border = BorderStroke(if (highlighted) 3.dp else 0.dp, WallaceGold),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
)
|
||||
.clickable { onClick() },
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
/** An empty place on the table, drawn so it can still be tapped into. */
|
||||
@Composable
|
||||
private fun Empty(label: String = "") {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(CARD_WIDTH)
|
||||
.aspectRatio(0.7f)
|
||||
.background(FeltGreenDark, RoundedCornerShape(4.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (label.isNotEmpty()) {
|
||||
Text(label, color = Parchment.copy(alpha = 0.5f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EndTableDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onKeepPlaying,
|
||||
title = { Text("End this game?") },
|
||||
text = { Text("The deal is left as it stands.") },
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LeftTableDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onStay,
|
||||
title = { Text("The game has ended") },
|
||||
text = { Text(notice) },
|
||||
confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } },
|
||||
dismissButton = { TextButton(onClick = onStay) { Text("Look at the table") } },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Divider
|
||||
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.mutableStateListOf
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.prolibertate.games.game.yahtzee.DICE_COUNT
|
||||
import org.prolibertate.games.game.yahtzee.RollDice
|
||||
import org.prolibertate.games.game.yahtzee.ScoreIn
|
||||
import org.prolibertate.games.game.yahtzee.YahtzeeCategory
|
||||
import org.prolibertate.games.game.yahtzee.YahtzeeMove
|
||||
import org.prolibertate.games.game.yahtzee.YahtzeeSection
|
||||
import org.prolibertate.games.game.yahtzee.YahtzeeState
|
||||
import org.prolibertate.games.game.yahtzee.scoreOf
|
||||
import org.prolibertate.games.net.MatchController
|
||||
import org.prolibertate.games.ui.ScreenScaffold
|
||||
import org.prolibertate.games.ui.theme.Parchment
|
||||
import org.prolibertate.games.ui.theme.WallaceGold
|
||||
import org.prolibertate.games.ui.theme.WallaceRed
|
||||
|
||||
private const val TITLE = "Yahtzee"
|
||||
|
||||
/**
|
||||
* The Yahtzee card.
|
||||
*
|
||||
* A turn is two decisions and the screen shows them together: which dice to keep
|
||||
* before throwing again, and which box to write in when the throwing stops. Each
|
||||
* open box shows what the dice on the table would score in it, because that
|
||||
* comparison is the whole of the choice — and doing the arithmetic by eye is not
|
||||
* what anyone wants from a phone.
|
||||
*/
|
||||
@Composable
|
||||
fun YahtzeeScreen(
|
||||
controller: MatchController<YahtzeeState, YahtzeeMove>,
|
||||
localSeat: Int,
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val legal by controller.legalMoves.collectAsState()
|
||||
val abandoned by controller.abandoned.collectAsState()
|
||||
|
||||
val kept = remember { mutableStateListOf<Int>() }
|
||||
var confirmingEnd by remember { mutableStateOf(false) }
|
||||
|
||||
val endGameAction: @Composable () -> Unit = {
|
||||
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
|
||||
}
|
||||
|
||||
val current = state
|
||||
if (current == null) {
|
||||
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||
Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") }
|
||||
abandoned?.let { LeftCardDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) EndCardDialog(onExit) { confirmingEnd = false }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val yours = current.turn == localSeat && !current.isOver
|
||||
val canRoll = legal.any { it is RollDice }
|
||||
val writable = legal.filterIsInstance<ScoreIn>().map { it.category }.toSet()
|
||||
|
||||
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||
Column(
|
||||
modifier = modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = when {
|
||||
current.isOver -> "Cards full."
|
||||
!yours -> "Seat ${current.turn} is throwing…"
|
||||
!current.hasRolled -> "Your turn — throw the dice."
|
||||
else -> "${current.rollsLeft} throws left, or write it down."
|
||||
},
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
DiceRow(
|
||||
dice = current.dice,
|
||||
kept = kept,
|
||||
enabled = yours && current.hasRolled && canRoll,
|
||||
onToggle = { index ->
|
||||
if (kept.contains(index)) kept.remove(index) else kept.add(index)
|
||||
},
|
||||
)
|
||||
|
||||
if (yours && canRoll) {
|
||||
Button(
|
||||
onClick = {
|
||||
controller.submit(RollDice(kept.toList()))
|
||||
// What was kept applied to the throw just made; the next
|
||||
// one starts from whatever is on the table now.
|
||||
kept.clear()
|
||||
},
|
||||
) {
|
||||
Text(if (current.hasRolled) "Throw the rest" else "Throw")
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
ScoreCard(
|
||||
state = current,
|
||||
localSeat = localSeat,
|
||||
writable = if (yours) writable else emptySet(),
|
||||
onWrite = {
|
||||
controller.submit(ScoreIn(it))
|
||||
kept.clear()
|
||||
},
|
||||
)
|
||||
|
||||
current.log.takeLast(3).forEach {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
|
||||
abandoned?.let { LeftCardDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) EndCardDialog(onExit) { confirmingEnd = false }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiceRow(
|
||||
dice: List<Int>,
|
||||
kept: List<Int>,
|
||||
enabled: Boolean,
|
||||
onToggle: (Int) -> Unit,
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (dice.isEmpty()) {
|
||||
repeat(DICE_COUNT) { Die(face = null, held = false, enabled = false) {} }
|
||||
} else {
|
||||
dice.forEachIndexed { index, face ->
|
||||
Die(
|
||||
face = face,
|
||||
held = kept.contains(index),
|
||||
enabled = enabled,
|
||||
onClick = { onToggle(index) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Die(face: Int?, held: Boolean, enabled: Boolean, onClick: () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.background(if (face == null) Parchment.copy(alpha = 0.3f) else Parchment, RoundedCornerShape(8.dp))
|
||||
.border(
|
||||
width = if (held) 3.dp else 1.dp,
|
||||
color = if (held) WallaceGold else WallaceRed.copy(alpha = 0.4f),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
)
|
||||
.clickable(enabled = enabled) { onClick() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = face?.toString() ?: "·",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = WallaceRed,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScoreCard(
|
||||
state: YahtzeeState,
|
||||
localSeat: Int,
|
||||
writable: Set<YahtzeeCategory>,
|
||||
onWrite: (YahtzeeCategory) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text("", modifier = Modifier.weight(2f), style = MaterialTheme.typography.labelSmall)
|
||||
state.cards.indices.forEach { seat ->
|
||||
Text(
|
||||
text = if (seat == localSeat) "You" else "S$seat",
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
YahtzeeSection.entries.forEach { section ->
|
||||
YahtzeeCategory.entries.filter { it.section == section }.forEach { category ->
|
||||
CardRow(state, localSeat, category, writable, onWrite)
|
||||
}
|
||||
if (section == YahtzeeSection.UPPER) {
|
||||
TotalRow("Bonus at 63", state.cards.map { it.upperBonus })
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
TotalRow("Total", state.cards.indices.map { state.totalFor(it) }, bold = true)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CardRow(
|
||||
state: YahtzeeState,
|
||||
localSeat: Int,
|
||||
category: YahtzeeCategory,
|
||||
writable: Set<YahtzeeCategory>,
|
||||
onWrite: (YahtzeeCategory) -> Unit,
|
||||
) {
|
||||
val open = writable.contains(category)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = open) { onWrite(category) }
|
||||
.padding(vertical = 2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = category.label,
|
||||
modifier = Modifier.weight(2f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = if (open) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
state.cards.forEachIndexed { seat, card ->
|
||||
val written = card[category]
|
||||
Text(
|
||||
text = when {
|
||||
written != null -> "$written"
|
||||
// What this throw would be worth here, which is the only
|
||||
// number the decision actually turns on.
|
||||
seat == localSeat && open -> "(${scoreOf(category, state.dice)})"
|
||||
else -> "—"
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (written == null && open) WallaceRed else MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TotalRow(label: String, values: List<Int>, bold: Boolean = false) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.weight(2f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
values.forEach {
|
||||
Text(
|
||||
text = "$it",
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EndCardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onKeepPlaying,
|
||||
title = { Text("End this game?") },
|
||||
text = { Text("The game ends here. Anyone else at the table is told you have left.") },
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LeftCardDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onStay,
|
||||
title = { Text("The game has ended") },
|
||||
text = { Text(notice) },
|
||||
confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } },
|
||||
dismissButton = { TextButton(onClick = onStay) { Text("Look at the card") } },
|
||||
)
|
||||
}
|
||||
@@ -73,6 +73,8 @@ class CatalogTest {
|
||||
GameCatalog.CRIBBAGE,
|
||||
GameCatalog.HEARTS,
|
||||
GameCatalog.PEG_SOLITAIRE,
|
||||
GameCatalog.YAHTZEE,
|
||||
GameCatalog.KLONDIKE,
|
||||
GameCatalog.WIZARD,
|
||||
GameCatalog.CHESS,
|
||||
GameCatalog.TAYU,
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
package org.prolibertate.games.game.klondike
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
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 org.prolibertate.games.game.solitaire.acceptsOnFoundation
|
||||
import org.prolibertate.games.game.solitaire.patienceOrder
|
||||
|
||||
class KlondikeRulesTest {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private fun config(options: KlondikeOptions = KlondikeOptions(), seed: Long = 5L) = TableConfig(
|
||||
gameId = "klondike",
|
||||
seats = listOf(PlayerSlot(seat = 0, name = "P0", kind = PlayerKind.HUMAN_LOCAL, team = 0)),
|
||||
optionsJson = json.encodeToString(options),
|
||||
seed = seed,
|
||||
)
|
||||
|
||||
private fun c(rank: Rank, suit: Suit) = Card(rank, suit)
|
||||
|
||||
// ---- the deal ----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the deal is seven columns and a stock of twenty-four`() {
|
||||
val state = KlondikeRules.initialState(config())
|
||||
assertEquals(TABLEAU_PILES, state.tableau.size)
|
||||
state.tableau.forEachIndexed { index, pile ->
|
||||
assertEquals("column ${index + 1}", index + 1, pile.faceDown.size + pile.faceUp.size)
|
||||
assertEquals("only the last card is turned up", 1, pile.faceUp.size)
|
||||
}
|
||||
assertEquals(24, state.stock.size)
|
||||
assertTrue(state.waste.isEmpty())
|
||||
assertTrue(state.foundations.all { it.isEmpty() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the pack is dealt whole and no card is dealt twice`() {
|
||||
val state = KlondikeRules.initialState(config())
|
||||
val all = state.stock + state.waste + state.tableau.flatMap { it.faceDown + it.faceUp }
|
||||
assertEquals(52, all.size)
|
||||
assertEquals(52, all.distinct().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a table for more than one seat is refused`() {
|
||||
val two = TableConfig(
|
||||
gameId = "klondike",
|
||||
seats = (0..1).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
|
||||
optionsJson = json.encodeToString(KlondikeOptions()),
|
||||
seed = 1L,
|
||||
)
|
||||
assertThrows { KlondikeRules.initialState(two) }
|
||||
}
|
||||
|
||||
// ---- stock and waste ---------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `drawing one turns one card up`() {
|
||||
val state = KlondikeRules.initialState(config(KlondikeOptions(drawCount = 1)))
|
||||
val drawn = KlondikeRules.applyMove(state, 0, Draw)
|
||||
assertEquals(23, drawn.stock.size)
|
||||
assertEquals(1, drawn.waste.size)
|
||||
assertEquals("the top of the stock is the card turned", state.stock.last(), drawn.waste.last())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drawing three leaves the deepest of them showing`() {
|
||||
// Turning a packet of three over reverses it, so the card that was
|
||||
// furthest down is the one in play.
|
||||
val state = KlondikeRules.initialState(config(KlondikeOptions(drawCount = 3)))
|
||||
val drawn = KlondikeRules.applyMove(state, 0, Draw)
|
||||
assertEquals(21, drawn.stock.size)
|
||||
assertEquals(3, drawn.waste.size)
|
||||
val top3 = state.stock.takeLast(3)
|
||||
assertEquals(top3.reversed(), drawn.waste)
|
||||
assertEquals(top3.first(), drawn.waste.last())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the waste turns back over into a new stock`() {
|
||||
var state = KlondikeRules.initialState(config(KlondikeOptions(drawCount = 3)))
|
||||
repeat(8) { state = KlondikeRules.applyMove(state, 0, Draw) }
|
||||
assertTrue(state.stock.isEmpty())
|
||||
assertEquals(24, state.waste.size)
|
||||
assertTrue(state.canRedeal)
|
||||
|
||||
val wasteBefore = state.waste
|
||||
val turned = KlondikeRules.applyMove(state, 0, Redeal)
|
||||
assertEquals(wasteBefore.reversed(), turned.stock)
|
||||
assertTrue(turned.waste.isEmpty())
|
||||
assertEquals(1, turned.redealsUsed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a redeal limit of zero means one pass through the pack`() {
|
||||
var state = KlondikeRules.initialState(
|
||||
config(KlondikeOptions(drawCount = 3, redealLimit = 0)),
|
||||
)
|
||||
repeat(8) { state = KlondikeRules.applyMove(state, 0, Draw) }
|
||||
assertFalse("no turning it back over", state.canRedeal)
|
||||
assertFalse(KlondikeRules.legalMoves(state, 0).contains(Redeal))
|
||||
}
|
||||
|
||||
// ---- building ----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the ace is low here, whatever the rest of the deck thinks`() {
|
||||
// Rank.order puts the ace at fourteen, because the deck was built for
|
||||
// trick-taking games where it is the highest card there is. Patience
|
||||
// counts it as one, and every comparison in this game has to agree —
|
||||
// otherwise a two can never follow an ace home and the game is
|
||||
// unwinnable in a way nothing else would show.
|
||||
assertEquals(14, Rank.ACE.order)
|
||||
assertEquals(1, Rank.ACE.patienceOrder)
|
||||
assertEquals(13, Rank.KING.patienceOrder)
|
||||
|
||||
var foundation = emptyList<Card>()
|
||||
Rank.standard.sortedBy { it.patienceOrder }.forEach { rank ->
|
||||
val card = c(rank, Suit.SPADES)
|
||||
assertTrue("$rank should go home next", acceptsOnFoundation(foundation, card))
|
||||
foundation = foundation + card
|
||||
}
|
||||
assertEquals(13, foundation.size)
|
||||
|
||||
// And a column runs the other way, down to an ace.
|
||||
assertTrue(buildsDown(c(Rank.ACE, Suit.HEARTS), c(Rank.TWO, Suit.SPADES)))
|
||||
assertTrue(buildsDown(c(Rank.QUEEN, Suit.HEARTS), c(Rank.KING, Suit.SPADES)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `foundations run ace upwards in one suit`() {
|
||||
assertTrue(acceptsOnFoundation(emptyList(), c(Rank.ACE, Suit.SPADES)))
|
||||
assertFalse(acceptsOnFoundation(emptyList(), c(Rank.TWO, Suit.SPADES)))
|
||||
|
||||
val ace = listOf(c(Rank.ACE, Suit.SPADES))
|
||||
assertTrue(acceptsOnFoundation(ace, c(Rank.TWO, Suit.SPADES)))
|
||||
assertFalse("wrong suit", acceptsOnFoundation(ace, c(Rank.TWO, Suit.HEARTS)))
|
||||
assertFalse("skips a rank", acceptsOnFoundation(ace, c(Rank.THREE, Suit.SPADES)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `columns build down and in the other colour`() {
|
||||
assertTrue(buildsDown(c(Rank.NINE, Suit.HEARTS), c(Rank.TEN, Suit.SPADES)))
|
||||
assertTrue(buildsDown(c(Rank.NINE, Suit.DIAMONDS), c(Rank.TEN, Suit.CLUBS)))
|
||||
assertFalse("same colour", buildsDown(c(Rank.NINE, Suit.HEARTS), c(Rank.TEN, Suit.DIAMONDS)))
|
||||
assertFalse("wrong way up", buildsDown(c(Rank.JACK, Suit.HEARTS), c(Rank.TEN, Suit.SPADES)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a run travels only if it is a run`() {
|
||||
assertTrue(
|
||||
isMovableRun(
|
||||
listOf(
|
||||
c(Rank.TEN, Suit.SPADES),
|
||||
c(Rank.NINE, Suit.HEARTS),
|
||||
c(Rank.EIGHT, Suit.CLUBS),
|
||||
),
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
"colours repeat",
|
||||
isMovableRun(listOf(c(Rank.TEN, Suit.SPADES), c(Rank.NINE, Suit.CLUBS))),
|
||||
)
|
||||
assertTrue("one card is a run", isMovableRun(listOf(c(Rank.TEN, Suit.SPADES))))
|
||||
assertFalse(isMovableRun(emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty column takes a king, or anything if the table says so`() {
|
||||
assertTrue(acceptsInSpace(c(Rank.KING, Suit.SPADES), kingsOnly = true))
|
||||
assertFalse(acceptsInSpace(c(Rank.QUEEN, Suit.SPADES), kingsOnly = true))
|
||||
assertTrue(acceptsInSpace(c(Rank.TWO, Suit.SPADES), kingsOnly = false))
|
||||
}
|
||||
|
||||
// ---- moving ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `emptying a column turns the card underneath up`() {
|
||||
val base = KlondikeRules.initialState(config())
|
||||
// Column 1 holds one buried card and a red six; column 2's black seven
|
||||
// will take it.
|
||||
val state = base.copy(
|
||||
tableau = base.tableau.toMutableList().also {
|
||||
it[1] = TableauPile(
|
||||
faceDown = listOf(c(Rank.ACE, Suit.CLUBS)),
|
||||
faceUp = listOf(c(Rank.SIX, Suit.HEARTS)),
|
||||
)
|
||||
it[2] = TableauPile(faceUp = listOf(c(Rank.SEVEN, Suit.SPADES)))
|
||||
},
|
||||
)
|
||||
val move = MoveCards(Spot.tableau(1), Spot.tableau(2))
|
||||
assertTrue(KlondikeRules.legalMoves(state, 0).contains(move))
|
||||
|
||||
val after = KlondikeRules.applyMove(state, 0, move)
|
||||
assertEquals(listOf(c(Rank.ACE, Suit.CLUBS)), after.tableau[1].faceUp)
|
||||
assertTrue("nothing left buried", after.tableau[1].faceDown.isEmpty())
|
||||
assertEquals(
|
||||
listOf(c(Rank.SEVEN, Suit.SPADES), c(Rank.SIX, Suit.HEARTS)),
|
||||
after.tableau[2].faceUp,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shifting a whole column into an empty one is not offered`() {
|
||||
// It gains nothing and would let a game shuffle back and forth for ever.
|
||||
val base = KlondikeRules.initialState(config())
|
||||
val state = base.copy(
|
||||
tableau = base.tableau.toMutableList().also {
|
||||
it[0] = TableauPile(faceUp = listOf(c(Rank.KING, Suit.SPADES)))
|
||||
it[1] = TableauPile()
|
||||
},
|
||||
)
|
||||
assertFalse(
|
||||
KlondikeRules.legalMoves(state, 0)
|
||||
.contains(MoveCards(Spot.tableau(0), Spot.tableau(1))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an illegal move is refused rather than applied`() {
|
||||
val state = KlondikeRules.initialState(config())
|
||||
// Nothing is ever taken off the stock by hand, and an empty foundation
|
||||
// has nothing to give — neither depends on how the pack fell.
|
||||
assertThrows {
|
||||
KlondikeRules.applyMove(state, 0, MoveCards(Spot.stock, Spot.tableau(0)))
|
||||
}
|
||||
assertThrows {
|
||||
KlondikeRules.applyMove(state, 0, MoveCards(Spot.foundation(0), Spot.tableau(0)))
|
||||
}
|
||||
assertThrows { KlondikeRules.applyMove(state, 1, Draw) }
|
||||
}
|
||||
|
||||
// ---- finishing ---------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `four full foundations is out`() {
|
||||
val base = KlondikeRules.initialState(config())
|
||||
val full = Suit.entries.map { suit -> Rank.standard.map { Card(it, suit) } }
|
||||
val won = base.copy(foundations = full, tableau = List(TABLEAU_PILES) { TableauPile() })
|
||||
assertTrue(won.isWon)
|
||||
assertTrue(KlondikeRules.isFinished(won))
|
||||
assertNull(KlondikeRules.currentSeat(won))
|
||||
assertTrue(KlondikeRules.summary(won).contains("Out"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a deal with nothing left to try is finished too`() {
|
||||
val base = KlondikeRules.initialState(config())
|
||||
// No stock, no waste, and two columns that cannot touch each other.
|
||||
val stuck = base.copy(
|
||||
stock = emptyList(),
|
||||
waste = emptyList(),
|
||||
tableau = List(TABLEAU_PILES) { index ->
|
||||
when (index) {
|
||||
0 -> TableauPile(faceUp = listOf(c(Rank.FIVE, Suit.SPADES)))
|
||||
1 -> TableauPile(faceUp = listOf(c(Rank.NINE, Suit.CLUBS)))
|
||||
else -> TableauPile(faceUp = listOf(c(Rank.SEVEN, Suit.CLUBS)))
|
||||
}
|
||||
},
|
||||
)
|
||||
assertFalse(stuck.isWon)
|
||||
assertTrue(KlondikeRules.isFinished(stuck))
|
||||
assertTrue(KlondikeRules.summary(stuck).contains("Blocked"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a deal can be played a long way without the rules complaining`() {
|
||||
var state = KlondikeRules.initialState(config())
|
||||
var guard = 400
|
||||
while (!KlondikeRules.isFinished(state) && guard-- > 0) {
|
||||
val legal = KlondikeRules.legalMoves(state, 0)
|
||||
state = KlondikeRules.applyMove(state, 0, KlondikeAi.chooseMove(state, 0, legal))
|
||||
// Every card is somewhere, and only ever in one place.
|
||||
val all = state.stock + state.waste +
|
||||
state.foundations.flatten() +
|
||||
state.tableau.flatMap { it.faceDown + it.faceUp }
|
||||
assertEquals("no card lost or copied", 52, all.size)
|
||||
assertEquals(52, all.distinct().size)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- housekeeping ------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `restart deals the same pack again`() {
|
||||
val state = KlondikeRules.initialState(config())
|
||||
val played = KlondikeRules.applyMove(state, 0, Draw)
|
||||
val again = KlondikeRules.restart(played)
|
||||
assertEquals(state.tableau, again.tableau)
|
||||
assertEquals(state.stock, again.stock)
|
||||
assertEquals(0, again.moves)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing is hidden from the only player`() {
|
||||
val state = KlondikeRules.initialState(config())
|
||||
assertEquals(state, KlondikeRules.viewFor(state, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state and moves survive a round trip`() {
|
||||
val state = KlondikeRules.applyMove(KlondikeRules.initialState(config()), 0, Draw)
|
||||
assertEquals(state, KlondikeRules.decodeState(KlondikeRules.encodeState(state)))
|
||||
|
||||
listOf<KlondikeMove>(
|
||||
Draw,
|
||||
Redeal,
|
||||
MoveCards(Spot.waste, Spot.foundation(2)),
|
||||
MoveCards(Spot.tableau(3), Spot.tableau(5), 4),
|
||||
).forEach {
|
||||
assertEquals(it, KlondikeRules.decodeMove(KlondikeRules.encodeMove(it)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertThrows(block: () -> Unit) {
|
||||
try {
|
||||
block()
|
||||
throw AssertionError("expected the rules to reject that")
|
||||
} catch (expected: IllegalArgumentException) {
|
||||
// Rejected, which is the point.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package org.prolibertate.games.game.yahtzee
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
|
||||
class YahtzeeRulesTest {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private fun config(options: YahtzeeOptions = YahtzeeOptions(), seed: Long = 11L) = TableConfig(
|
||||
gameId = "yahtzee",
|
||||
seats = (0 until options.playerCount).map {
|
||||
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
|
||||
},
|
||||
optionsJson = json.encodeToString(options),
|
||||
seed = seed,
|
||||
)
|
||||
|
||||
// ---- scoring -----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the upper six count only the die they name`() {
|
||||
val dice = listOf(3, 3, 5, 1, 3)
|
||||
assertEquals(1, scoreOf(YahtzeeCategory.ONES, dice))
|
||||
assertEquals(0, scoreOf(YahtzeeCategory.TWOS, dice))
|
||||
assertEquals(9, scoreOf(YahtzeeCategory.THREES, dice))
|
||||
assertEquals(5, scoreOf(YahtzeeCategory.FIVES, dice))
|
||||
assertEquals(0, scoreOf(YahtzeeCategory.SIXES, dice))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `three and four of a kind pay the whole throw`() {
|
||||
val three = listOf(4, 4, 4, 2, 6)
|
||||
assertEquals(20, scoreOf(YahtzeeCategory.THREE_OF_A_KIND, three))
|
||||
assertEquals(0, scoreOf(YahtzeeCategory.FOUR_OF_A_KIND, three))
|
||||
|
||||
val four = listOf(4, 4, 4, 4, 6)
|
||||
assertEquals(22, scoreOf(YahtzeeCategory.THREE_OF_A_KIND, four))
|
||||
assertEquals(22, scoreOf(YahtzeeCategory.FOUR_OF_A_KIND, four))
|
||||
|
||||
assertEquals(0, scoreOf(YahtzeeCategory.THREE_OF_A_KIND, listOf(1, 2, 3, 4, 5)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a full house is three of one and two of another, and nothing else`() {
|
||||
assertEquals(25, scoreOf(YahtzeeCategory.FULL_HOUSE, listOf(2, 2, 5, 5, 5)))
|
||||
assertEquals(0, scoreOf(YahtzeeCategory.FULL_HOUSE, listOf(2, 2, 2, 5, 6)))
|
||||
// Five of a kind is not a full house on the printed card, whatever the
|
||||
// joker variations do with it.
|
||||
assertEquals(0, scoreOf(YahtzeeCategory.FULL_HOUSE, listOf(4, 4, 4, 4, 4)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `straights want consecutive faces, and a repeat does not break them`() {
|
||||
assertEquals(4, longestRun(listOf(1, 2, 3, 4, 4)))
|
||||
assertEquals(5, longestRun(listOf(2, 3, 4, 5, 6)))
|
||||
assertEquals(1, longestRun(listOf(3, 3, 3, 3, 3)))
|
||||
|
||||
assertEquals(30, scoreOf(YahtzeeCategory.SMALL_STRAIGHT, listOf(1, 2, 3, 4, 4)))
|
||||
assertEquals(0, scoreOf(YahtzeeCategory.LARGE_STRAIGHT, listOf(1, 2, 3, 4, 4)))
|
||||
assertEquals(40, scoreOf(YahtzeeCategory.LARGE_STRAIGHT, listOf(3, 4, 5, 6, 2)))
|
||||
// A large straight is a small one as well.
|
||||
assertEquals(30, scoreOf(YahtzeeCategory.SMALL_STRAIGHT, listOf(3, 4, 5, 6, 2)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `five of a kind is fifty, and chance is whatever is showing`() {
|
||||
assertEquals(50, scoreOf(YahtzeeCategory.YAHTZEE, listOf(6, 6, 6, 6, 6)))
|
||||
assertEquals(0, scoreOf(YahtzeeCategory.YAHTZEE, listOf(6, 6, 6, 6, 1)))
|
||||
assertEquals(19, scoreOf(YahtzeeCategory.CHANCE, listOf(6, 6, 4, 2, 1)))
|
||||
assertTrue(isYahtzee(listOf(2, 2, 2, 2, 2)))
|
||||
assertFalse(isYahtzee(listOf(2, 2, 2, 2, 3)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the upper bonus arrives at sixty-three`() {
|
||||
var card = YahtzeeCard()
|
||||
// Exactly three of each is sixty-three, which is what the bonus is for.
|
||||
YahtzeeCategory.entries.filter { it.section == YahtzeeSection.UPPER }.forEach {
|
||||
card = card.with(it, it.face!! * 3)
|
||||
}
|
||||
assertEquals(63, card.upperSubtotal)
|
||||
assertEquals(35, card.upperBonus)
|
||||
|
||||
var short = YahtzeeCard()
|
||||
YahtzeeCategory.entries.filter { it.section == YahtzeeSection.UPPER }.forEach {
|
||||
short = short.with(it, it.face!! * 2)
|
||||
}
|
||||
assertEquals(42, short.upperSubtotal)
|
||||
assertEquals(0, short.upperBonus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a box can only be written in once`() {
|
||||
val card = YahtzeeCard().with(YahtzeeCategory.CHANCE, 20)
|
||||
assertTrue(card.isFilled(YahtzeeCategory.CHANCE))
|
||||
assertFalse(card.isFilled(YahtzeeCategory.ONES))
|
||||
try {
|
||||
card.with(YahtzeeCategory.CHANCE, 5)
|
||||
throw AssertionError("expected a rejection")
|
||||
} catch (expected: IllegalArgumentException) {
|
||||
// As intended.
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the state machine -------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a turn opens with one thing to do, and it is throwing`() {
|
||||
val state = YahtzeeRules.initialState(config())
|
||||
assertEquals(0, YahtzeeRules.currentSeat(state))
|
||||
val legal = YahtzeeRules.legalMoves(state, 0)
|
||||
assertEquals(listOf(RollDice(emptyList())), legal)
|
||||
// Nothing may be written in before the dice have been thrown.
|
||||
assertThrows { YahtzeeRules.applyMove(state, 0, ScoreIn(YahtzeeCategory.CHANCE)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a throw puts five dice on the table`() {
|
||||
val state = YahtzeeRules.initialState(config())
|
||||
val rolled = YahtzeeRules.applyMove(state, 0, RollDice())
|
||||
assertEquals(DICE_COUNT, rolled.dice.size)
|
||||
assertTrue(rolled.dice.all { it in 1..6 })
|
||||
assertEquals(1, rolled.rollsUsed)
|
||||
assertEquals(2, rolled.rollsLeft)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same state and move always throw the same dice`() {
|
||||
// The host and every client run applyMove separately; a throw that
|
||||
// differed between them would split the game in two.
|
||||
val state = YahtzeeRules.initialState(config())
|
||||
val once = YahtzeeRules.applyMove(state, 0, RollDice())
|
||||
val again = YahtzeeRules.applyMove(state, 0, RollDice())
|
||||
assertEquals(once.dice, again.dice)
|
||||
|
||||
val keptOnce = YahtzeeRules.applyMove(once, 0, RollDice(listOf(0, 1)))
|
||||
val keptAgain = YahtzeeRules.applyMove(once, 0, RollDice(listOf(0, 1)))
|
||||
assertEquals(keptOnce.dice, keptAgain.dice)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `kept dice stay put and the rest are thrown again`() {
|
||||
val state = YahtzeeRules.applyMove(YahtzeeRules.initialState(config()), 0, RollDice())
|
||||
val kept = YahtzeeRules.applyMove(state, 0, RollDice(listOf(0, 2, 4)))
|
||||
assertEquals(state.dice[0], kept.dice[0])
|
||||
assertEquals(state.dice[2], kept.dice[2])
|
||||
assertEquals(state.dice[4], kept.dice[4])
|
||||
assertEquals(2, kept.rollsUsed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `there are three throws in a turn and no more`() {
|
||||
var state = YahtzeeRules.applyMove(YahtzeeRules.initialState(config()), 0, RollDice())
|
||||
state = YahtzeeRules.applyMove(state, 0, RollDice())
|
||||
state = YahtzeeRules.applyMove(state, 0, RollDice())
|
||||
assertEquals(ROLLS_PER_TURN, state.rollsUsed)
|
||||
assertEquals(0, state.rollsLeft)
|
||||
assertThrows { YahtzeeRules.applyMove(state, 0, RollDice()) }
|
||||
// Only writing it down is left.
|
||||
assertTrue(YahtzeeRules.legalMoves(state, 0).all { it is ScoreIn })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing may be kept before the first throw`() {
|
||||
val state = YahtzeeRules.initialState(config())
|
||||
assertThrows { YahtzeeRules.applyMove(state, 0, RollDice(listOf(0))) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `writing a box passes the dice on and the round turns over`() {
|
||||
val state = YahtzeeRules.applyMove(YahtzeeRules.initialState(config()), 0, RollDice())
|
||||
val written = YahtzeeRules.applyMove(state, 0, ScoreIn(YahtzeeCategory.CHANCE))
|
||||
assertEquals("the dice go back in the cup", emptyList<Int>(), written.dice)
|
||||
assertEquals(0, written.rollsUsed)
|
||||
assertEquals(1, written.turn)
|
||||
assertEquals("still the first round", 0, written.round)
|
||||
assertEquals(state.dice.sum(), written.cards[0][YahtzeeCategory.CHANCE])
|
||||
|
||||
val second = YahtzeeRules.applyMove(written, 1, RollDice())
|
||||
val wrapped = YahtzeeRules.applyMove(second, 1, ScoreIn(YahtzeeCategory.CHANCE))
|
||||
assertEquals(0, wrapped.turn)
|
||||
assertEquals("round turns over once everyone has written", 1, wrapped.round)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a seat that is not on the clock cannot move`() {
|
||||
val state = YahtzeeRules.initialState(config())
|
||||
assertTrue(YahtzeeRules.legalMoves(state, 1).isEmpty())
|
||||
assertThrows { YahtzeeRules.applyMove(state, 1, RollDice()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a second Yahtzee pays a hundred, but only over a fifty`() {
|
||||
val base = YahtzeeRules.initialState(config())
|
||||
val fives = listOf(5, 5, 5, 5, 5)
|
||||
|
||||
// The box already holds the fifty, so the bonus is earned.
|
||||
val earned = base.copy(
|
||||
dice = fives,
|
||||
rollsUsed = 1,
|
||||
cards = listOf(
|
||||
YahtzeeCard().with(YahtzeeCategory.YAHTZEE, YAHTZEE_SCORE),
|
||||
YahtzeeCard(),
|
||||
),
|
||||
)
|
||||
val after = YahtzeeRules.applyMove(earned, 0, ScoreIn(YahtzeeCategory.FIVES))
|
||||
assertEquals(YAHTZEE_BONUS, after.yahtzeeBonuses[0])
|
||||
assertEquals(25, after.cards[0][YahtzeeCategory.FIVES])
|
||||
|
||||
// A zero written there earlier forfeits it.
|
||||
val forfeited = base.copy(
|
||||
dice = fives,
|
||||
rollsUsed = 1,
|
||||
cards = listOf(YahtzeeCard().with(YahtzeeCategory.YAHTZEE, 0), YahtzeeCard()),
|
||||
)
|
||||
assertEquals(
|
||||
0,
|
||||
YahtzeeRules.applyMove(forfeited, 0, ScoreIn(YahtzeeCategory.FIVES)).yahtzeeBonuses[0],
|
||||
)
|
||||
|
||||
// And it can be switched off at the table.
|
||||
val off = earned.copy(options = YahtzeeOptions(playerCount = 2, yahtzeeBonus = false))
|
||||
assertEquals(
|
||||
0,
|
||||
YahtzeeRules.applyMove(off, 0, ScoreIn(YahtzeeCategory.FIVES)).yahtzeeBonuses[0],
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a full card ends the game`() {
|
||||
var state = YahtzeeRules.initialState(config(YahtzeeOptions(playerCount = 1)))
|
||||
// Thirteen turns, each filling one box.
|
||||
var guard = 200
|
||||
while (!YahtzeeRules.isFinished(state) && guard-- > 0) {
|
||||
val legal = YahtzeeRules.legalMoves(state, state.turn)
|
||||
state = YahtzeeRules.applyMove(state, state.turn, YahtzeeAi.chooseMove(state, state.turn, legal))
|
||||
}
|
||||
assertTrue("the game should end on its own", guard > 0)
|
||||
assertTrue(YahtzeeRules.isFinished(state))
|
||||
assertTrue(state.cards[0].isComplete)
|
||||
assertEquals(YahtzeeCategory.entries.size, state.round)
|
||||
assertNull(YahtzeeRules.currentSeat(state))
|
||||
assertTrue(YahtzeeRules.summary(state).contains("Winner"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two can play it through as well`() {
|
||||
var state = YahtzeeRules.initialState(config(YahtzeeOptions(playerCount = 2)))
|
||||
var guard = 400
|
||||
while (!YahtzeeRules.isFinished(state) && guard-- > 0) {
|
||||
val legal = YahtzeeRules.legalMoves(state, state.turn)
|
||||
state = YahtzeeRules.applyMove(state, state.turn, YahtzeeAi.chooseMove(state, state.turn, legal))
|
||||
}
|
||||
assertTrue(guard > 0)
|
||||
assertTrue(state.cards.all { it.isComplete })
|
||||
state.cards.indices.forEach { assertTrue(state.totalFor(it) >= 0) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing is hidden from anyone`() {
|
||||
val state = YahtzeeRules.initialState(config())
|
||||
assertEquals(state, YahtzeeRules.viewFor(state, 0))
|
||||
assertEquals(state, YahtzeeRules.viewFor(state, 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state and moves survive a round trip`() {
|
||||
val state = YahtzeeRules.applyMove(YahtzeeRules.initialState(config()), 0, RollDice())
|
||||
assertEquals(state, YahtzeeRules.decodeState(YahtzeeRules.encodeState(state)))
|
||||
|
||||
val roll: YahtzeeMove = RollDice(listOf(0, 3))
|
||||
assertEquals(roll, YahtzeeRules.decodeMove(YahtzeeRules.encodeMove(roll)))
|
||||
val write: YahtzeeMove = ScoreIn(YahtzeeCategory.LARGE_STRAIGHT)
|
||||
assertEquals(write, YahtzeeRules.decodeMove(YahtzeeRules.encodeMove(write)))
|
||||
}
|
||||
|
||||
private fun assertThrows(block: () -> Unit) {
|
||||
try {
|
||||
block()
|
||||
throw AssertionError("expected the rules to reject that")
|
||||
} catch (expected: IllegalArgumentException) {
|
||||
// Rejected, which is the point.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user