From 68f8da0379eaa22ad7f37199623b90d805c0cd3b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 15:14:16 +0000 Subject: [PATCH 1/5] Yahtzee, as rules and an AI Five dice, thirteen boxes, three throws a turn. The first dice game in the app -- backgammon rolls them, but as a race rather than for what the faces themselves are worth. The throw is derived from the state rather than from a generator held on the side. applyMove has to return the same thing every time it is given the same state and move, because the host and every client run it separately, and a throw that differed between them would split the game in two. Seeding on the round, the seat, the rolls used and what was kept gives dice that are unpredictable to play against and identical to replay. RollDice carries positions rather than faces. Two fives are two different dice, and keeping one while rerolling the other is something a set of faces cannot say. A card is thirteen slots in printed order rather than a map, so an unfilled box crosses the wire as a plain null instead of going missing. A full house is strictly three and two, so five of a kind does not pay for it -- that is what is printed on the card, and the joker variations that say otherwise are not played here. The second-Yahtzee hundred is only earned over a fifty: a zero written in that box earlier forfeits it. The AI does not try each way of keeping and look at what came back. Since the throw is a pure function of the state and the dice held, that would be reading the roll before making it -- winning by seeing the future rather than by playing well. It decides from what is already on the table: a run if one is nearly there, otherwise the face there is most of. Not in GameCatalog yet, for the same reason as the others: the id exists so the rules compile, and the entry lands with the screen. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC --- .../org/prolibertate/games/game/Catalog.kt | 7 + .../games/game/yahtzee/YahtzeeAi.kt | 86 +++++ .../games/game/yahtzee/YahtzeeModel.kt | 192 ++++++++++++ .../games/game/yahtzee/YahtzeeRules.kt | 175 +++++++++++ .../games/game/yahtzee/YahtzeeRulesTest.kt | 294 ++++++++++++++++++ 5 files changed, 754 insertions(+) create mode 100644 app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeAi.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeModel.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeRules.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/yahtzee/YahtzeeRulesTest.kt 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..c38d813 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -42,6 +42,13 @@ object GameCatalog { */ const val HEARTS = "hearts" const val PEG_SOLITAIRE = "pegsolitaire" + + /** + * An id ahead of its screen: the engine is written and tested, but a + * catalogue entry with nothing behind it would crash on the first tap, so it + * joins [all] when its screen lands. + */ + const val YAHTZEE = "yahtzee" const val SEQUENCE = "sequence" const val MASTERMIND = "mastermind" const val BACKGAMMON = "backgammon" 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/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. + } + } +} From 2b09bcdde88440963f74e2c67232099d6c4c197c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 15:23:47 +0000 Subject: [PATCH 2/5] Klondike, the first of the card solitaires Seven columns, four foundations and a stock, played by one person against the deal. Three axes of variation, which is what most people mean by house rules for patience: one card off the stock or three, how often the waste may be turned back over, and whether an empty column will take anything or only a king. A single pass through the pack is redealLimit = 0. A spot is a kind and an index rather than a class per kind. Every place a card can be in this game is one of four things identified by at most one number, so a pair says everything a hierarchy would and crosses the wire as two plain fields. A tableau pile keeps its face-down and face-up parts separate, because that split is the game: a column is only worth what has been turned up, and what is buried is the thing being played for. Stripping the turned-up run off a column turns the next card, which is where most of a deal is won. Shifting a whole column into an empty one is not offered as a move at all. It achieves nothing and would let a game walk back and forth for ever -- which matters here more than usual, because isFinished is "won, or nothing left to try", and a game that can always shuffle pointlessly is never blocked. Turning a packet over reverses it, so drawing three leaves the deepest of the three showing, and a redeal puts the top of the waste at the bottom of the new stock. Both are tested, because getting either backwards is invisible until somebody counts the cards. The AI orders its preferences rather than scoring finely: home always, then turning a card up, then emptying a column, and aimless shuffling between columns below drawing, so it is only reached when there is nothing else. That ordering is what keeps it from walking a run between two columns for ever. Not in GameCatalog yet -- id now, entry with the screen. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC --- .../org/prolibertate/games/game/Catalog.kt | 1 + .../games/game/klondike/KlondikeAi.kt | 50 +++ .../games/game/klondike/KlondikeModel.kt | 146 +++++++++ .../games/game/klondike/KlondikeRules.kt | 249 ++++++++++++++ .../games/game/klondike/KlondikeRulesTest.kt | 308 ++++++++++++++++++ 5 files changed, 754 insertions(+) create mode 100644 app/src/main/java/org/prolibertate/games/game/klondike/KlondikeAi.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/klondike/KlondikeModel.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/klondike/KlondikeRules.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/klondike/KlondikeRulesTest.kt 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 c38d813..14dc42c 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -49,6 +49,7 @@ object GameCatalog { * joins [all] when its screen lands. */ const val YAHTZEE = "yahtzee" + const val KLONDIKE = "klondike" const val SEQUENCE = "sequence" const val MASTERMIND = "mastermind" const val BACKGAMMON = "backgammon" 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..cf8ed09 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeModel.kt @@ -0,0 +1,146 @@ +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 + +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 + +/** 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.order == top.rank.order + 1 +} + +/** Down by one, and in the other colour. */ +fun buildsDown(lower: Card, onto: Card): Boolean = + lower.rank.order == onto.rank.order - 1 && lower.suit.isRed != onto.suit.isRed + +/** + * 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..ce1c175 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeRules.kt @@ -0,0 +1,249 @@ +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 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/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..e9ada33 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/klondike/KlondikeRulesTest.kt @@ -0,0 +1,308 @@ +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 + +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 `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. + } + } +} From 3dbc4b5557bb7c565db55a9db75efb6842953413 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 15:30:15 +0000 Subject: [PATCH 3/5] Count the ace low in Klondike Rank.order puts the ace at fourteen, which is right for the games this deck was built for -- in Euchre, Kaiser and the rest it is the highest card there is. Patience counts it the other way: foundations run ace upwards and columns run down to an ace. Using the raw order meant a two could never follow an ace home and an ace could never be laid on a two, so no foundation could be built past its first card. Nothing about the deal or the moves would have looked wrong; the game would simply never have gone out. patienceOrder is local to this game rather than a change to Rank, because ace high is correct everywhere else in the app. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC --- .../games/game/klondike/KlondikeModel.kt | 14 +++++++++-- .../games/game/klondike/KlondikeRulesTest.kt | 24 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) 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 index cf8ed09..6b02b9d 100644 --- a/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeModel.kt +++ b/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeModel.kt @@ -120,16 +120,26 @@ data class MoveCards(val from: Spot, val to: Spot, val count: Int = 1) : Klondik /** Foundations are indexed by suit, so a card always knows which one it wants. */ fun foundationFor(card: Card): Int = card.suit.ordinal +/** + * Where a rank sits when the ace is low. + * + * [Rank.order] puts the ace at fourteen, because the games this deck was built + * for are trick-taking ones where it is the highest card there is. Patience + * counts it the other way: foundations run ace upwards and columns run down to + * an ace, so every comparison here has to use this and not the raw order. + */ +val Rank.patienceOrder: Int get() = if (this == Rank.ACE) 1 else order + /** 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.order == top.rank.order + 1 + return card.suit == top.suit && card.rank.patienceOrder == top.rank.patienceOrder + 1 } /** Down by one, and in the other colour. */ fun buildsDown(lower: Card, onto: Card): Boolean = - lower.rank.order == onto.rank.order - 1 && lower.suit.isRed != onto.suit.isRed + lower.rank.patienceOrder == onto.rank.patienceOrder - 1 && lower.suit.isRed != onto.suit.isRed /** * Whether [cards] is a run that can travel as one: each card a step below the 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 index e9ada33..9f62f0e 100644 --- a/app/src/test/java/org/prolibertate/games/game/klondike/KlondikeRulesTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/klondike/KlondikeRulesTest.kt @@ -112,6 +112,30 @@ class KlondikeRulesTest { // ---- 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))) From 831b2fc92515bf807efaa16d1d7b85db8e55f94f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 15:39:04 +0000 Subject: [PATCH 4/5] Screens for Yahtzee and Klondike, and both into the catalogue Two engines that had been sitting behind ids now have screens, so they are tappable and playable. The Yahtzee card shows what the dice on the table would score in every box still open. That comparison is the whole of the decision, and doing the arithmetic by eye is not what anybody wants from a phone. A turn is two choices and the screen shows them together: which dice to keep before throwing again, and which box to write in when the throwing stops. Klondike plays tap-to-take then tap-to-put rather than by dragging. A drag across seven columns of overlapping cards on a phone is mostly a test of aim. Tapping a face-up card takes it and everything resting on it, so a run travels the way it would on a table. Which taps do anything is read off the legal moves the rules offer, so the screen never has to know why a move is allowed. Klondike deals again by rebuilding the controller, as peg solitaire does: a blocked deal is genuinely finished, so driveIdleSeats has already stopped and advanceIdle would never run. Yahtzee seats one to six, so it needed a case in seatCountFor. Without one the seat count falls through to the catalogue minimum, which is one now that solo play exists -- a two-handed game would have been dealt a single seat and the rules would have refused the table. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC --- .../org/prolibertate/games/game/Catalog.kt | 32 +- .../prolibertate/games/ui/GameSetupScreen.kt | 66 ++++ .../org/prolibertate/games/ui/PlayScreen.kt | 54 ++++ .../games/ui/game/KlondikeScreen.kt | 257 +++++++++++++++ .../games/ui/game/YahtzeeScreen.kt | 304 ++++++++++++++++++ .../prolibertate/games/game/CatalogTest.kt | 2 + 6 files changed, 705 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/KlondikeScreen.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/YahtzeeScreen.kt 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 14dc42c..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,19 +35,9 @@ 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" - /** - * An id ahead of its screen: the engine is written and tested, but a - * catalogue entry with nothing behind it would crash on the first tap, so it - * joins [all] when its screen lands. - */ const val YAHTZEE = "yahtzee" const val KLONDIKE = "klondike" const val SEQUENCE = "sequence" @@ -143,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", @@ -217,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/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, From 7569f0b89197793a18cfe4dc833cad13ccca9922 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 15:44:53 +0000 Subject: [PATCH 5/5] Share the ace-low convention rather than copy it Klondike had its own patienceOrder, acceptsOnFoundation and buildsDown. FreeCell builds the same way, Spider builds in suit, and Pyramid needs an ace worth one to pair to thirteen -- four separate definitions of "the ace is low" would be four chances to disagree, and the one that already went wrong cost a CI round trip to find. game.solitaire.Patience now holds the ordering and the build rules, and Klondike reads them from there. buildsDown stays as a name in the Klondike package because that is what this game builds with; it just no longer defines what it means. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC --- .../games/game/klondike/KlondikeModel.kt | 23 +++-------- .../games/game/klondike/KlondikeRules.kt | 1 + .../games/game/solitaire/Patience.kt | 41 +++++++++++++++++++ .../games/game/klondike/KlondikeRulesTest.kt | 2 + 4 files changed, 50 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/org/prolibertate/games/game/solitaire/Patience.kt 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 index 6b02b9d..24f29b5 100644 --- a/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeModel.kt +++ b/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeModel.kt @@ -5,6 +5,7 @@ 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 @@ -121,25 +122,13 @@ data class MoveCards(val from: Spot, val to: Spot, val count: Int = 1) : Klondik fun foundationFor(card: Card): Int = card.suit.ordinal /** - * Where a rank sits when the ace is low. + * Down by one and in the other colour. * - * [Rank.order] puts the ace at fourteen, because the games this deck was built - * for are trick-taking ones where it is the highest card there is. Patience - * counts it the other way: foundations run ace upwards and columns run down to - * an ace, so every comparison here has to use this and not the raw order. + * 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. */ -val Rank.patienceOrder: Int get() = if (this == Rank.ACE) 1 else order - -/** 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 -} - -/** Down by one, and in the other colour. */ -fun buildsDown(lower: Card, onto: Card): Boolean = - lower.rank.patienceOrder == onto.rank.patienceOrder - 1 && lower.suit.isRed != onto.suit.isRed +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 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 index ce1c175..5419e0c 100644 --- a/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeRules.kt +++ b/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeRules.kt @@ -8,6 +8,7 @@ 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 /** 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/test/java/org/prolibertate/games/game/klondike/KlondikeRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/klondike/KlondikeRulesTest.kt index 9f62f0e..e86778f 100644 --- a/app/src/test/java/org/prolibertate/games/game/klondike/KlondikeRulesTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/klondike/KlondikeRulesTest.kt @@ -13,6 +13,8 @@ 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 {