diff --git a/app/src/main/java/org/prolibertate/games/game/Catalog.kt b/app/src/main/java/org/prolibertate/games/game/Catalog.kt index 840b2fe..f96b203 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -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", diff --git a/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeAi.kt b/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeAi.kt new file mode 100644 index 0000000..b542471 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeAi.kt @@ -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 { + + override fun chooseMove(state: KlondikeState, seat: Int, legal: List): 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() + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeModel.kt b/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeModel.kt new file mode 100644 index 0000000..24f29b5 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeModel.kt @@ -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 = emptyList(), + val faceUp: List = 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, + /** Face up; the last card is the one in play. */ + val waste: List, + /** One per suit, in [Suit] order, each running ace upwards. */ + val foundations: List>, + val tableau: List, + val redealsUsed: Int, + val moves: Int, + val log: List, +) { + /** 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): 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] diff --git a/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeRules.kt b/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeRules.kt new file mode 100644 index 0000000..5419e0c --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeRules.kt @@ -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 { + + 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(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 { + if (seat != SOLO_SEAT || state.isWon) return emptyList() + val moves = mutableListOf() + 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 = 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): 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 List.replacing(index: Int, value: T): List = + 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) +} diff --git a/app/src/main/java/org/prolibertate/games/game/solitaire/Patience.kt b/app/src/main/java/org/prolibertate/games/game/solitaire/Patience.kt new file mode 100644 index 0000000..653561f --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/solitaire/Patience.kt @@ -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): 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, builds: (Card, Card) -> Boolean): Boolean = + cards.isNotEmpty() && cards.zipWithNext().all { (upper, lower) -> builds(lower, upper) } diff --git a/app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeAi.kt b/app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeAi.kt new file mode 100644 index 0000000..b6ecc05 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeAi.kt @@ -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 { + + override fun chooseMove(state: YahtzeeState, seat: Int, legal: List): YahtzeeMove { + require(legal.isNotEmpty()) { "No legal move for seat $seat" } + val rolls = legal.filterIsInstance() + val writes = legal.filterIsInstance() + + // 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): List { + val run = runIndices(dice) + if (run.size >= 4) return run + + val counts = countsOf(dice) + val most = counts.entries + .sortedWith(compareByDescending> { 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): List { + 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() + return dice.indices.filter { index -> + val face = dice[index] + face in wanted && taken.add(face) + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeModel.kt b/app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeModel.kt new file mode 100644 index 0000000..4e14cc3 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeModel.kt @@ -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 = 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, + val rollsUsed: Int, + val cards: List, + /** Hundreds earned for extra Yahtzees, counted per seat. */ + val yahtzeeBonuses: List, + val log: List, +) { + 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 = 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): Map = dice.groupingBy { it }.eachCount() + +/** The longest run of consecutive faces present, however many of each. */ +fun longestRun(dice: List): 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 { + 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): Boolean = + dice.size == DICE_COUNT && dice.toSet().size == 1 diff --git a/app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeRules.kt b/app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeRules.kt new file mode 100644 index 0000000..3e82893 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeRules.kt @@ -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 { + + override val gameId: String = GameCatalog.YAHTZEE + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + override fun initialState(config: TableConfig): YahtzeeState { + val options = json.decodeFromString(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 { + if (state.isOver || state.turn != seat) return emptyList() + val moves = mutableListOf() + 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> = + (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): List { + 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) +} diff --git a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt index 1fe2edf..58ee816 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -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(optionsJson).playerCount + GameCatalog.TAYU -> setupJson.decodeFromString(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)) }, + ) + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt index 7c7b05c..4b93b30 100644 --- a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt @@ -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( + 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( + 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( diff --git a/app/src/main/java/org/prolibertate/games/ui/game/KlondikeScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/KlondikeScreen.kt new file mode 100644 index 0000000..1bd3ec3 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/KlondikeScreen.kt @@ -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, + 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?>(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() + 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") } }, + ) +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/YahtzeeScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/YahtzeeScreen.kt new file mode 100644 index 0000000..abf9903 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/YahtzeeScreen.kt @@ -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, + 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() } + 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().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, + kept: List, + 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, + 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, + 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, 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") } }, + ) +} diff --git a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt index 9c44e39..45ed393 100644 --- a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt @@ -73,6 +73,8 @@ class CatalogTest { GameCatalog.CRIBBAGE, GameCatalog.HEARTS, GameCatalog.PEG_SOLITAIRE, + GameCatalog.YAHTZEE, + GameCatalog.KLONDIKE, GameCatalog.WIZARD, GameCatalog.CHESS, GameCatalog.TAYU, diff --git a/app/src/test/java/org/prolibertate/games/game/klondike/KlondikeRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/klondike/KlondikeRulesTest.kt new file mode 100644 index 0000000..e86778f --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/klondike/KlondikeRulesTest.kt @@ -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() + 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( + 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. + } + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/yahtzee/YahtzeeRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/yahtzee/YahtzeeRulesTest.kt new file mode 100644 index 0000000..7ab59b6 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/yahtzee/YahtzeeRulesTest.kt @@ -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(), 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. + } + } +}