From 88948eac9ec71bcc3a44702312c82ad6afcf39d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 05:11:00 +0000 Subject: [PATCH 01/16] Hearts, as rules and an AI The trick-taking games already here all ask a seat to win tricks it named. Hearts inverts that: every heart costs a point, the queen of spades costs thirteen, and the hand is played to take none of them. Four restrictions decide what may be played, and they lift in that order: the two of clubs opens the round, a seat holding the led suit must follow it, no point card may be discarded on the first trick, and hearts cannot be led until one has been played. Each of the last three lifts when a seat holds nothing else, which is why playableCards checks them in sequence rather than as one condition. Passing is simultaneous at the table but the engine hands out one seat at a time, so currentSeat returns the lowest seat that has not chosen and the swap runs once the last one has. viewFor strips the selections as well as the hands: a pass a client could read in advance is not a pass. Taking all twenty-six is the one case where a round does not sum to twenty-six, so scoreRound works from the cards taken rather than a running total -- a count cannot tell an unbroken moon from thirteen hearts and a queen picked up elsewhere. The AI plays to avoid points rather than to shoot: it sheds the queen and her guards early, ducks under whoever is winning, and takes a trick as cheaply as it can when it cannot get out of the way. Not in GameCatalog yet. The engine is complete and tested, but tapping a catalogue entry with no screen behind it would crash, so it is added with the screen rather than before it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC --- .../games/game/hearts/HeartsAi.kt | 108 ++++++ .../games/game/hearts/HeartsModel.kt | 164 +++++++++ .../games/game/hearts/HeartsRules.kt | 294 ++++++++++++++++ .../games/game/hearts/HeartsRulesTest.kt | 332 ++++++++++++++++++ 4 files changed, 898 insertions(+) create mode 100644 app/src/main/java/org/prolibertate/games/game/hearts/HeartsAi.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/hearts/HeartsModel.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/hearts/HeartsRules.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/hearts/HeartsRulesTest.kt diff --git a/app/src/main/java/org/prolibertate/games/game/hearts/HeartsAi.kt b/app/src/main/java/org/prolibertate/games/game/hearts/HeartsAi.kt new file mode 100644 index 0000000..a9e1ca9 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/hearts/HeartsAi.kt @@ -0,0 +1,108 @@ +package org.prolibertate.games.game.hearts + +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.GameAi + +/** + * A cautious Hearts player. + * + * It plays to avoid points rather than to shoot the moon: it sheds the queen of + * spades and the guards above her at the first opportunity, ducks under whoever + * is winning a trick when it can, and takes a trick as cheaply as possible when + * it cannot get out of the way. That is enough to make it an opponent worth + * beating without pretending to read the table. + */ +object HeartsAi : GameAi { + + override fun chooseMove(state: HeartsState, seat: Int, legal: List): HeartsMove { + require(legal.isNotEmpty()) { "No legal move for seat $seat" } + return when (state.phase) { + HeartsPhase.PASSING -> choosePass(state, seat, legal) + else -> choosePlay(state, seat, legal) + } + } + + private fun choosePass(state: HeartsState, seat: Int, legal: List): HeartsMove { + val hand = state.hands[seat] + val wanted = hand.sortedByDescending { passDanger(it, hand) }.take(PASS_SIZE).toSet() + // Prefer the exact bundle the heuristic asked for, but fall back rather + // than fail: the caller's list is what the host will validate against. + return legal.firstOrNull { it is PassCards && it.cards.toSet() == wanted } ?: legal.first() + } + + /** + * How much a card wants to be somebody else's problem. + * + * The queen is the whole game, and the ace and king of spades are only + * dangerous because they can be forced to capture her — so a long spade + * holding, which gives room to duck, makes all three far less urgent. + */ + private fun passDanger(card: Card, hand: List): Int { + val spades = hand.count { it.suit == Suit.SPADES } + val guarded = spades >= 5 + return when { + card == QUEEN_OF_SPADES -> if (guarded) 45 else 100 + card.suit == Suit.SPADES && card.rank.order > Rank.QUEEN.order -> + if (guarded) 30 else 90 + + card.suit == Suit.HEARTS -> 40 + card.rank.order + else -> card.rank.order + } + } + + private fun choosePlay(state: HeartsState, seat: Int, legal: List): HeartsMove { + val cards = legal.filterIsInstance().map { it.card } + if (cards.isEmpty()) return legal.first() + val chosen = when { + state.trick.isEmpty() -> lead(cards) + else -> follow(state, cards) + } + return PlayCard(chosen) + } + + /** + * Leads low, and keeps off spades while the queen is still unaccounted for — + * leading a spade under her is how a seat ends up holding her. + */ + private fun lead(cards: List): Card { + val offSpades = cards.filter { it.suit != Suit.SPADES } + val pool = if (offSpades.isNotEmpty()) offSpades else cards + return pool.minBy { it.rank.order } + } + + private fun follow(state: HeartsState, cards: List): Card { + val led = state.trick.first().card.suit + val best = state.trick.maxOf { trickStrength(it.card, led) } + val pot = pointsIn(state.trick.map { it.card }) + val last = state.trick.size == state.options.playerCount - 1 + + val ducking = cards.filter { trickStrength(it, led) < best } + if (ducking.isNotEmpty()) { + // Safe under the current winner, so shed the most expensive card + // that still loses. + return ducking.maxBy { it.rank.order + pointsOf(it) * 2 } + } + + val void = cards.none { it.suit == led } + if (void) return discard(cards) + + // Every card left would take the trick. Playing last with nothing in the + // pot means it costs nothing, so there is no reason to waste a high card + // on it; otherwise take it as cheaply as possible. + return if (last && pot == 0) cards.maxBy { it.rank.order } else cards.minBy { it.rank.order } + } + + /** Void in the led suit and free to throw anything, so throw the worst of it. */ + private fun discard(cards: List): Card { + cards.firstOrNull { it == QUEEN_OF_SPADES }?.let { return it } + cards.filter { it.suit == Suit.SPADES && it.rank.order > Rank.QUEEN.order } + .maxByOrNull { it.rank.order } + ?.let { return it } + cards.filter { it.suit == Suit.HEARTS } + .maxByOrNull { it.rank.order } + ?.let { return it } + return cards.maxBy { it.rank.order } + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/hearts/HeartsModel.kt b/app/src/main/java/org/prolibertate/games/game/hearts/HeartsModel.kt new file mode 100644 index 0000000..63c56ef --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/hearts/HeartsModel.kt @@ -0,0 +1,164 @@ +package org.prolibertate.games.game.hearts + +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 + +@Serializable +data class HeartsOptions( + /** + * Four hands of thirteen, which is the game as it is normally played. Three + * and five handed Hearts exist, but they need cards struck from the deck to + * divide evenly, and a seat that holds a different number of cards from its + * neighbours changes which discards are safe — a different game to bid and + * play, not a seat count. + */ + val playerCount: Int = 4, + /** The round in which somebody crosses this ends the game. */ + val targetScore: Int = 100, + /** + * Taking all twenty-six points scores nothing and gives everyone else the + * lot. Switching this off scores a moon like any other hand, which is to say + * it becomes the worst result on the table rather than the best. + */ + val allowShootTheMoon: Boolean = true, +) { + init { + require(playerCount == 4) { "Hearts is dealt four handed" } + require(targetScore > 0) { "targetScore must be positive" } + } +} + +/** + * Where the three passed cards go. The cycle repeats every fourth round, and + * the hold round is what stops a seat from planning around a known direction + * forever. + */ +@Serializable +enum class PassDirection(val label: String) { + LEFT("left"), + RIGHT("right"), + ACROSS("across"), + HOLD("nobody"), + ; + + companion object { + /** Rounds run left, right, across, hold, and back to left. */ + fun forRound(round: Int): PassDirection = entries[round % entries.size] + } +} + +@Serializable +enum class HeartsPhase { PASSING, PLAYING, ROUND_OVER, GAME_OVER } + +@Serializable +data class PlayedCard(val seat: Int, val card: Card) + +@Serializable +data class HeartsState( + val options: HeartsOptions, + val seed: Long, + /** Zero-based. Only used to pick the pass direction and to number the log. */ + val round: Int, + val phase: HeartsPhase, + val hands: List>, + /** Survives redaction so a client can draw the backs of other hands. */ + val handCounts: List, + /** What each seat has chosen to pass, before the swap happens. */ + val passSelections: List>, + val turn: Int, + val leader: Int, + /** + * Which trick of the round is being played, counting from zero. Carried + * rather than derived because the first trick is the one that bans point + * cards, and a rule that important should not depend on reading it back out + * of how many cards happen to have been taken. + */ + val trickNumber: Int, + val trick: List, + /** Held on the table so a finished trick can be read before it is swept. */ + val completedTrick: List = emptyList(), + /** + * Every card each seat has taken in tricks this round. Kept whole rather + * than as a running count because shooting the moon is decided on the + * twenty-six penalty cards together, not on a total that cannot tell an + * unbroken moon from thirteen hearts and a lucky queen elsewhere. + */ + val taken: List>, + val heartsBroken: Boolean, + val scores: List, + val roundScores: List, + val log: List, +) { + val passDirection: PassDirection get() = PassDirection.forRound(round) + + /** Thirteen, four handed: every card dealt is a card played. */ + val tricksPerRound: Int get() = DECK_SIZE / options.playerCount +} + +/** An ordinary pack, which Hearts uses whole. */ +const val DECK_SIZE: Int = 52 + +@Serializable +sealed interface HeartsMove + +/** The three cards this seat is giving away. Order does not matter. */ +@Serializable +@SerialName("pass") +data class PassCards(val cards: List) : HeartsMove + +@Serializable +@SerialName("play") +data class PlayCard(val card: Card) : HeartsMove + +/** How many cards change hands in a pass. */ +const val PASS_SIZE: Int = 3 + +val QUEEN_OF_SPADES: Card = Card(Rank.QUEEN, Suit.SPADES) + +/** The two of clubs always leads the first trick of a round. */ +val TWO_OF_CLUBS: Card = Card(Rank.TWO, Suit.CLUBS) + +/** Every heart costs a point; the queen of spades costs thirteen. */ +fun pointsOf(card: Card): Int = when { + card == QUEEN_OF_SPADES -> 13 + card.suit == Suit.HEARTS -> 1 + else -> 0 +} + +/** The whole twenty-six, which is what a moon has to consist of. */ +const val ALL_POINTS: Int = 26 + +fun pointsIn(cards: List): Int = cards.sumOf { pointsOf(it) } + +/** Which seat receives what [from] passes, given the round's direction. */ +fun passTarget(from: Int, playerCount: Int, direction: PassDirection): Int = when (direction) { + PassDirection.LEFT -> (from + 1) % playerCount + PassDirection.RIGHT -> (from + playerCount - 1) % playerCount + PassDirection.ACROSS -> (from + playerCount / 2) % playerCount + PassDirection.HOLD -> from +} + +/** + * Ranks a card within a trick. Nothing trumps in Hearts, so a card that did not + * follow the led suit cannot win however high it is. + */ +fun trickStrength(card: Card, ledSuit: Suit): Int = + if (card.suit == ledSuit) card.rank.order else -1 + +/** + * Turns a round's takings into the points each seat adds to its score. + * + * A seat that took all twenty-six has shot the moon: it scores nothing and + * every other seat takes the lot. That is the one case where a round's scores + * do not sum to twenty-six. + */ +fun scoreRound(taken: List>, allowShootTheMoon: Boolean): List { + val raw = taken.map { pointsIn(it) } + if (!allowShootTheMoon) return raw + val shooter = raw.indexOfFirst { it == ALL_POINTS } + if (shooter < 0) return raw + return raw.indices.map { if (it == shooter) 0 else ALL_POINTS } +} diff --git a/app/src/main/java/org/prolibertate/games/game/hearts/HeartsRules.kt b/app/src/main/java/org/prolibertate/games/game/hearts/HeartsRules.kt new file mode 100644 index 0000000..4185381 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/hearts/HeartsRules.kt @@ -0,0 +1,294 @@ +package org.prolibertate.games.game.hearts + +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.Suit +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 + +/** + * Hearts as a pure state machine. See RULES-hearts.md. + */ +object HeartsRules : GameRules { + + override val gameId: String = GameCatalog.HEARTS + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + override fun initialState(config: TableConfig): HeartsState { + val options = json.decodeFromString(config.optionsJson) + require(config.seats.size == options.playerCount) { + "Seat count ${config.seats.size} does not match ${options.playerCount} players" + } + return deal(options, config.seed, 0, List(options.playerCount) { 0 }, listOf("Round 1.")) + } + + private fun deal( + options: HeartsOptions, + seed: Long, + round: Int, + scores: List, + log: List, + ): HeartsState { + // A fresh generator per round, keyed on the round, so the whole game is + // reproducible from the one seed the host shipped with the table. + val random = Random(seed + round) + val deck = Decks.standard52().shuffledWith(random) + val perHand = deck.size / options.playerCount + val hands = (0 until options.playerCount).map { seat -> + deck.subList(seat * perHand, (seat + 1) * perHand).sortedWith(handOrder) + } + val direction = PassDirection.forRound(round) + val passing = direction != PassDirection.HOLD + // A hold round has nothing to swap, so it opens straight onto the lead. + val opener = if (passing) 0 else holderOfTwoOfClubs(hands) + return HeartsState( + options = options, + seed = seed, + round = round, + phase = if (passing) HeartsPhase.PASSING else HeartsPhase.PLAYING, + hands = hands, + handCounts = hands.map { it.size }, + passSelections = List(options.playerCount) { emptyList() }, + turn = opener, + leader = opener, + trickNumber = 0, + trick = emptyList(), + completedTrick = emptyList(), + taken = List(options.playerCount) { emptyList() }, + heartsBroken = false, + scores = scores, + roundScores = List(options.playerCount) { 0 }, + log = log + if (passing) "Passing ${direction.label}." else "No passing this round.", + ) + } + + /** Sorted by suit then rank, which is how a hand is held rather than dealt. */ + private val handOrder = compareBy({ it.suit.ordinal }, { it.rank.order }) + + private fun holderOfTwoOfClubs(hands: List>): Int = + hands.indexOfFirst { it.contains(TWO_OF_CLUBS) } + + override fun currentSeat(state: HeartsState): Int? = when (state.phase) { + // Passing is simultaneous at the table, but the engine hands out one + // seat at a time. The lowest seat that has not chosen goes next, and + // the swap happens once the last one has. + HeartsPhase.PASSING -> state.passSelections.indexOfFirst { it.isEmpty() }.takeIf { it >= 0 } + HeartsPhase.PLAYING -> state.turn + HeartsPhase.ROUND_OVER, HeartsPhase.GAME_OVER -> null + } + + override fun legalMoves(state: HeartsState, seat: Int): List = + when (state.phase) { + HeartsPhase.PASSING -> + if (state.passSelections[seat].isNotEmpty()) { + emptyList() + } else { + combinations(state.hands[seat], PASS_SIZE).map { PassCards(it) } + } + + HeartsPhase.PLAYING -> + if (state.turn != seat) emptyList() else playableCards(state, seat).map { PlayCard(it) } + + HeartsPhase.ROUND_OVER, HeartsPhase.GAME_OVER -> emptyList() + } + + /** + * The cards [seat] may legally play into the trick as it stands. + * + * Four restrictions, in the order they bite: the two of clubs opens the + * round, a seat that can follow the led suit must, no point card may be + * discarded on the first trick, and hearts cannot be led until one has been + * played. Each of the last three lifts when a seat holds nothing else. + */ + fun playableCards(state: HeartsState, seat: Int): List { + val hand = state.hands[seat] + if (hand.isEmpty()) return emptyList() + val firstTrick = state.trickNumber == 0 + + if (state.trick.isEmpty()) { + if (firstTrick) return listOf(TWO_OF_CLUBS).filter { hand.contains(it) } + val offHearts = hand.filter { it.suit != Suit.HEARTS } + // Hearts may only be led once broken — unless that is all there is + // left to lead, in which case the restriction cannot be honoured. + return if (state.heartsBroken || offHearts.isEmpty()) hand else offHearts + } + + val led = state.trick.first().card.suit + val following = hand.filter { it.suit == led } + if (following.isNotEmpty()) return following + + // Void in the led suit, so anything goes — except that the first trick + // takes no points while the seat has a card that costs none. + if (firstTrick) { + val harmless = hand.filter { pointsOf(it) == 0 } + if (harmless.isNotEmpty()) return harmless + } + return hand + } + + override fun applyMove(state: HeartsState, seat: Int, move: HeartsMove): HeartsState = + when (move) { + is PassCards -> applyPass(state, seat, move) + is PlayCard -> applyPlay(state, seat, move) + } + + private fun applyPass(state: HeartsState, seat: Int, move: PassCards): HeartsState { + require(state.phase == HeartsPhase.PASSING) { "Not passing" } + require(state.passSelections[seat].isEmpty()) { "Seat $seat has already passed" } + require(move.cards.size == PASS_SIZE) { "A pass is $PASS_SIZE cards" } + require(move.cards.distinct().size == PASS_SIZE) { "Cannot pass the same card twice" } + val hand = state.hands[seat] + require(move.cards.all { hand.contains(it) }) { "Cannot pass a card not held" } + + val selections = state.passSelections.toMutableList() + selections[seat] = move.cards + if (selections.any { it.isEmpty() }) { + return state.copy(passSelections = selections) + } + return completePass(state.copy(passSelections = selections)) + } + + /** Everyone has chosen, so the three-card bundles change hands at once. */ + private fun completePass(state: HeartsState): HeartsState { + val direction = state.passDirection + val count = state.options.playerCount + val incoming = MutableList(count) { emptyList() } + for (from in 0 until count) { + incoming[passTarget(from, count, direction)] = state.passSelections[from] + } + val hands = state.hands.mapIndexed { seat, hand -> + (hand - state.passSelections[seat].toSet() + incoming[seat]).sortedWith(handOrder) + } + val opener = holderOfTwoOfClubs(hands) + return state.copy( + phase = HeartsPhase.PLAYING, + hands = hands, + handCounts = hands.map { it.size }, + turn = opener, + leader = opener, + log = state.log + "Cards passed ${direction.label}.", + ) + } + + private fun applyPlay(state: HeartsState, seat: Int, move: PlayCard): HeartsState { + require(state.phase == HeartsPhase.PLAYING) { "Not playing" } + require(state.turn == seat) { "Seat $seat is not on the clock" } + require(playableCards(state, seat).contains(move.card)) { "${move.card} is not playable" } + + val hands = state.hands.toMutableList() + hands[seat] = hands[seat] - move.card + val trick = state.trick + PlayedCard(seat, move.card) + val broken = state.heartsBroken || move.card.suit == Suit.HEARTS + + if (trick.size < state.options.playerCount) { + return state.copy( + hands = hands, + handCounts = hands.map { it.size }, + trick = trick, + completedTrick = emptyList(), + turn = (seat + 1) % state.options.playerCount, + heartsBroken = broken, + ) + } + + val led = trick.first().card.suit + val winner = trick.maxBy { trickStrength(it.card, led) }.seat + val taken = state.taken.toMutableList() + taken[winner] = taken[winner] + trick.map { it.card } + val swept = state.copy( + hands = hands, + handCounts = hands.map { it.size }, + trick = emptyList(), + completedTrick = trick, + taken = taken, + heartsBroken = broken, + turn = winner, + leader = winner, + trickNumber = state.trickNumber + 1, + ) + return if (hands.all { it.isEmpty() }) finishRound(swept) else swept + } + + private fun finishRound(state: HeartsState): HeartsState { + val gained = scoreRound(state.taken, state.options.allowShootTheMoon) + val scores = state.scores.mapIndexed { seat, score -> score + gained[seat] } + val moon = state.taken.indexOfFirst { pointsIn(it) == ALL_POINTS } + val over = scores.any { it >= state.options.targetScore } + val notes = buildList { + if (moon >= 0 && state.options.allowShootTheMoon) add("Seat $moon shot the moon.") + add("Round ${state.round + 1} scored.") + if (over) add("Game over — lowest score wins.") + } + return state.copy( + phase = if (over) HeartsPhase.GAME_OVER else HeartsPhase.ROUND_OVER, + scores = scores, + roundScores = gained, + log = state.log + notes, + ) + } + + /** Advances from [HeartsPhase.ROUND_OVER] into the next deal. */ + fun startNextRound(state: HeartsState): HeartsState { + check(state.phase == HeartsPhase.ROUND_OVER) { "Round is not over" } + val next = state.round + 1 + return deal(state.options, state.seed, next, state.scores, state.log + "Round ${next + 1}.") + } + + override fun isFinished(state: HeartsState): Boolean = state.phase == HeartsPhase.GAME_OVER + + override fun summary(state: HeartsState): String = when (state.phase) { + HeartsPhase.PASSING -> "Passing ${state.passDirection.label}" + HeartsPhase.PLAYING -> "Trick ${state.trickNumber + 1} of ${state.tricksPerRound}" + HeartsPhase.ROUND_OVER -> "Round ${state.round + 1}: " + state.roundScores.joinToString(", ") + HeartsPhase.GAME_OVER -> { + val best = state.scores.min() + "Winner: seat ${state.scores.indexOf(best)} on $best" + } + } + + /** + * Other hands go, and so do the cards they have chosen to pass — a pass is + * secret until it lands, and a client that could read the selections would + * know exactly what is coming. + */ + override fun viewFor(state: HeartsState, seat: Int): HeartsState = state.copy( + hands = state.hands.mapIndexed { index, hand -> if (index == seat) hand else emptyList() }, + handCounts = state.handCounts, + passSelections = state.passSelections.mapIndexed { index, cards -> + if (index == seat) cards else emptyList() + }, + ) + + override fun encodeState(state: HeartsState): String = json.encodeToString(state) + override fun decodeState(json: String): HeartsState = this.json.decodeFromString(json) + override fun encodeMove(move: HeartsMove): String = json.encodeToString(move) + override fun decodeMove(json: String): HeartsMove = this.json.decodeFromString(json) + + /** Every distinct [size]-card selection from [cards], order ignored. */ + private fun combinations(cards: List, size: Int): List> { + if (size == 0) return listOf(emptyList()) + if (cards.size < size) return emptyList() + val result = mutableListOf>() + fun walk(start: Int, chosen: List) { + if (chosen.size == size) { + result.add(chosen) + return + } + // Stop early once too few cards remain to finish the selection. + for (i in start..cards.size - (size - chosen.size)) { + walk(i + 1, chosen + cards[i]) + } + } + walk(0, emptyList()) + return result + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/hearts/HeartsRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/hearts/HeartsRulesTest.kt new file mode 100644 index 0000000..db8b5d7 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/hearts/HeartsRulesTest.kt @@ -0,0 +1,332 @@ +package org.prolibertate.games.game.hearts + +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 HeartsRulesTest { + + private val json = Json { encodeDefaults = true } + + private fun config(options: HeartsOptions = HeartsOptions(), seed: Long = 7L) = TableConfig( + gameId = "hearts", + seats = (0 until options.playerCount).map { + PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) + }, + optionsJson = json.encodeToString(options), + seed = seed, + ) + + private fun c(rank: Rank, suit: Suit) = Card(rank, suit) + + /** A state part way through a round, with passing already done. */ + private fun playing( + hands: List>, + turn: Int = 0, + leader: Int = 0, + trick: List = emptyList(), + trickNumber: Int = 1, + heartsBroken: Boolean = false, + taken: List> = List(hands.size) { emptyList() }, + options: HeartsOptions = HeartsOptions(), + ) = HeartsState( + options = options, + seed = 1L, + round = 0, + phase = HeartsPhase.PLAYING, + hands = hands, + handCounts = hands.map { it.size }, + passSelections = List(hands.size) { emptyList() }, + turn = turn, + leader = leader, + trickNumber = trickNumber, + trick = trick, + taken = taken, + heartsBroken = heartsBroken, + scores = List(hands.size) { 0 }, + roundScores = List(hands.size) { 0 }, + log = emptyList(), + ) + + @Test + fun `a deal gives four hands of thirteen from one pack`() { + val state = HeartsRules.initialState(config()) + assertEquals(4, state.hands.size) + state.hands.forEach { assertEquals(13, it.size) } + val all = state.hands.flatten() + assertEquals(52, all.size) + assertEquals("no card is dealt twice", 52, all.distinct().size) + } + + @Test + fun `the pass direction cycles and the fourth round holds`() { + assertEquals(PassDirection.LEFT, PassDirection.forRound(0)) + assertEquals(PassDirection.RIGHT, PassDirection.forRound(1)) + assertEquals(PassDirection.ACROSS, PassDirection.forRound(2)) + assertEquals(PassDirection.HOLD, PassDirection.forRound(3)) + assertEquals(PassDirection.LEFT, PassDirection.forRound(4)) + } + + @Test + fun `a hold round opens straight onto the lead`() { + // Round three holds, so there is nothing to swap and play begins at once + // with whoever holds the two of clubs. + var state = HeartsRules.initialState(config()) + // Walk to round three through the public entry point rather than + // reaching into the private deal. + repeat(3) { + state = HeartsRules.startNextRound(state.copy(phase = HeartsPhase.ROUND_OVER)) + } + assertEquals(3, state.round) + assertEquals(PassDirection.HOLD, state.passDirection) + assertEquals(HeartsPhase.PLAYING, state.phase) + assertTrue(state.hands[state.turn].contains(TWO_OF_CLUBS)) + } + + @Test + fun `passing left moves three cards to the next seat`() { + var state = HeartsRules.initialState(config()) + assertEquals(HeartsPhase.PASSING, state.phase) + val given = (0 until 4).map { state.hands[it].take(PASS_SIZE) } + for (seat in 0 until 4) { + assertEquals(seat, HeartsRules.currentSeat(state)) + state = HeartsRules.applyMove(state, seat, PassCards(given[seat])) + } + assertEquals(HeartsPhase.PLAYING, state.phase) + for (seat in 0 until 4) { + val receiver = (seat + 1) % 4 + assertTrue( + "seat $receiver should hold what seat $seat passed", + state.hands[receiver].containsAll(given[seat]), + ) + assertFalse( + "seat $seat should not still hold what it passed", + state.hands[seat].any { given[seat].contains(it) }, + ) + } + state.hands.forEach { assertEquals(13, it.size) } + } + + @Test + fun `a pass must be three distinct cards from the hand`() { + val state = HeartsRules.initialState(config()) + val hand = state.hands[0] + assertThrows { HeartsRules.applyMove(state, 0, PassCards(hand.take(2))) } + assertThrows { HeartsRules.applyMove(state, 0, PassCards(listOf(hand[0], hand[0], hand[1]))) } + // A card this seat does not hold — it is in somebody else's thirteen. + val notHeld = state.hands[1].first() + assertThrows { HeartsRules.applyMove(state, 0, PassCards(listOf(notHeld) + hand.take(2))) } + } + + @Test + fun `the two of clubs opens the round`() { + val hands = listOf( + listOf(c(Rank.TWO, Suit.CLUBS), c(Rank.KING, Suit.SPADES)), + listOf(c(Rank.THREE, Suit.CLUBS), c(Rank.FOUR, Suit.HEARTS)), + listOf(c(Rank.FIVE, Suit.CLUBS), c(Rank.SIX, Suit.DIAMONDS)), + listOf(c(Rank.SEVEN, Suit.CLUBS), c(Rank.EIGHT, Suit.DIAMONDS)), + ) + val state = playing(hands, trickNumber = 0) + assertEquals(listOf(TWO_OF_CLUBS), HeartsRules.playableCards(state, 0)) + assertThrows { HeartsRules.applyMove(state, 0, PlayCard(c(Rank.KING, Suit.SPADES))) } + } + + @Test + fun `a seat holding the led suit must follow it`() { + val hands = listOf( + listOf(c(Rank.TWO, Suit.CLUBS)), + listOf(c(Rank.THREE, Suit.CLUBS), c(Rank.ACE, Suit.HEARTS)), + listOf(c(Rank.FIVE, Suit.CLUBS)), + listOf(c(Rank.SEVEN, Suit.CLUBS)), + ) + val state = playing( + hands, + turn = 1, + trick = listOf(PlayedCard(0, c(Rank.TWO, Suit.CLUBS))), + ) + assertEquals(listOf(c(Rank.THREE, Suit.CLUBS)), HeartsRules.playableCards(state, 1)) + } + + @Test + fun `no point card may be discarded on the first trick`() { + val hands = listOf( + listOf(c(Rank.TWO, Suit.CLUBS)), + // Void in clubs, holding the queen, a heart, and one harmless card. + listOf(QUEEN_OF_SPADES, c(Rank.ACE, Suit.HEARTS), c(Rank.NINE, Suit.DIAMONDS)), + listOf(c(Rank.FIVE, Suit.CLUBS)), + listOf(c(Rank.SEVEN, Suit.CLUBS)), + ) + val state = playing( + hands, + turn = 1, + trickNumber = 0, + trick = listOf(PlayedCard(0, c(Rank.TWO, Suit.CLUBS))), + ) + assertEquals(listOf(c(Rank.NINE, Suit.DIAMONDS)), HeartsRules.playableCards(state, 1)) + } + + @Test + fun `the first trick ban lifts when a seat holds nothing but points`() { + val hands = listOf( + listOf(c(Rank.TWO, Suit.CLUBS)), + listOf(QUEEN_OF_SPADES, c(Rank.ACE, Suit.HEARTS)), + listOf(c(Rank.FIVE, Suit.CLUBS)), + listOf(c(Rank.SEVEN, Suit.CLUBS)), + ) + val state = playing( + hands, + turn = 1, + trickNumber = 0, + trick = listOf(PlayedCard(0, c(Rank.TWO, Suit.CLUBS))), + ) + assertEquals( + setOf(QUEEN_OF_SPADES, c(Rank.ACE, Suit.HEARTS)), + HeartsRules.playableCards(state, 1).toSet(), + ) + } + + @Test + fun `hearts cannot be led until one has been played`() { + val hands = listOf( + listOf(c(Rank.ACE, Suit.HEARTS), c(Rank.THREE, Suit.CLUBS)), + listOf(c(Rank.FOUR, Suit.CLUBS)), + listOf(c(Rank.FIVE, Suit.CLUBS)), + listOf(c(Rank.SIX, Suit.CLUBS)), + ) + val unbroken = playing(hands, heartsBroken = false) + assertEquals(listOf(c(Rank.THREE, Suit.CLUBS)), HeartsRules.playableCards(unbroken, 0)) + + val broken = playing(hands, heartsBroken = true) + assertEquals(2, HeartsRules.playableCards(broken, 0).size) + } + + @Test + fun `hearts may be led when they are all that is left`() { + val hands = listOf( + listOf(c(Rank.ACE, Suit.HEARTS), c(Rank.TWO, Suit.HEARTS)), + listOf(c(Rank.FOUR, Suit.CLUBS)), + listOf(c(Rank.FIVE, Suit.CLUBS)), + listOf(c(Rank.SIX, Suit.CLUBS)), + ) + val state = playing(hands, heartsBroken = false) + assertEquals(2, HeartsRules.playableCards(state, 0).size) + } + + @Test + fun `the highest card of the led suit takes the trick`() { + val hands = listOf( + listOf(c(Rank.TWO, Suit.CLUBS)), + listOf(c(Rank.KING, Suit.CLUBS)), + // An ace off suit cannot win, however high. + listOf(c(Rank.ACE, Suit.DIAMONDS)), + listOf(c(Rank.FIVE, Suit.CLUBS)), + ) + var state = playing(hands) + state = HeartsRules.applyMove(state, 0, PlayCard(c(Rank.TWO, Suit.CLUBS))) + state = HeartsRules.applyMove(state, 1, PlayCard(c(Rank.KING, Suit.CLUBS))) + state = HeartsRules.applyMove(state, 2, PlayCard(c(Rank.ACE, Suit.DIAMONDS))) + state = HeartsRules.applyMove(state, 3, PlayCard(c(Rank.FIVE, Suit.CLUBS))) + assertEquals("king of clubs takes it", 1, state.leader) + assertEquals(4, state.taken[1].size) + } + + @Test + fun `playing a heart breaks them`() { + val hands = listOf( + listOf(c(Rank.TWO, Suit.CLUBS)), + listOf(c(Rank.ACE, Suit.HEARTS)), + listOf(c(Rank.FIVE, Suit.CLUBS)), + listOf(c(Rank.SEVEN, Suit.CLUBS)), + ) + var state = playing(hands) + assertFalse(state.heartsBroken) + state = HeartsRules.applyMove(state, 0, PlayCard(c(Rank.TWO, Suit.CLUBS))) + state = HeartsRules.applyMove(state, 1, PlayCard(c(Rank.ACE, Suit.HEARTS))) + assertTrue(state.heartsBroken) + } + + @Test + fun `hearts cost a point each and the queen costs thirteen`() { + assertEquals(1, pointsOf(c(Rank.TWO, Suit.HEARTS))) + assertEquals(1, pointsOf(c(Rank.ACE, Suit.HEARTS))) + assertEquals(13, pointsOf(QUEEN_OF_SPADES)) + assertEquals(0, pointsOf(c(Rank.KING, Suit.SPADES))) + assertEquals(0, pointsOf(c(Rank.ACE, Suit.DIAMONDS))) + // The whole pack is worth twenty-six and nothing more. + assertEquals(ALL_POINTS, org.prolibertate.games.game.cards.Decks.standard52().sumOf { pointsOf(it) }) + } + + @Test + fun `an ordinary round shares out twenty-six points`() { + val hearts = Rank.standard.map { Card(it, Suit.HEARTS) } + val taken = listOf( + hearts.take(6), + hearts.drop(6) + QUEEN_OF_SPADES, + emptyList(), + emptyList(), + ) + val scored = scoreRound(taken, allowShootTheMoon = true) + assertEquals(listOf(6, 20, 0, 0), scored) + assertEquals(ALL_POINTS, scored.sum()) + } + + @Test + fun `shooting the moon scores everyone else instead`() { + val everything = Rank.standard.map { Card(it, Suit.HEARTS) } + QUEEN_OF_SPADES + val taken = listOf(emptyList(), everything, emptyList(), emptyList()) + assertEquals(listOf(26, 0, 26, 26), scoreRound(taken, allowShootTheMoon = true)) + // Switched off, a moon is simply the worst hand at the table. + assertEquals(listOf(0, 26, 0, 0), scoreRound(taken, allowShootTheMoon = false)) + } + + @Test + fun `a client sees neither other hands nor what they are passing`() { + var state = HeartsRules.initialState(config()) + state = HeartsRules.applyMove(state, 0, PassCards(state.hands[0].take(PASS_SIZE))) + val view = HeartsRules.viewFor(state, 0) + assertEquals(state.hands[0], view.hands[0]) + assertTrue("other hands are stripped", view.hands.drop(1).all { it.isEmpty() }) + assertEquals("counts survive so backs can be drawn", state.handCounts, view.handCounts) + assertEquals(state.passSelections[0], view.passSelections[0]) + assertTrue(view.passSelections.drop(1).all { it.isEmpty() }) + } + + @Test + fun `state and moves survive a round trip`() { + val state = HeartsRules.initialState(config()) + val decoded = HeartsRules.decodeState(HeartsRules.encodeState(state)) + assertEquals(state, decoded) + + val pass: HeartsMove = PassCards(state.hands[0].take(PASS_SIZE)) + assertEquals(pass, HeartsRules.decodeMove(HeartsRules.encodeMove(pass))) + val play: HeartsMove = PlayCard(TWO_OF_CLUBS) + assertEquals(play, HeartsRules.decodeMove(HeartsRules.encodeMove(play))) + } + + @Test + fun `nobody is on the clock once the game is over`() { + val state = HeartsRules.initialState(config()).copy(phase = HeartsPhase.GAME_OVER) + assertNull(HeartsRules.currentSeat(state)) + assertTrue(HeartsRules.legalMoves(state, 0).isEmpty()) + assertTrue(HeartsRules.isFinished(state)) + } + + private fun assertThrows(block: () -> Unit) { + try { + block() + throw AssertionError("expected the move to be rejected") + } catch (expected: IllegalArgumentException) { + // The rules rejected it, which is the point. + } + } +} From 84a9b3ffd784eb5c741c7a46437e992754d41d35 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 05:16:57 +0000 Subject: [PATCH 02/16] Keep the Hearts id, which the rules engine needs to compile HeartsRules names GameCatalog.HEARTS as its gameId, so the constant has to exist even though the game is not in the catalogue yet. The id alone puts nothing in the menu -- `all` is what the menu is built from -- so this compiles the engine without offering a game that has no screen behind it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC --- app/src/main/java/org/prolibertate/games/game/Catalog.kt | 7 +++++++ 1 file changed, 7 insertions(+) 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 63a926b..20e3cea 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -34,6 +34,13 @@ object GameCatalog { const val WIZARD = "wizard" 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 SEQUENCE = "sequence" const val MASTERMIND = "mastermind" const val BACKGAMMON = "backgammon" From 51aa0c35186b438099f7d4d42b41a89b8f6d1b6a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 13:01:07 +0000 Subject: [PATCH 03/16] Peg solitaire, on five boards The tee game from a restaurant table, and four larger boards it generalises to. Two grid shapes, and the only difference between them is how many directions a peg can jump: a triangle staggers its rows so a hole has six neighbours, a square board has four. The jump, the goal and the dead end are the same either way, which is why one legalJumps serves all five. Hole counts are checked rather than asserted from memory -- 15, 21, 33 for the English cross, 37 for the shallower French cut, 41 for the diamond -- and a test walks every board from its opening to a standstill, which is what would catch a geometry that produced a board nobody can move on. PegJump carries only its ends. A jump is always two steps in one direction, so the hole in between is the only one it can be, and storing it as well would be a second copy of the same fact with the freedom to disagree. This is the first one-seat game in the app. GameRules took it without changes: one seat, always on the clock until the board runs out of jumps, and viewFor is the identity because there is nobody to hide anything from. The catalogue entry and the seat-count change it needs come with the screen. The AI never runs during an ordinary game, since the only seat belongs to the person playing. It is here because an empty seat has to be playable by something, and because keeping the pegs together is what a "suggest a jump" button would want. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC --- .../org/prolibertate/games/game/Catalog.kt | 7 + .../games/game/pegsolitaire/PegSolitaireAi.kt | 54 ++++ .../game/pegsolitaire/PegSolitaireModel.kt | 153 +++++++++++ .../game/pegsolitaire/PegSolitaireRules.kt | 127 +++++++++ .../pegsolitaire/PegSolitaireRulesTest.kt | 240 ++++++++++++++++++ 5 files changed, 581 insertions(+) create mode 100644 app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireAi.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireModel.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireRules.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireRulesTest.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 20e3cea..bf0b16d 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -41,6 +41,13 @@ object GameCatalog { * [all] when its screen lands. */ const val HEARTS = "hearts" + + /** + * Peg solitaire, likewise an id ahead of its screen. It is also the first + * game here played by one person, so it arrives with the seat-count change + * that allows it rather than before it. + */ + const val PEG_SOLITAIRE = "pegsolitaire" const val SEQUENCE = "sequence" const val MASTERMIND = "mastermind" const val BACKGAMMON = "backgammon" diff --git a/app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireAi.kt b/app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireAi.kt new file mode 100644 index 0000000..0b73946 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireAi.kt @@ -0,0 +1,54 @@ +package org.prolibertate.games.game.pegsolitaire + +import org.prolibertate.games.game.engine.GameAi + +/** + * Plays the board for the one seat. + * + * Nothing normally asks it to: the single seat belongs to the person holding the + * phone, so this never runs during an ordinary game. It exists because a seat + * that could be left empty has to be playable by something, and because a + * one-move-deep chooser is exactly what a "suggest a jump" button would want. + * + * The heuristic is the one every good peg player uses by hand: keep the pegs + * together. A jump that leaves more jumps behind it has not painted the board + * into a corner, and pulling toward the middle stops a stranded peg being left + * out on an arm where nothing can ever reach it. + */ +object PegSolitaireAi : GameAi { + + override fun chooseMove( + state: PegSolitaireState, + seat: Int, + legal: List, + ): PegSolitaireMove { + require(legal.isNotEmpty()) { "No jump available" } + val jumps = legal.filterIsInstance() + if (jumps.isEmpty()) return legal.first() + return jumps.maxBy { score(state, it) } + } + + private fun score(state: PegSolitaireState, jump: PegJump): Int { + val after = state.copy(pegs = state.pegs - jump.from - jump.over + jump.to) + // Solving outright beats every other consideration. + if (after.solved) return Int.MAX_VALUE + val followUps = PegSolitaireRules.legalJumps(after).size + return followUps * 10 - spread(after) + } + + /** + * How far the pegs sit from their own centre, in whole holes. + * + * A smaller number is a tighter cluster. Manhattan distance is the right + * measure on both board shapes here, because a jump moves two steps along one + * of the grid's own directions either way. + */ + private fun spread(state: PegSolitaireState): Int { + if (state.pegs.isEmpty()) return 0 + val meanRow = state.pegs.sumOf { it.row } / state.pegs.size + val meanCol = state.pegs.sumOf { it.col } / state.pegs.size + return state.pegs.sumOf { + kotlin.math.abs(it.row - meanRow) + kotlin.math.abs(it.col - meanCol) + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireModel.kt b/app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireModel.kt new file mode 100644 index 0000000..2112031 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireModel.kt @@ -0,0 +1,153 @@ +package org.prolibertate.games.game.pegsolitaire + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * A hole in the board, by row and column. + * + * Both geometries the boards come in are indexed this way, which is what lets + * one jump rule serve them all: a triangular row is simply shorter than the one + * below it, and a cross is a square with pieces bitten out of the corners. + */ +@Serializable +data class Hole(val row: Int, val col: Int) + +/** + * The boards this plays on. + * + * Two shapes of grid, and the difference between them is only how many + * directions a peg can jump in. On a triangle a hole has six neighbours, because + * the rows are staggered; on a square board it has four, because they are not. + * Everything else — the jump, the goal, the dead end — is the same game. + */ +@Serializable +enum class PegBoard(val label: String, val holeCount: Int) { + /** The tee game found on a restaurant table: five rows, fifteen holes. */ + TRIANGLE_15("Triangle of 15", 15), + TRIANGLE_21("Triangle of 21", 21), + + /** The classic cross: a seven by seven square with two-by-two corners cut. */ + ENGLISH_CROSS("English cross", 33), + + /** The continental board, with a shallower cut that leaves four more holes. */ + FRENCH_CROSS("French cross", 37), + DIAMOND_41("Diamond of 41", 41), + ; + + val isTriangular: Boolean get() = this == TRIANGLE_15 || this == TRIANGLE_21 + + /** Every hole on this board. */ + fun holes(): Set = when (this) { + TRIANGLE_15 -> triangle(5) + TRIANGLE_21 -> triangle(6) + // 49 less the four two-by-two corners. + ENGLISH_CROSS -> square(7) { r, c -> r in 2..4 || c in 2..4 } + // 49 less a three-hole triangle at each corner. + FRENCH_CROSS -> square(7) { r, c -> + val fromEdgeRow = minOf(r, 6 - r) + val fromEdgeCol = minOf(c, 6 - c) + fromEdgeRow + fromEdgeCol >= 2 + } + + DIAMOND_41 -> square(9) { r, c -> kotlin.math.abs(r - 4) + kotlin.math.abs(c - 4) <= 4 } + } + + /** + * The directions a jump may run in, as row and column steps. + * + * Diagonals are not among the four on a square board: a peg moves along a + * rank or a file and nowhere else, which is what makes the cross boards hard. + */ + fun directions(): List> = if (isTriangular) { + listOf(0 to -1, 0 to 1, -1 to -1, -1 to 0, 1 to 0, 1 to 1) + } else { + listOf(0 to -1, 0 to 1, -1 to 0, 1 to 0) + } + + /** Where the one empty hole sits when nothing else is chosen. */ + fun defaultStart(): Hole = when { + // The apex, which is how the tee game is set out on the table. + isTriangular -> Hole(0, 0) + this == DIAMOND_41 -> Hole(4, 4) + else -> Hole(3, 3) + } + + private fun triangle(rows: Int): Set = + (0 until rows).flatMap { r -> (0..r).map { c -> Hole(r, c) } }.toSet() + + private fun square(size: Int, keep: (Int, Int) -> Boolean): Set = + (0 until size).flatMap { r -> + (0 until size).mapNotNull { c -> Hole(r, c).takeIf { keep(r, c) } } + }.toSet() +} + +/** + * What counts as having won. + * + * Leaving one peg anywhere is the game as it is usually played. Leaving it in + * the hole the board started empty is the harder problem, and on the English + * cross it is the one the puzzle is famous for. + */ +@Serializable +enum class PegGoal(val label: String) { + ONE_PEG("Leave one peg"), + ONE_PEG_IN_START("Leave one peg, in the starting hole"), +} + +@Serializable +data class PegSolitaireOptions( + val board: PegBoard = PegBoard.TRIANGLE_15, + val goal: PegGoal = PegGoal.ONE_PEG, + /** + * The hole left empty at the start. Null takes the board's own default, + * which keeps a saved table valid if the default ever moves. + */ + val startEmpty: Hole? = null, +) { + /** The empty hole this table actually opens with. */ + fun start(): Hole = startEmpty ?: board.defaultStart() + + init { + require(board.holes().contains(start())) { + "${start()} is not a hole on the ${board.label}" + } + } +} + +@Serializable +data class PegSolitaireState( + val options: PegSolitaireOptions, + /** Which holes still hold a peg. Everything else on the board is empty. */ + val pegs: Set, + val jumps: Int, + /** Held so the screen can show what just happened. */ + val lastJump: PegJump? = null, + val log: List, +) { + val board: PegBoard get() = options.board + + val remaining: Int get() = pegs.size + + /** True once the board matches what the chosen goal asked for. */ + val solved: Boolean + get() = remaining == 1 && when (options.goal) { + PegGoal.ONE_PEG -> true + PegGoal.ONE_PEG_IN_START -> pegs.first() == options.start() + } +} + +@Serializable +sealed interface PegSolitaireMove + +/** + * A peg jumps a neighbour and lands two holes away, and the peg it jumped is + * taken off. [over] is not carried because a jump is always exactly two steps in + * one direction, so the hole between the ends is the only one it can be — + * storing it would be a second copy of the same fact, free to disagree. + */ +@Serializable +@SerialName("jump") +data class PegJump(val from: Hole, val to: Hole) : PegSolitaireMove { + val over: Hole get() = Hole((from.row + to.row) / 2, (from.col + to.col) / 2) +} diff --git a/app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireRules.kt b/app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireRules.kt new file mode 100644 index 0000000..e885805 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireRules.kt @@ -0,0 +1,127 @@ +package org.prolibertate.games.game.pegsolitaire + +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 + +/** + * Peg solitaire as a pure state machine. See RULES-pegsolitaire.md. + * + * The first game here played by one person, which the engine takes in its + * stride: there is one seat, it is always on the clock until the board runs out + * of jumps, and [viewFor] has nothing to hide because there is nobody to hide it + * from. + */ +object PegSolitaireRules : GameRules { + + override val gameId: String = GameCatalog.PEG_SOLITAIRE + + /** The one seat. Named rather than written as a bare 0 at each use. */ + const val SOLO_SEAT: Int = 0 + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + override fun initialState(config: TableConfig): PegSolitaireState { + val options = json.decodeFromString(config.optionsJson) + require(config.seats.size == 1) { + "Peg solitaire seats one, not ${config.seats.size}" + } + val start = options.start() + return PegSolitaireState( + options = options, + pegs = options.board.holes() - start, + jumps = 0, + lastJump = null, + log = listOf("${options.board.label}, opening at row ${start.row + 1}."), + ) + } + + /** + * Every jump available on the board as it stands. + * + * A jump needs three holes in a line: a peg to move, a peg beside it to take, + * and an empty hole beyond. Checking that the landing hole is on the board is + * what keeps a jump from running off a triangle's short row or out through a + * cross's missing corner. + */ + fun legalJumps(state: PegSolitaireState): List { + val holes = state.board.holes() + val directions = state.board.directions() + return state.pegs.flatMap { from -> + directions.mapNotNull { (dr, dc) -> + val over = Hole(from.row + dr, from.col + dc) + val to = Hole(from.row + 2 * dr, from.col + 2 * dc) + // `over` holding a peg already implies it is a hole, since a peg + // can only ever sit in one. + val ok = state.pegs.contains(over) && + holes.contains(to) && + !state.pegs.contains(to) + if (ok) PegJump(from, to) else null + } + } + } + + override fun currentSeat(state: PegSolitaireState): Int? = + if (isFinished(state)) null else SOLO_SEAT + + override fun legalMoves(state: PegSolitaireState, seat: Int): List = + if (seat != SOLO_SEAT) emptyList() else legalJumps(state) + + override fun applyMove( + state: PegSolitaireState, + seat: Int, + move: PegSolitaireMove, + ): PegSolitaireState { + require(seat == SOLO_SEAT) { "Peg solitaire has one seat" } + val jump = move as PegJump + require(legalJumps(state).contains(jump)) { "$jump is not a legal jump" } + val pegs = state.pegs - jump.from - jump.over + jump.to + val next = state.copy( + pegs = pegs, + jumps = state.jumps + 1, + lastJump = jump, + ) + val note = when { + next.solved -> "Solved in ${next.jumps} jumps." + legalJumps(next).isEmpty() -> "No jumps left, ${next.remaining} pegs standing." + else -> null + } + return if (note == null) next else next.copy(log = next.log + note) + } + + /** The board is finished when nothing can jump, whether or not it was solved. */ + override fun isFinished(state: PegSolitaireState): Boolean = legalJumps(state).isEmpty() + + override fun summary(state: PegSolitaireState): String = when { + state.solved -> "Solved in ${state.jumps} jumps" + isFinished(state) -> "Stuck with ${state.remaining} pegs" + else -> "${state.remaining} pegs, ${legalJumps(state).size} jumps available" + } + + /** + * Nothing to strip. One person plays, the whole board is face up in front of + * them, and there is no second seat that could be told less than the truth. + */ + override fun viewFor(state: PegSolitaireState, seat: Int): PegSolitaireState = state + + override fun encodeState(state: PegSolitaireState): String = json.encodeToString(state) + override fun decodeState(json: String): PegSolitaireState = this.json.decodeFromString(json) + override fun encodeMove(move: PegSolitaireMove): String = json.encodeToString(move) + override fun decodeMove(json: String): PegSolitaireMove = this.json.decodeFromString(json) + + /** Deals the same board again, so a lost game can be retried from the start. */ + fun restart(state: PegSolitaireState): PegSolitaireState { + val start = state.options.start() + return state.copy( + pegs = state.options.board.holes() - start, + jumps = 0, + lastJump = null, + log = state.log + "Board reset.", + ) + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireRulesTest.kt new file mode 100644 index 0000000..387bde0 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireRulesTest.kt @@ -0,0 +1,240 @@ +package org.prolibertate.games.game.pegsolitaire + +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 PegSolitaireRulesTest { + + private val json = Json { encodeDefaults = true } + + private fun config(options: PegSolitaireOptions = PegSolitaireOptions()) = TableConfig( + gameId = "pegsolitaire", + seats = listOf(PlayerSlot(seat = 0, name = "P0", kind = PlayerKind.HUMAN_LOCAL, team = 0)), + optionsJson = json.encodeToString(options), + seed = 1L, + ) + + @Test + fun `every board has the number of holes it claims`() { + PegBoard.entries.forEach { board -> + assertEquals(board.label, board.holeCount, board.holes().size) + } + } + + @Test + fun `a triangle staggers its rows and a cross does not`() { + // Six ways off a hole on a triangle, four on a square grid. This is the + // only thing that differs between the two shapes. + assertEquals(6, PegBoard.TRIANGLE_15.directions().size) + assertEquals(6, PegBoard.TRIANGLE_21.directions().size) + assertEquals(4, PegBoard.ENGLISH_CROSS.directions().size) + assertEquals(4, PegBoard.FRENCH_CROSS.directions().size) + assertEquals(4, PegBoard.DIAMOND_41.directions().size) + // No diagonal creeps into a cross board. + assertTrue(PegBoard.ENGLISH_CROSS.directions().all { (dr, dc) -> dr == 0 || dc == 0 }) + } + + @Test + fun `the cross boards are cut where they should be`() { + val english = PegBoard.ENGLISH_CROSS.holes() + assertFalse("a two-by-two corner is gone", english.contains(Hole(0, 0))) + assertFalse(english.contains(Hole(1, 1))) + assertTrue(english.contains(Hole(0, 3))) + assertTrue(english.contains(Hole(3, 3))) + + val french = PegBoard.FRENCH_CROSS.holes() + // The shallower cut takes three holes per corner, so (1,1) survives. + assertFalse(french.contains(Hole(0, 0))) + assertFalse(french.contains(Hole(0, 1))) + assertFalse(french.contains(Hole(1, 0))) + assertTrue("the French cut leaves this one", french.contains(Hole(1, 1))) + + val diamond = PegBoard.DIAMOND_41.holes() + assertTrue(diamond.contains(Hole(0, 4))) + assertTrue(diamond.contains(Hole(4, 0))) + assertFalse(diamond.contains(Hole(0, 0))) + } + + @Test + fun `a board opens one peg short`() { + val state = PegSolitaireRules.initialState(config()) + assertEquals(PegBoard.TRIANGLE_15.holeCount - 1, state.remaining) + assertFalse("the opening hole is empty", state.pegs.contains(Hole(0, 0))) + assertEquals(0, state.jumps) + assertNull(state.lastJump) + } + + @Test + fun `the tee game opens with exactly two jumps, both into the apex`() { + val state = PegSolitaireRules.initialState(config()) + val jumps = PegSolitaireRules.legalJumps(state) + assertEquals(2, jumps.size) + assertTrue("every opening jump lands in the empty hole", jumps.all { it.to == Hole(0, 0) }) + assertEquals(setOf(Hole(2, 0), Hole(2, 2)), jumps.map { it.from }.toSet()) + // The jumped peg is the one between the ends, and it is never invented. + assertEquals(setOf(Hole(1, 0), Hole(1, 1)), jumps.map { it.over }.toSet()) + } + + @Test + fun `a jump moves one peg and takes the one it passed`() { + val state = PegSolitaireRules.initialState(config()) + val jump = PegJump(from = Hole(2, 0), to = Hole(0, 0)) + val after = PegSolitaireRules.applyMove(state, 0, jump) + assertFalse("the mover left", after.pegs.contains(Hole(2, 0))) + assertFalse("the jumped peg was taken", after.pegs.contains(Hole(1, 0))) + assertTrue("the mover arrived", after.pegs.contains(Hole(0, 0))) + assertEquals(state.remaining - 1, after.remaining) + assertEquals(1, after.jumps) + assertEquals(jump, after.lastJump) + } + + @Test + fun `a jump needs a peg to pass and an empty hole to land in`() { + val state = PegSolitaireRules.initialState(config()) + // Landing hole already occupied. + assertThrows { PegSolitaireRules.applyMove(state, 0, PegJump(Hole(4, 0), Hole(2, 0))) } + // Nothing to jump over: (1,0) is empty only after a jump, but (0,0) is + // the opening hole, so a jump starting there has no peg to move. + assertThrows { PegSolitaireRules.applyMove(state, 0, PegJump(Hole(0, 0), Hole(2, 0))) } + // Straight off the board. + assertThrows { PegSolitaireRules.applyMove(state, 0, PegJump(Hole(2, 0), Hole(2, 4))) } + } + + @Test + fun `only the one seat may play`() { + val state = PegSolitaireRules.initialState(config()) + assertEquals(0, PegSolitaireRules.currentSeat(state)) + assertTrue(PegSolitaireRules.legalMoves(state, 1).isEmpty()) + assertThrows { PegSolitaireRules.applyMove(state, 1, PegJump(Hole(2, 0), Hole(0, 0))) } + } + + @Test + fun `a table for anything but one seat is refused`() { + val twoSeats = TableConfig( + gameId = "pegsolitaire", + seats = (0..1).map { + PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) + }, + optionsJson = json.encodeToString(PegSolitaireOptions()), + seed = 1L, + ) + assertThrows { PegSolitaireRules.initialState(twoSeats) } + } + + @Test + fun `one peg left is solved, and the stricter goal asks where`() { + val loose = PegSolitaireOptions(goal = PegGoal.ONE_PEG) + val strict = PegSolitaireOptions(goal = PegGoal.ONE_PEG_IN_START) + val base = PegSolitaireRules.initialState(config(loose)) + + val elsewhere = base.copy(pegs = setOf(Hole(4, 2))) + assertTrue(elsewhere.solved) + assertFalse("the strict goal wants it back where it started", elsewhere.copy(options = strict).solved) + + val atStart = base.copy(options = strict, pegs = setOf(Hole(0, 0))) + assertTrue("the survivor is in the opening hole", atStart.solved) + } + + @Test + fun `a board with no jumps left is finished, solved or not`() { + val state = PegSolitaireRules.initialState(config()) + // Two pegs far apart cannot reach each other, so nothing can jump. + val stuck = state.copy(pegs = setOf(Hole(4, 0), Hole(4, 4))) + assertTrue(PegSolitaireRules.isFinished(stuck)) + assertFalse(stuck.solved) + assertNull(PegSolitaireRules.currentSeat(stuck)) + assertTrue(PegSolitaireRules.summary(stuck).contains("Stuck")) + + val won = state.copy(pegs = setOf(Hole(2, 1))) + assertTrue(PegSolitaireRules.isFinished(won)) + assertTrue(won.solved) + } + + @Test + fun `an opening hole has to be a hole on the board`() { + assertThrows { PegSolitaireOptions(board = PegBoard.TRIANGLE_15, startEmpty = Hole(9, 9)) } + // A hole that exists on one board but not another. + assertThrows { + PegSolitaireOptions(board = PegBoard.ENGLISH_CROSS, startEmpty = Hole(0, 0)) + } + } + + @Test + fun `every board can be opened and played`() { + // Each board deals, offers jumps, and runs to a finish without the rules + // throwing. This is what catches a geometry that produces a board nobody + // can move on at all. + PegBoard.entries.forEach { board -> + val state = PegSolitaireRules.initialState( + config(PegSolitaireOptions(board = board)), + ) + assertEquals(board.holeCount - 1, state.remaining) + assertTrue( + "${board.label} should have an opening jump", + PegSolitaireRules.legalJumps(state).isNotEmpty(), + ) + val end = playOut(state) + assertTrue(PegSolitaireRules.isFinished(end)) + assertTrue("${board.label} should take pegs off", end.remaining < state.remaining) + } + } + + @Test + fun `restart puts every peg back`() { + val state = PegSolitaireRules.initialState(config()) + val played = PegSolitaireRules.applyMove(state, 0, PegJump(Hole(2, 0), Hole(0, 0))) + val again = PegSolitaireRules.restart(played) + assertEquals(state.pegs, again.pegs) + assertEquals(0, again.jumps) + assertNull(again.lastJump) + } + + @Test + fun `nothing is hidden from the only player`() { + val state = PegSolitaireRules.initialState(config()) + assertEquals(state, PegSolitaireRules.viewFor(state, 0)) + } + + @Test + fun `state and moves survive a round trip`() { + PegBoard.entries.forEach { board -> + val state = PegSolitaireRules.initialState( + config(PegSolitaireOptions(board = board, goal = PegGoal.ONE_PEG_IN_START)), + ) + assertEquals(state, PegSolitaireRules.decodeState(PegSolitaireRules.encodeState(state))) + } + val move: PegSolitaireMove = PegJump(Hole(2, 0), Hole(0, 0)) + assertEquals(move, PegSolitaireRules.decodeMove(PegSolitaireRules.encodeMove(move))) + } + + /** Runs a board to a standstill using the AI, which never has to guess. */ + private fun playOut(from: PegSolitaireState): PegSolitaireState { + var state = from + // Each jump takes exactly one peg off, so the board cannot outlast its + // own peg count — a loop that did would be a bug in applyMove. + var guard = from.remaining + 1 + while (!PegSolitaireRules.isFinished(state) && guard-- > 0) { + val legal = PegSolitaireRules.legalMoves(state, 0) + state = PegSolitaireRules.applyMove(state, 0, PegSolitaireAi.chooseMove(state, 0, legal)) + } + assertTrue("playing out should terminate", guard > 0) + return state + } + + private fun assertThrows(block: () -> Unit) { + try { + block() + throw AssertionError("expected the rules to reject that") + } catch (expected: IllegalArgumentException) { + // Rejected, which is the point. + } + } +} From 434ea86605feceafcddd4fa9278b79df04e1893d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 14:13:10 +0000 Subject: [PATCH 04/16] Screens for Hearts and peg solitaire, and a seat for one Both engines had been sitting behind ids with no screen. This gives them one each and puts them in the catalogue. Peg solitaire is the first game here played alone, and almost nothing had to move for it. seatCountFor already fell through to minPlayers, so one seat came free; CatalogTest's floor of two players is the only invariant that had to go, and it went to one. The setup screen grows a solo path instead: a one-seat game has nobody to play against and no seat anyone could join, so it offers "Play" and not "Host a game for others to join", and the menu says "On your own" rather than "1 players". Playing again cannot go through advanceIdle. driveIdleSeats returns the moment rules.isFinished is true, before it ever reaches the confirmation branch, and a peg board with no jumps left genuinely is finished -- unlike a Wizard round, which is only between deals. Rather than bend isFinished into meaning something else, the screen rebuilds the controller behind a generation counter. The board is deterministic, so the retry is the same puzzle rather than a new one. One board layout serves both geometries. Each hole is placed in hole-widths rather than pixels and a triangle offsets its rows by half a hole, which is the same staggering that gives a triangular hole six neighbours instead of four. The Hearts screen reads the phase because two different things happen at it: choosing three cards to give away, which is a multiple selection nothing else in the app does, and then playing one card at a time like every other trick screen here. Passing is capped at three by the picker rather than by the error the rules would otherwise raise. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC --- .../org/prolibertate/games/game/Catalog.kt | 30 +- .../java/org/prolibertate/games/ui/AppRoot.kt | 14 +- .../prolibertate/games/ui/GameSetupScreen.kt | 82 +++++- .../org/prolibertate/games/ui/PlayScreen.kt | 73 +++++ .../games/ui/game/HeartsScreen.kt | 249 +++++++++++++++++ .../games/ui/game/PegSolitaireScreen.kt | 259 ++++++++++++++++++ .../prolibertate/games/game/CatalogTest.kt | 8 +- 7 files changed, 693 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/HeartsScreen.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/PegSolitaireScreen.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 bf0b16d..840b2fe 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -41,12 +41,6 @@ object GameCatalog { * [all] when its screen lands. */ const val HEARTS = "hearts" - - /** - * Peg solitaire, likewise an id ahead of its screen. It is also the first - * game here played by one person, so it arrives with the seat-count change - * that allows it rather than before it. - */ const val PEG_SOLITAIRE = "pegsolitaire" const val SEQUENCE = "sequence" const val MASTERMIND = "mastermind" @@ -130,6 +124,17 @@ object GameCatalog { "twice round the board to 121.", available = true, ), + GameDescriptor( + id = HEARTS, + title = "Hearts", + category = GameCategory.CARD, + minPlayers = 4, + maxPlayers = 4, + teamBased = false, + blurb = "Take no trick worth taking. Every heart costs a point, the queen " + + "of spades costs thirteen, and taking all of them costs everybody else.", + available = true, + ), GameDescriptor( id = SEQUENCE, title = "Sequence", @@ -191,6 +196,19 @@ object GameCatalog { blurb = "Three in a row takes a piece. Reduce them to two and the game is yours.", available = true, ), + GameDescriptor( + id = PEG_SOLITAIRE, + title = "Peg Solitaire", + category = GameCategory.BOARD, + // The first game here played alone. Everything downstream reads the + // seat count from these two numbers, so one seat is all it takes. + minPlayers = 1, + maxPlayers = 1, + teamBased = false, + blurb = "The tee game from the table, and four larger boards. Jump a peg, " + + "take the one you passed, and try to finish with one standing.", + available = true, + ), GameDescriptor( id = PIRATES, title = "Pirates and Bulgars", diff --git a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt index e7ae875..e140fc3 100644 --- a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt +++ b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt @@ -297,14 +297,12 @@ private fun GameTile(game: GameDescriptor, onClick: () -> Unit) { modifier = Modifier.padding(top = 4.dp), ) Text( - text = if (game.available) { - if (game.minPlayers == game.maxPlayers) { - "${game.minPlayers} players" - } else { - "${game.minPlayers}–${game.maxPlayers} players" - } - } else { - "Coming soon" + text = when { + !game.available -> "Coming soon" + // A puzzle for one, rather than "1 players". + game.maxPlayers == 1 -> "On your own" + game.minPlayers == game.maxPlayers -> "${game.minPlayers} players" + else -> "${game.minPlayers}–${game.maxPlayers} players" }, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(top = 8.dp), 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 1e03faa..1fe2edf 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -40,6 +40,10 @@ import org.prolibertate.games.game.crazyeights.CrazyEightsOptions import org.prolibertate.games.game.cribbage.CribbageOptions import org.prolibertate.games.game.euchre.EuchreOptions import org.prolibertate.games.game.golf.GolfOptions +import org.prolibertate.games.game.hearts.HeartsOptions +import org.prolibertate.games.game.pegsolitaire.PegBoard +import org.prolibertate.games.game.pegsolitaire.PegGoal +import org.prolibertate.games.game.pegsolitaire.PegSolitaireOptions import org.prolibertate.games.game.kaiser.KaiserOptions import org.prolibertate.games.game.mastermind.MastermindLevel import org.prolibertate.games.game.mastermind.MastermindOptions @@ -88,6 +92,8 @@ fun GameSetupScreen( var backgammon by remember { mutableStateOf(BackgammonOptions()) } var mastermind by remember { mutableStateOf(MastermindOptions()) } var pirates by remember { mutableStateOf(PiratesOptions()) } + var hearts by remember { mutableStateOf(HeartsOptions()) } + var pegs by remember { mutableStateOf(PegSolitaireOptions()) } // 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) } @@ -111,6 +117,8 @@ fun GameSetupScreen( GameCatalog.BACKGAMMON -> setupJson.encodeToString(backgammon) GameCatalog.MASTERMIND -> setupJson.encodeToString(mastermind) GameCatalog.PIRATES -> setupJson.encodeToString(pirates) + GameCatalog.HEARTS -> setupJson.encodeToString(hearts) + GameCatalog.PEG_SOLITAIRE -> setupJson.encodeToString(pegs) else -> "{}" } val seatCount = seatCountFor(descriptor.id, optionsJson) @@ -162,15 +170,27 @@ fun GameSetupScreen( onSide = { playPirates = it }, ) + GameCatalog.HEARTS -> HeartsOptionsEditor(hearts) { hearts = it } + + GameCatalog.PEG_SOLITAIRE -> PegSolitaireOptionsEditor(pegs) { pegs = it } + else -> Text("No options yet for this game.") } Divider() - Text("Opponents", fontWeight = FontWeight.Bold) + // A one-seat game has nobody to play against and no seat anyone + // could join, so it is offered as itself rather than as a table + // with every other chair filled by the computer. + val solo = seatCount == 1 + Text(if (solo) "On your own" else "Opponents", fontWeight = FontWeight.Bold) Text( - text = "Play now and every other seat is taken by the computer. Host a game " + - "instead and people can claim those seats as they join — whatever is " + - "still empty when you start stays computer-controlled.", + text = if (solo) { + "A puzzle for one. There is nobody to wait for and nothing to host." + } else { + "Play now and every other seat is taken by the computer. Host a game " + + "instead and people can claim those seats as they join — whatever is " + + "still empty when you start stays computer-controlled." + }, style = MaterialTheme.typography.bodySmall, ) @@ -182,14 +202,16 @@ fun GameSetupScreen( descriptor.id == GameCatalog.PIRATES && playPirates -> 1 else -> 0 } - PrimaryAction(text = "Play against the computer") { + PrimaryAction(text = if (solo) "Play" else "Play against the computer") { onPlayOffline( offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat), localSeat, ) } - PrimaryAction(text = "Host a game for others to join") { - onHostOnline(optionsJson) + if (!solo) { + PrimaryAction(text = "Host a game for others to join") { + onHostOnline(optionsJson) + } } } } @@ -944,3 +966,49 @@ private fun ToggleRow( Switch(checked = checked, onCheckedChange = onChange) } } + +@Composable +private fun HeartsOptionsEditor(options: HeartsOptions, onChange: (HeartsOptions) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ChipRow( + label = "Game ends at", + values = listOf(50, 100), + selected = options.targetScore, + display = { "$it points" }, + onSelect = { onChange(options.copy(targetScore = it)) }, + ) + ToggleRow( + title = "Shooting the moon", + subtitle = "Take all twenty-six and score nothing, while everyone else takes " + + "the lot. Switched off, a moon is simply the worst hand at the table.", + checked = options.allowShootTheMoon, + onChange = { onChange(options.copy(allowShootTheMoon = it)) }, + ) + } +} + +@Composable +private fun PegSolitaireOptionsEditor( + options: PegSolitaireOptions, + onChange: (PegSolitaireOptions) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ChipRow( + label = "Board", + values = PegBoard.entries.toList(), + selected = options.board, + display = { it.label }, + // The opening hole is cleared as well: a hole that exists on the old + // board may be a missing corner on the new one, and the options + // refuse to be built that way. + onSelect = { onChange(options.copy(board = it, startEmpty = null)) }, + ) + ChipRow( + label = "Finish", + values = PegGoal.entries.toList(), + selected = options.goal, + display = { it.label }, + onSelect = { onChange(options.copy(goal = 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 56ecd49..334f578 100644 --- a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt @@ -4,7 +4,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.runtime.rememberCoroutineScope import org.prolibertate.games.game.GameCatalog import org.prolibertate.games.game.chess.ChessAi @@ -26,6 +29,15 @@ 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.hearts.HeartsAi +import org.prolibertate.games.game.hearts.HeartsMove +import org.prolibertate.games.game.hearts.HeartsPhase +import org.prolibertate.games.game.hearts.HeartsRules +import org.prolibertate.games.game.hearts.HeartsState +import org.prolibertate.games.game.pegsolitaire.PegSolitaireAi +import org.prolibertate.games.game.pegsolitaire.PegSolitaireMove +import org.prolibertate.games.game.pegsolitaire.PegSolitaireRules +import org.prolibertate.games.game.pegsolitaire.PegSolitaireState import org.prolibertate.games.game.golf.GolfAi import org.prolibertate.games.game.golf.GolfMove import org.prolibertate.games.game.golf.GolfPhase @@ -430,6 +442,67 @@ fun PlayScreen( PiratesScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) } + GameCatalog.HEARTS -> { + val controller = remember(route) { + MatchController( + rules = HeartsRules, + ai = HeartsAi, + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + advanceIdle = { state -> + if (state.phase == HeartsPhase.ROUND_OVER) { + HeartsRules.startNextRound(state) + } else { + null + } + }, + // What a round cost is read before the next deal. + awaitsConfirmation = { state -> state.phase == HeartsPhase.ROUND_OVER }, + aiThinkingMillis = { settings.scaled(700L) }, + holdBeforeNextMove = { state -> + if (state.completedTrick.isNotEmpty()) { + settings.scaled(TRICK_HOLD_MILLIS) + } else { + 0L + } + }, + ) + } + StartMatch(env, controller, route) + HeartsScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) + } + + GameCatalog.PEG_SOLITAIRE -> { + // Playing again means a fresh board rather than a fresh round, and + // it cannot go through advanceIdle: driveIdleSeats returns as soon + // as rules.isFinished is true, and a peg board with no jumps left + // genuinely is finished. Rebuilding the controller is the honest + // way to deal again, and the board is deterministic, so the retry + // is the same puzzle. + var generation by remember(route) { mutableIntStateOf(0) } + val controller = remember(route, generation) { + MatchController( + rules = PegSolitaireRules, + // Never consulted while a person holds the only seat. + ai = PegSolitaireAi, + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + ) + } + StartMatch(env, controller, route) + PegSolitaireScreen( + 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/HeartsScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/HeartsScreen.kt new file mode 100644 index 0000000..dd70ea6 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/HeartsScreen.kt @@ -0,0 +1,249 @@ +package org.prolibertate.games.ui.game + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.rememberScrollState +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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.mutableStateListOf +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.cards.Card +import org.prolibertate.games.game.hearts.HeartsMove +import org.prolibertate.games.game.hearts.HeartsPhase +import org.prolibertate.games.game.hearts.HeartsState +import org.prolibertate.games.game.hearts.PASS_SIZE +import org.prolibertate.games.game.hearts.PassCards +import org.prolibertate.games.game.hearts.PlayCard +import org.prolibertate.games.game.hearts.pointsOf +import org.prolibertate.games.net.MatchController +import org.prolibertate.games.ui.ScreenScaffold +import org.prolibertate.games.ui.common.PlayingCardView + +private const val TITLE = "Hearts" + +/** + * The Hearts table. + * + * Two quite different things happen at this screen, so it reads the phase and + * shows one or the other: choosing three cards to give away, which is a + * multiple selection nothing else in the app does, and then playing one card at + * a time, which every trick-taking screen here does the same way. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun HeartsScreen( + 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 awaiting by controller.awaitingConfirmation.collectAsState() + + val chosen = 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("Dealing…") } + abandoned?.let { LeftHeartsDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) EndHeartsDialog(onExit) { confirmingEnd = false } + } + return + } + + val hand = current.hands.getOrElse(localSeat) { emptyList() } + val passing = current.phase == HeartsPhase.PASSING + val alreadyPassed = current.passSelections.getOrElse(localSeat) { emptyList() }.isNotEmpty() + val playable = legal.filterIsInstance().map { it.card }.toSet() + + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = when { + current.phase == HeartsPhase.GAME_OVER -> "Game over — lowest score wins." + current.phase == HeartsPhase.ROUND_OVER -> "Round scored." + passing && alreadyPassed -> "Waiting for the others to choose…" + passing -> "Choose three to pass ${current.passDirection.label}" + playable.isEmpty() -> "Waiting…" + else -> "Your turn" + }, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Every heart costs a point and the queen of spades costs thirteen. " + + "Take none of them.", + style = MaterialTheme.typography.bodySmall, + ) + + TrickView(current) + ScoreRow(current, localSeat) + Divider() + + Text( + text = if (passing && !alreadyPassed) { + "Passing ${current.passDirection.label} — ${chosen.size} of $PASS_SIZE chosen" + } else { + "Your hand" + }, + style = MaterialTheme.typography.labelLarge, + ) + FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + hand.forEach { card -> + val selectable = passing && !alreadyPassed + PlayingCardView( + card = card, + width = 52.dp, + selected = chosen.contains(card), + enabled = if (selectable) true else playable.contains(card), + caption = pointsOf(card).takeIf { it > 0 }?.let { "−$it" }, + onClick = { + if (selectable) { + toggle(chosen, card) + } else if (playable.contains(card)) { + controller.submit(PlayCard(card)) + } + }, + ) + } + } + + if (passing && !alreadyPassed) { + Button( + enabled = chosen.size == PASS_SIZE, + onClick = { + controller.submit(PassCards(chosen.toList())) + chosen.clear() + }, + ) { Text("Pass these three") } + } + + if (awaiting) { + Button(onClick = controller::confirmAdvance) { Text("Next round") } + } + + current.log.takeLast(3).forEach { + Text(it, style = MaterialTheme.typography.bodySmall) + } + } + + abandoned?.let { LeftHeartsDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) EndHeartsDialog(onExit) { confirmingEnd = false } + } +} + +/** Adds or removes [card], never letting the selection run past three. */ +private fun toggle(chosen: SnapshotStateList, card: Card) { + if (chosen.contains(card)) { + chosen.remove(card) + } else if (chosen.size < PASS_SIZE) { + chosen.add(card) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun TrickView(state: HeartsState) { + // The trick just swept is held on the table for a moment, so show that when + // there is nothing in front of the players yet. + val showing = state.trick.ifEmpty { state.completedTrick } + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = if (state.trick.isEmpty() && showing.isNotEmpty()) { + "Last trick — taken by seat ${state.leader}" + } else { + "Trick ${state.trickNumber + 1} of ${state.tricksPerRound}" + }, + style = MaterialTheme.typography.labelLarge, + ) + if (showing.isEmpty()) { + Text("Nothing played yet.", style = MaterialTheme.typography.bodySmall) + } else { + FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + showing.forEach { played -> + PlayingCardView( + card = played.card, + width = 46.dp, + caption = "seat ${played.seat}", + ) + } + } + } + } +} + +@Composable +private fun ScoreRow(state: HeartsState, localSeat: Int) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + state.scores.forEachIndexed { seat, score -> + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = if (seat == localSeat) "You" else "Seat $seat", + style = MaterialTheme.typography.labelSmall, + fontWeight = if (seat == localSeat) FontWeight.Bold else FontWeight.Normal, + ) + Text("$score", fontWeight = FontWeight.Bold) + // What this round has cost so far, which is the number anybody + // actually watches while a hand is being played. + Text( + text = "+${state.roundScores.getOrElse(seat) { 0 }}", + style = MaterialTheme.typography.labelSmall, + ) + } + } + } +} + +@Composable +private fun EndHeartsDialog(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 LeftHeartsDialog(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/PegSolitaireScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/PegSolitaireScreen.kt new file mode 100644 index 0000000..bb27414 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/PegSolitaireScreen.kt @@ -0,0 +1,259 @@ +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.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +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.pegsolitaire.Hole +import org.prolibertate.games.game.pegsolitaire.PegBoard +import org.prolibertate.games.game.pegsolitaire.PegJump +import org.prolibertate.games.game.pegsolitaire.PegSolitaireMove +import org.prolibertate.games.game.pegsolitaire.PegSolitaireState +import org.prolibertate.games.net.MatchController +import org.prolibertate.games.ui.ScreenScaffold +import org.prolibertate.games.ui.theme.FeltGreenDark +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 = "Peg Solitaire" + +/** + * The peg board. + * + * One layout serves both board shapes. Each hole is placed at a position in + * hole-widths rather than pixels, and a triangle simply offsets its rows by half + * a hole so they stagger — which is the same thing that gives a triangular hole + * six neighbours instead of four. Scaling that grid to whatever width the phone + * has is then the only sizing decision left. + */ +@Composable +fun PegSolitaireScreen( + 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 selected 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("Setting up…") } + abandoned?.let { LeftBoardDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) EndBoardDialog(onExit) { confirmingEnd = false } + } + return + } + + val jumps = legal.filterIsInstance() + val movable = jumps.map { it.from }.toSet() + val landings = selected?.let { from -> jumps.filter { it.from == from } }.orEmpty() + val destinations = landings.map { it.to }.toSet() + val finished = jumps.isEmpty() + + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = when { + current.solved -> "Solved, in ${current.jumps} jumps." + finished -> "Stuck with ${current.remaining} pegs." + selected != null -> "Tap a ringed hole to jump into it." + else -> "Tap a peg that can jump." + }, + fontWeight = FontWeight.Bold, + ) + Text( + text = "${current.board.label}. ${current.remaining} pegs left, " + + "${current.jumps} jumps made.", + style = MaterialTheme.typography.bodySmall, + ) + + Board( + state = current, + selected = selected, + movable = movable, + destinations = destinations, + onTap = { hole -> + selected = when { + // Completing a jump takes priority: the tapped hole is + // one the selected peg was offered. + selected != null && destinations.contains(hole) -> { + controller.submit(PegJump(from = selected!!, to = hole)) + null + } + + movable.contains(hole) -> hole + else -> null + } + }, + ) + + if (finished) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onRestart) { Text("Play again") } + TextButton(onClick = onExit) { Text("Leave") } + } + } + + current.log.takeLast(3).forEach { + Text(it, style = MaterialTheme.typography.bodySmall) + } + } + + abandoned?.let { LeftBoardDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) EndBoardDialog(onExit) { confirmingEnd = false } + } +} + +@Composable +private fun Board( + state: PegSolitaireState, + selected: Hole?, + movable: Set, + destinations: Set, + onTap: (Hole) -> Unit, +) { + val board = state.board + val holes = remember(board) { board.holes().toList() } + val places = remember(board) { holes.associateWith { placeOf(board, it) } } + val xs = places.values.map { it.first } + val ys = places.values.map { it.second } + val minX = xs.min() + val minY = ys.min() + val spanX = xs.max() - minX + val spanY = ys.max() - minY + + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + // One extra hole-width so the last column has somewhere to sit. + val cell: Dp = maxWidth / (spanX + 1f) + Box( + modifier = Modifier + .fillMaxWidth() + .height(cell * (spanY + 1f)) + .background(FeltGreenDark), + ) { + holes.forEach { hole -> + val (x, y) = places.getValue(hole) + HoleView( + filled = state.pegs.contains(hole), + selected = hole == selected, + canMove = movable.contains(hole), + isDestination = destinations.contains(hole), + size = cell, + modifier = Modifier.offset(x = cell * (x - minX), y = cell * (y - minY)), + onTap = { onTap(hole) }, + ) + } + } + } +} + +/** + * Where a hole sits, in hole-widths from the board's top left. + * + * The half-hole shift on a triangle is the whole difference between the two + * geometries as far as drawing is concerned. + */ +private fun placeOf(board: PegBoard, hole: Hole): Pair = + if (board.isTriangular) { + (hole.col - hole.row / 2f) to hole.row.toFloat() + } else { + hole.col.toFloat() to hole.row.toFloat() + } + +@Composable +private fun HoleView( + filled: Boolean, + selected: Boolean, + canMove: Boolean, + isDestination: Boolean, + size: Dp, + modifier: Modifier, + onTap: () -> Unit, +) { + Box(modifier = modifier.size(size), contentAlignment = Alignment.Center) { + val diameter = size * 0.72f + val ring = when { + selected -> WallaceGold + isDestination -> WallaceGold + else -> Parchment.copy(alpha = 0.35f) + } + Box( + modifier = Modifier + .size(diameter) + .background( + color = if (filled) WallaceRed else FeltGreenDark, + shape = CircleShape, + ) + .border( + // A destination is drawn as an empty ring rather than filled, + // so it never reads as a peg already sitting there. + border = BorderStroke(if (selected || isDestination) 3.dp else 1.dp, ring), + shape = CircleShape, + ) + .clickable(enabled = filled && canMove || isDestination) { onTap() }, + ) + } +} + +@Composable +private fun EndBoardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) { + AlertDialog( + onDismissRequest = onKeepPlaying, + title = { Text("End this game?") }, + text = { Text("The board is left as it stands.") }, + confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } }, + dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } }, + ) +} + +@Composable +private fun LeftBoardDialog(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 board") } }, + ) +} 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 80766a4..9c44e39 100644 --- a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt @@ -71,6 +71,8 @@ class CatalogTest { GameCatalog.KAISER, GameCatalog.CRAZY_EIGHTS, GameCatalog.CRIBBAGE, + GameCatalog.HEARTS, + GameCatalog.PEG_SOLITAIRE, GameCatalog.WIZARD, GameCatalog.CHESS, GameCatalog.TAYU, @@ -87,7 +89,11 @@ class CatalogTest { @Test fun `player counts are sane`() { GameCatalog.all.forEach { game -> - assertTrue("${game.id} min players", game.minPlayers >= 2) + // One, not two. Peg solitaire is played alone, and the engine took + // that without changes — one seat, always on the clock, nothing to + // redact. Everything downstream reads the seat count from here, so + // this is the only place that had to allow it. + assertTrue("${game.id} min players", game.minPlayers >= 1) assertTrue("${game.id} max >= min", game.maxPlayers >= game.minPlayers) } } From 480f0ba1643b4ea5117ee1455ae6688cc6436cc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 14:19:43 +0000 Subject: [PATCH 05/16] Import the two new screens into PlayScreen They live in ui.game and PlayScreen is in ui, so naming them was not enough. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC --- app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt | 2 ++ 1 file changed, 2 insertions(+) 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 334f578..7c7b05c 100644 --- a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt @@ -93,11 +93,13 @@ import org.prolibertate.games.ui.game.CheckersScreen import org.prolibertate.games.ui.game.ChessScreen import org.prolibertate.games.ui.game.CrazyEightsScreen import org.prolibertate.games.ui.game.CribbageScreen +import org.prolibertate.games.ui.game.HeartsScreen 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.PiratesScreen import org.prolibertate.games.ui.game.PresidentScreen import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS From 68f8da0379eaa22ad7f37199623b90d805c0cd3b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 15:14:16 +0000 Subject: [PATCH 06/16] 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 07/16] 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 08/16] 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 09/16] 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 10/16] 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 { From 40ea68f4c0b455347850b3b909fa6e315ee3353e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 16:05:03 +0000 Subject: [PATCH 11/16] FreeCell, Spider and Pyramid, as rules Three more patiences, and they are three different games rather than three settings of one. FreeCell builds down in alternating colours with everything face up; Spider builds down in rank but only moves runs of one suit; Pyramid builds nothing at all and takes cards away in pairs that make thirteen. FreeCell's supermove is arithmetic rather than a rule. A run is really moved one card at a time through the cells and the spaces, so what can travel is what those could have held: one per free cell plus the card itself, doubled for every empty column usable as a staging pile. Moving into an empty column cannot also stage through it, which is the fiddly case and is tested on its own. Spider separates landing from travelling, which is the whole difficulty of the four-suit game: a card lands on anything a rank higher, but only a same-suit run moves as a unit. The pack is a hundred and four cards whatever the suit count -- eight copies of one suit, or two of each of four -- so the deal keeps its shape. Pyramid blanks the places it takes cards from rather than compacting them. A place is what tells the two cards above it that they are free, so an index that shifted would uncover the wrong cards. All three read the ace-low ordering from game.solitaire rather than defining it, which is the point of having put it there. The AI is deliberately dim -- it takes the first move on offer. A greedy patience player that looked clever would be worse, because it would be trusted as a hint, and a hint that walks a run back and forth is more annoying than no hint at all. Klondike has a real one because its move ordering is obvious; these three do not, and pretending otherwise would be the wrong kind of helpful. Ids only for now. Catalogue entries land with the screens. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC --- .../org/prolibertate/games/game/Catalog.kt | 3 + .../games/game/freecell/FreeCellModel.kt | 81 ++++ .../games/game/freecell/FreeCellRules.kt | 193 ++++++++++ .../games/game/pyramid/PyramidModel.kt | 98 +++++ .../games/game/pyramid/PyramidRules.kt | 150 ++++++++ .../games/game/solitaire/HintAi.kt | 24 ++ .../games/game/spider/SpiderModel.kt | 107 ++++++ .../games/game/spider/SpiderRules.kt | 177 +++++++++ .../game/solitaire/SolitaireRulesTest.kt | 351 ++++++++++++++++++ 9 files changed, 1184 insertions(+) create mode 100644 app/src/main/java/org/prolibertate/games/game/freecell/FreeCellModel.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/freecell/FreeCellRules.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/pyramid/PyramidModel.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/pyramid/PyramidRules.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/solitaire/HintAi.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/spider/SpiderModel.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/spider/SpiderRules.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/solitaire/SolitaireRulesTest.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 f96b203..14a48a8 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -40,6 +40,9 @@ object GameCatalog { const val YAHTZEE = "yahtzee" const val KLONDIKE = "klondike" + const val FREECELL = "freecell" + const val SPIDER = "spider" + const val PYRAMID = "pyramid" const val SEQUENCE = "sequence" const val MASTERMIND = "mastermind" const val BACKGAMMON = "backgammon" diff --git a/app/src/main/java/org/prolibertate/games/game/freecell/FreeCellModel.kt b/app/src/main/java/org/prolibertate/games/game/freecell/FreeCellModel.kt new file mode 100644 index 0000000..620c9e8 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/freecell/FreeCellModel.kt @@ -0,0 +1,81 @@ +package org.prolibertate.games.game.freecell + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.prolibertate.games.game.cards.Card + +const val COLUMNS: Int = 8 +const val FOUNDATIONS: Int = 4 + +@Serializable +enum class CellKind { CELL, TABLEAU, FOUNDATION } + +@Serializable +data class Place(val kind: CellKind, val index: Int) { + companion object { + fun cell(index: Int) = Place(CellKind.CELL, index) + fun column(index: Int) = Place(CellKind.TABLEAU, index) + fun foundation(index: Int) = Place(CellKind.FOUNDATION, index) + } +} + +@Serializable +data class FreeCellOptions( + /** + * Four cells is the game as everyone knows it. Fewer is the same game made + * hard, since the cells are the only place a card can wait. + */ + val freeCells: Int = 4, + /** + * Move a run in one gesture rather than a card at a time. The rules do not + * actually allow it — a run is moved by shuffling cards through the cells — + * but every computer version does it for you, because doing it by hand is + * bookkeeping rather than a decision. + */ + val allowSupermoves: Boolean = true, +) { + init { + require(freeCells in 1..6) { "One to six cells" } + } +} + +@Serializable +data class FreeCellState( + val options: FreeCellOptions, + val seed: Long, + /** Null where a cell is empty. */ + val cells: List, + val foundations: List>, + /** Every card is face up in FreeCell; there is nothing buried to turn. */ + val tableau: List>, + val moves: Int, + val log: List, +) { + val freeCellCount: Int get() = cells.count { it == null } + val emptyColumns: Int get() = tableau.count { it.isEmpty() } + val isWon: Boolean get() = foundations.all { it.size == 13 } +} + +@Serializable +sealed interface FreeCellMove + +@Serializable +@SerialName("move") +data class MoveTo(val from: Place, val to: Place, val count: Int = 1) : FreeCellMove + +/** + * How many cards can travel together. + * + * A run is really moved one card at a time through the free cells and the empty + * columns, so what can be shifted is what those could have held: one per free + * cell plus the card itself, doubled for every empty column that could be used + * as a staging pile. Moving *into* an empty column cannot also use it, which is + * the fiddly part and the reason [intoEmptyColumn] exists. + */ +fun maxRunLength(freeCells: Int, emptyColumns: Int, intoEmptyColumn: Boolean): Int { + val usable = if (intoEmptyColumn) emptyColumns - 1 else emptyColumns + if (usable < 0) return 1 + var limit = freeCells + 1 + repeat(usable) { limit *= 2 } + return limit +} diff --git a/app/src/main/java/org/prolibertate/games/game/freecell/FreeCellRules.kt b/app/src/main/java/org/prolibertate/games/game/freecell/FreeCellRules.kt new file mode 100644 index 0000000..c082c0c --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/freecell/FreeCellRules.kt @@ -0,0 +1,193 @@ +package org.prolibertate.games.game.freecell + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.prolibertate.games.game.GameCatalog +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.cards.Decks +import org.prolibertate.games.game.cards.shuffledWith +import org.prolibertate.games.game.engine.GameRules +import org.prolibertate.games.game.engine.TableConfig +import org.prolibertate.games.game.solitaire.acceptsOnFoundation +import org.prolibertate.games.game.solitaire.buildsDownAlternating +import org.prolibertate.games.game.solitaire.isRun +import kotlin.random.Random + +/** + * FreeCell as a pure state machine. See RULES-freecell.md. + * + * The whole pack is face up from the first move, so unlike Klondike there is + * nothing hidden and nothing to be lucky about — very nearly every deal can be + * won by someone who plays it well enough. + */ +object FreeCellRules : GameRules { + + override val gameId: String = GameCatalog.FREECELL + const val SOLO_SEAT: Int = 0 + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + override fun initialState(config: TableConfig): FreeCellState { + val options = json.decodeFromString(config.optionsJson) + require(config.seats.size == 1) { "FreeCell seats one, not ${config.seats.size}" } + return deal(options, config.seed) + } + + private fun deal(options: FreeCellOptions, seed: Long): FreeCellState { + val deck = Decks.standard52().shuffledWith(Random(seed)) + // Fifty-two into eight leaves four columns of seven and four of six. + val columns = mutableListOf>() + var at = 0 + repeat(COLUMNS) { index -> + val size = if (index < 4) 7 else 6 + columns += deck.subList(at, at + size).toList() + at += size + } + return FreeCellState( + options = options, + seed = seed, + cells = List(options.freeCells) { null }, + foundations = List(FOUNDATIONS) { emptyList() }, + tableau = columns, + moves = 0, + log = listOf("Dealt, with ${options.freeCells} cells."), + ) + } + + override fun currentSeat(state: FreeCellState): Int? = + if (isFinished(state)) null else SOLO_SEAT + + /** The longest run sitting on the end of a column, by the build rule. */ + fun movableTail(column: List): List { + if (column.isEmpty()) return emptyList() + var take = 1 + while (take < column.size) { + val candidate = column.takeLast(take + 1) + if (!isRun(candidate, ::buildsDownAlternating)) break + take++ + } + return column.takeLast(take) + } + + override fun legalMoves(state: FreeCellState, seat: Int): List { + if (seat != SOLO_SEAT || state.isWon) return emptyList() + val moves = mutableListOf() + + fun offer(from: Place, cards: List) { + val head = cards.first() + if (cards.size == 1) { + val foundation = head.suit.ordinal + if (acceptsOnFoundation(state.foundations[foundation], head)) { + moves += MoveTo(from, Place.foundation(foundation), 1) + } + state.cells.indices.forEach { cell -> + if (state.cells[cell] == null && from.kind != CellKind.CELL) { + moves += MoveTo(from, Place.cell(cell), 1) + } + } + } + state.tableau.indices.forEach { column -> + if (from.kind == CellKind.TABLEAU && from.index == column) return@forEach + val target = state.tableau[column] + val fits = target.lastOrNull()?.let { buildsDownAlternating(head, it) } ?: true + if (!fits) return@forEach + // Moving a whole column into an empty one is a no-op that would + // let a game shuffle for ever. + if (target.isEmpty() && from.kind == CellKind.TABLEAU && + cards.size == state.tableau[from.index].size + ) { + return@forEach + } + val allowed = maxRunLength( + freeCells = state.freeCellCount, + emptyColumns = state.emptyColumns, + intoEmptyColumn = target.isEmpty(), + ).let { if (state.options.allowSupermoves) it else 1 } + if (cards.size <= allowed) moves += MoveTo(from, Place.column(column), cards.size) + } + } + + state.cells.forEachIndexed { index, card -> + card?.let { offer(Place.cell(index), listOf(it)) } + } + state.tableau.forEachIndexed { index, column -> + val tail = movableTail(column) + // Every length of the tail run can travel, not just the whole of it. + for (count in 1..tail.size) { + offer(Place.column(index), tail.takeLast(count)) + } + } + return moves + } + + override fun applyMove(state: FreeCellState, seat: Int, move: FreeCellMove): FreeCellState { + require(seat == SOLO_SEAT) { "FreeCell has one seat" } + require(legalMoves(state, seat).contains(move)) { "$move is not legal here" } + move as MoveTo + val cards = when (move.from.kind) { + CellKind.CELL -> listOfNotNull(state.cells[move.from.index]) + CellKind.TABLEAU -> state.tableau[move.from.index].takeLast(move.count) + CellKind.FOUNDATION -> state.foundations[move.from.index].takeLast(move.count) + } + var next = when (move.from.kind) { + CellKind.CELL -> state.copy(cells = state.cells.replacing(move.from.index, null)) + CellKind.TABLEAU -> state.copy( + tableau = state.tableau.replacing( + move.from.index, + state.tableau[move.from.index].dropLast(move.count), + ), + ) + + CellKind.FOUNDATION -> state.copy( + foundations = state.foundations.replacing( + move.from.index, + state.foundations[move.from.index].dropLast(move.count), + ), + ) + } + next = when (move.to.kind) { + CellKind.CELL -> next.copy(cells = next.cells.replacing(move.to.index, cards.single())) + CellKind.TABLEAU -> next.copy( + tableau = next.tableau.replacing( + move.to.index, + next.tableau[move.to.index] + cards, + ), + ) + + CellKind.FOUNDATION -> next.copy( + foundations = next.foundations.replacing( + move.to.index, + next.foundations[move.to.index] + cards, + ), + ) + } + return next.copy( + moves = state.moves + 1, + log = if (next.isWon) next.log + "Out, in ${state.moves + 1} moves." else next.log, + ) + } + + private fun List.replacing(index: Int, value: T): List = + toMutableList().also { it[index] = value } + + override fun isFinished(state: FreeCellState): Boolean = + state.isWon || legalMoves(state, SOLO_SEAT).isEmpty() + + override fun summary(state: FreeCellState): 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" + } + + override fun viewFor(state: FreeCellState, seat: Int): FreeCellState = state + + override fun encodeState(state: FreeCellState): String = json.encodeToString(state) + override fun decodeState(json: String): FreeCellState = this.json.decodeFromString(json) + override fun encodeMove(move: FreeCellMove): String = json.encodeToString(move) + override fun decodeMove(json: String): FreeCellMove = this.json.decodeFromString(json) + + fun restart(state: FreeCellState): FreeCellState = deal(state.options, state.seed) +} diff --git a/app/src/main/java/org/prolibertate/games/game/pyramid/PyramidModel.kt b/app/src/main/java/org/prolibertate/games/game/pyramid/PyramidModel.kt new file mode 100644 index 0000000..59fcabd --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/pyramid/PyramidModel.kt @@ -0,0 +1,98 @@ +package org.prolibertate.games.game.pyramid + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.solitaire.patienceOrder + +/** Seven rows of one to seven cards. */ +const val ROWS: Int = 7 +const val PYRAMID_SIZE: Int = ROWS * (ROWS + 1) / 2 + +/** A pair has to make this, and a king makes it alone. */ +const val TARGET: Int = 13 + +@Serializable +enum class PyramidZone { PYRAMID, WASTE } + +@Serializable +data class PyramidSpot(val zone: PyramidZone, val index: Int = 0) + +@Serializable +data class PyramidOptions( + /** How many times the waste may be turned back over. */ + val redeals: Int = 2, +) { + init { + require(redeals >= 0) { "redeals cannot be negative" } + } +} + +@Serializable +data class PyramidState( + val options: PyramidOptions, + val seed: Long, + /** Twenty-eight places; null where the card has been taken. */ + val pyramid: List, + val stock: List, + val waste: List, + val redealsUsed: Int, + val moves: Int, + val log: List, +) { + val isWon: Boolean get() = pyramid.all { it == null } + + val canRedeal: Boolean + get() = stock.isEmpty() && waste.isNotEmpty() && redealsUsed < options.redeals +} + +@Serializable +sealed interface PyramidMove + +@Serializable +@SerialName("draw") +data object DrawCard : PyramidMove + +@Serializable +@SerialName("redeal") +data object RecycleWaste : PyramidMove + +/** A king is worth thirteen on its own, so it needs no partner. */ +@Serializable +@SerialName("king") +data class TakeKing(val spot: PyramidSpot) : PyramidMove + +@Serializable +@SerialName("pair") +data class TakePair(val first: PyramidSpot, val second: PyramidSpot) : PyramidMove + +/** Ace one through king thirteen, which is what the pairing is counted in. */ +fun valueOf(card: Card): Int = card.rank.patienceOrder + +/** Where a row starts in the flattened pyramid. */ +fun rowStart(row: Int): Int = row * (row + 1) / 2 + +/** Which row a place is in. */ +fun rowOf(index: Int): Int { + var row = 0 + while (rowStart(row + 1) <= index) row++ + return row +} + +/** + * The two places resting on [index], if any. + * + * A card is only in play once both of them are gone, which is the whole shape of + * the game: the pyramid has to be taken apart from the bottom. + */ +fun coveredBy(index: Int): List { + val row = rowOf(index) + if (row == ROWS - 1) return emptyList() + val offset = index - rowStart(row) + val below = rowStart(row + 1) + offset + return listOf(below, below + 1) +} + +/** Whether the card at [index] has nothing left sitting on it. */ +fun isExposed(pyramid: List, index: Int): Boolean = + pyramid[index] != null && coveredBy(index).all { pyramid[it] == null } diff --git a/app/src/main/java/org/prolibertate/games/game/pyramid/PyramidRules.kt b/app/src/main/java/org/prolibertate/games/game/pyramid/PyramidRules.kt new file mode 100644 index 0000000..98aed3d --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/pyramid/PyramidRules.kt @@ -0,0 +1,150 @@ +package org.prolibertate.games.game.pyramid + +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 + +/** + * Pyramid as a pure state machine. See RULES-pyramid.md. + * + * The odd one out among the patiences here: nothing is built and nothing goes + * home in order. Cards are taken away in pairs that make thirteen, and the only + * structure is which of them are still buried. + */ +object PyramidRules : GameRules { + + override val gameId: String = GameCatalog.PYRAMID + const val SOLO_SEAT: Int = 0 + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + override fun initialState(config: TableConfig): PyramidState { + val options = json.decodeFromString(config.optionsJson) + require(config.seats.size == 1) { "Pyramid seats one, not ${config.seats.size}" } + return deal(options, config.seed) + } + + private fun deal(options: PyramidOptions, seed: Long): PyramidState { + val deck = Decks.standard52().shuffledWith(Random(seed)) + return PyramidState( + options = options, + seed = seed, + pyramid = deck.take(PYRAMID_SIZE), + stock = deck.drop(PYRAMID_SIZE), + waste = emptyList(), + redealsUsed = 0, + moves = 0, + log = listOf("Dealt."), + ) + } + + override fun currentSeat(state: PyramidState): Int? = + if (isFinished(state)) null else SOLO_SEAT + + /** Every card that can be reached: the exposed pyramid, and the waste's top. */ + fun available(state: PyramidState): List> { + val fromPyramid = state.pyramid.indices + .filter { isExposed(state.pyramid, it) } + .map { PyramidSpot(PyramidZone.PYRAMID, it) to state.pyramid[it]!! } + val fromWaste = state.waste.lastOrNull() + ?.let { listOf(PyramidSpot(PyramidZone.WASTE) to it) } + .orEmpty() + return fromPyramid + fromWaste + } + + override fun legalMoves(state: PyramidState, seat: Int): List { + if (seat != SOLO_SEAT || state.isWon) return emptyList() + val moves = mutableListOf() + if (state.stock.isNotEmpty()) moves += DrawCard + if (state.canRedeal) moves += RecycleWaste + + val reachable = available(state) + reachable.forEach { (spot, card) -> + if (valueOf(card) == TARGET) moves += TakeKing(spot) + } + // Each pair once: the second card is only ever looked for after the + // first, so a pair cannot be offered both ways round. + for (i in reachable.indices) { + for (j in i + 1 until reachable.size) { + val (spotA, cardA) = reachable[i] + val (spotB, cardB) = reachable[j] + if (valueOf(cardA) + valueOf(cardB) == TARGET) { + moves += TakePair(spotA, spotB) + } + } + } + return moves + } + + override fun applyMove(state: PyramidState, seat: Int, move: PyramidMove): PyramidState { + require(seat == SOLO_SEAT) { "Pyramid has one seat" } + require(legalMoves(state, seat).contains(move)) { "$move is not legal here" } + val next = when (move) { + DrawCard -> state.copy( + stock = state.stock.dropLast(1), + waste = state.waste + state.stock.last(), + ) + + RecycleWaste -> state.copy( + stock = state.waste.reversed(), + waste = emptyList(), + redealsUsed = state.redealsUsed + 1, + log = state.log + "Waste turned back over.", + ) + + is TakeKing -> remove(state, listOf(move.spot)) + is TakePair -> remove(state, listOf(move.first, move.second)) + } + return next.copy( + moves = state.moves + 1, + log = if (next.isWon) next.log + "Pyramid cleared, in ${state.moves + 1} moves." else next.log, + ) + } + + /** + * Takes cards off the table. + * + * The pyramid places are blanked rather than compacted, because a place is + * what tells the two cards above it that they are free — an index that + * shifted would uncover the wrong cards. + */ + private fun remove(state: PyramidState, spots: List): PyramidState { + val pyramid = state.pyramid.toMutableList() + var waste = state.waste + spots.forEach { spot -> + when (spot.zone) { + PyramidZone.PYRAMID -> pyramid[spot.index] = null + // Only ever the top card, so dropping the last is enough. + PyramidZone.WASTE -> waste = waste.dropLast(1) + } + } + return state.copy(pyramid = pyramid.toList(), waste = waste) + } + + override fun isFinished(state: PyramidState): Boolean = + state.isWon || legalMoves(state, SOLO_SEAT).isEmpty() + + override fun summary(state: PyramidState): String = when { + state.isWon -> "Cleared in ${state.moves} moves" + isFinished(state) -> "Blocked, ${state.pyramid.count { it != null }} cards left" + else -> "${state.pyramid.count { it != null }} of $PYRAMID_SIZE left" + } + + override fun viewFor(state: PyramidState, seat: Int): PyramidState = state + + override fun encodeState(state: PyramidState): String = json.encodeToString(state) + override fun decodeState(json: String): PyramidState = this.json.decodeFromString(json) + override fun encodeMove(move: PyramidMove): String = json.encodeToString(move) + override fun decodeMove(json: String): PyramidMove = this.json.decodeFromString(json) + + fun restart(state: PyramidState): PyramidState = deal(state.options, state.seed) +} diff --git a/app/src/main/java/org/prolibertate/games/game/solitaire/HintAi.kt b/app/src/main/java/org/prolibertate/games/game/solitaire/HintAi.kt new file mode 100644 index 0000000..682067e --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/solitaire/HintAi.kt @@ -0,0 +1,24 @@ +package org.prolibertate.games.game.solitaire + +import org.prolibertate.games.game.engine.GameAi + +/** + * Takes the first move on offer. + * + * Every patience here has one seat and it belongs to the person playing, so + * nothing asks this during an ordinary game. [MatchController] needs an AI to + * construct, though, and a seat that could be left empty has to be playable by + * something. + * + * It is deliberately not clever. A greedy patience player that looked good would + * be worse than this: it would be trusted as a hint, and a hint that walks a run + * back and forth is more annoying than no hint at all. Klondike has a real one + * because its move ordering is obvious; these three do not, and pretending + * otherwise would be the wrong kind of helpful. + */ +class FirstLegalAi : GameAi { + override fun chooseMove(state: S, seat: Int, legal: List): M { + require(legal.isNotEmpty()) { "No legal move for seat $seat" } + return legal.first() + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/spider/SpiderModel.kt b/app/src/main/java/org/prolibertate/games/game/spider/SpiderModel.kt new file mode 100644 index 0000000..e8fc7fb --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/spider/SpiderModel.kt @@ -0,0 +1,107 @@ +package org.prolibertate.games.game.spider + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.cards.Rank +import org.prolibertate.games.game.cards.Suit +import org.prolibertate.games.game.solitaire.patienceOrder + +const val COLUMNS: Int = 10 + +/** Eight complete runs, king down to ace, is the whole pack cleared. */ +const val RUNS_TO_WIN: Int = 8 +const val RUN_LENGTH: Int = 13 + +@Serializable +data class SpiderOptions( + /** + * How many suits the two packs are made of. One suit is the game people + * actually finish; four is the real thing and is brutal. Two sits between + * them. The pack is always a hundred and four cards either way — only how + * many different suits those cards are drawn from changes. + */ + val suits: Int = 1, +) { + init { + require(suits == 1 || suits == 2 || suits == 4) { "Spider is played with one, two or four suits" } + } +} + +@Serializable +data class SpiderPile( + 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 SpiderState( + val options: SpiderOptions, + val seed: Long, + val stock: List, + val tableau: List, + /** Runs sent away complete. Eight of them wins. */ + val completed: Int, + val moves: Int, + val log: List, +) { + val isWon: Boolean get() = completed == RUNS_TO_WIN + + /** + * A row cannot be dealt onto an empty column — the rule that stops a deal + * from being used to dodge a position with nowhere to put anything. + */ + val canDeal: Boolean get() = stock.isNotEmpty() && tableau.none { it.isEmpty } +} + +@Serializable +sealed interface SpiderMove + +/** One card face up onto every column at once. */ +@Serializable +@SerialName("deal") +data object DealRow : SpiderMove + +@Serializable +@SerialName("move") +data class MoveRun(val from: Int, val to: Int, val count: Int) : SpiderMove + +/** + * Down by one in the same suit. + * + * Spider lets a card *land* on any suit a rank higher, but only a same-suit run + * may travel as a unit — which is the whole difficulty of the four-suit game. + */ +fun sameSuitRun(cards: List): Boolean = + cards.isNotEmpty() && cards.zipWithNext().all { (upper, lower) -> + lower.suit == upper.suit && lower.rank.patienceOrder == upper.rank.patienceOrder - 1 + } + +/** What a column will take on top: anything one rank higher, of any suit. */ +fun landsOn(card: Card, onto: Card): Boolean = + card.rank.patienceOrder == onto.rank.patienceOrder - 1 + +/** King down to ace in one suit, which is a run ready to go away. */ +fun isCompleteRun(cards: List): Boolean = + cards.size == RUN_LENGTH && + cards.first().rank == Rank.KING && + cards.last().rank == Rank.ACE && + sameSuitRun(cards) + +/** + * A hundred and four cards drawn from [suits] suits. + * + * One suit means eight identical spade packs; four means two of each ordinary + * suit. The count never changes, so the deal is the same shape whatever the + * table chose. + */ +fun spiderDeck(suits: Int): List { + val used = Suit.entries.take(suits) + val copies = 8 / suits + return used.flatMap { suit -> + List(copies) { Rank.standard.map { Card(it, suit) } }.flatten() + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/spider/SpiderRules.kt b/app/src/main/java/org/prolibertate/games/game/spider/SpiderRules.kt new file mode 100644 index 0000000..c16ac0e --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/spider/SpiderRules.kt @@ -0,0 +1,177 @@ +package org.prolibertate.games.game.spider + +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.shuffledWith +import org.prolibertate.games.game.engine.GameRules +import org.prolibertate.games.game.engine.TableConfig +import kotlin.random.Random + +/** + * Spider as a pure state machine. See RULES-spider.md. + */ +object SpiderRules : GameRules { + + override val gameId: String = GameCatalog.SPIDER + const val SOLO_SEAT: Int = 0 + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + override fun initialState(config: TableConfig): SpiderState { + val options = json.decodeFromString(config.optionsJson) + require(config.seats.size == 1) { "Spider seats one, not ${config.seats.size}" } + return deal(options, config.seed) + } + + private fun deal(options: SpiderOptions, seed: Long): SpiderState { + val deck = spiderDeck(options.suits).shuffledWith(Random(seed)).toMutableList() + // Fifty-four cards go out: six to the first four columns, five to the + // rest. The remaining fifty are the five rows still to come. + val piles = (0 until COLUMNS).map { index -> + val size = if (index < 4) 6 else 5 + val taken = List(size) { deck.removeAt(deck.size - 1) } + SpiderPile(faceDown = taken.dropLast(1), faceUp = listOf(taken.last())) + } + return SpiderState( + options = options, + seed = seed, + stock = deck.toList(), + tableau = piles, + completed = 0, + moves = 0, + log = listOf("Dealt, ${options.suits} suit${if (options.suits == 1) "" else "s"}."), + ) + } + + override fun currentSeat(state: SpiderState): Int? = + if (isFinished(state)) null else SOLO_SEAT + + /** The longest same-suit descending run on the end of a column. */ + fun movableTail(pile: SpiderPile): List { + if (pile.faceUp.isEmpty()) return emptyList() + var take = 1 + while (take < pile.faceUp.size) { + if (!sameSuitRun(pile.faceUp.takeLast(take + 1))) break + take++ + } + return pile.faceUp.takeLast(take) + } + + override fun legalMoves(state: SpiderState, seat: Int): List { + if (seat != SOLO_SEAT || state.isWon) return emptyList() + val moves = mutableListOf() + if (state.canDeal) moves += DealRow + + state.tableau.forEachIndexed { from, pile -> + val tail = movableTail(pile) + for (count in 1..tail.size) { + val run = tail.takeLast(count) + val head = run.first() + state.tableau.indices.forEach { to -> + if (to == from) return@forEach + val target = state.tableau[to] + val fits = target.top?.let { landsOn(head, it) } ?: true + if (!fits) return@forEach + // Emptying one column only to fill another gains nothing. + if (target.isEmpty && pile.faceDown.isEmpty() && count == pile.faceUp.size) { + return@forEach + } + moves += MoveRun(from, to, count) + } + } + } + return moves + } + + override fun applyMove(state: SpiderState, seat: Int, move: SpiderMove): SpiderState { + require(seat == SOLO_SEAT) { "Spider has one seat" } + require(legalMoves(state, seat).contains(move)) { "$move is not legal here" } + val next = when (move) { + DealRow -> dealRow(state) + is MoveRun -> moveRun(state, move) + } + return next.copy( + moves = state.moves + 1, + log = if (next.isWon) next.log + "Out, in ${state.moves + 1} moves." else next.log, + ) + } + + private fun dealRow(state: SpiderState): SpiderState { + var stock = state.stock + val piles = state.tableau.map { pile -> + val card = stock.lastOrNull() ?: return@map pile + stock = stock.dropLast(1) + pile.copy(faceUp = pile.faceUp + card) + } + // A dealt row can complete a run in any column it landed on. + return sweep(state.copy(stock = stock, tableau = piles), piles.indices.toList()) + } + + private fun moveRun(state: SpiderState, move: MoveRun): SpiderState { + val source = state.tableau[move.from] + val run = source.faceUp.takeLast(move.count) + val left = source.faceUp.dropLast(move.count) + val stripped = if (left.isEmpty() && source.faceDown.isNotEmpty()) { + SpiderPile(source.faceDown.dropLast(1), listOf(source.faceDown.last())) + } else { + source.copy(faceUp = left) + } + val target = state.tableau[move.to] + val piles = state.tableau.toMutableList().also { + it[move.from] = stripped + it[move.to] = target.copy(faceUp = target.faceUp + run) + } + return sweep(state.copy(tableau = piles), listOf(move.to)) + } + + /** + * Sends away any king-to-ace run finished by the last move. + * + * Only the columns that changed are looked at, and only their ends: a + * complete run can only ever be sitting on the bottom of a pile, because + * anything laid on an ace would have to be lower than one. + */ + private fun sweep(state: SpiderState, touched: List): SpiderState { + var piles = state.tableau + var completed = state.completed + var notes = state.log + touched.forEach { index -> + val pile = piles[index] + if (pile.faceUp.size < RUN_LENGTH) return@forEach + if (!isCompleteRun(pile.faceUp.takeLast(RUN_LENGTH))) return@forEach + val left = pile.faceUp.dropLast(RUN_LENGTH) + val stripped = if (left.isEmpty() && pile.faceDown.isNotEmpty()) { + SpiderPile(pile.faceDown.dropLast(1), listOf(pile.faceDown.last())) + } else { + pile.copy(faceUp = left) + } + piles = piles.toMutableList().also { it[index] = stripped } + completed++ + notes = notes + "A run went away — $completed of $RUNS_TO_WIN." + } + return state.copy(tableau = piles, completed = completed, log = notes) + } + + override fun isFinished(state: SpiderState): Boolean = + state.isWon || legalMoves(state, SOLO_SEAT).isEmpty() + + override fun summary(state: SpiderState): String = when { + state.isWon -> "All eight runs, in ${state.moves} moves" + isFinished(state) -> "Blocked with ${state.completed} runs away" + else -> "${state.completed} of $RUNS_TO_WIN runs away" + } + + override fun viewFor(state: SpiderState, seat: Int): SpiderState = state + + override fun encodeState(state: SpiderState): String = json.encodeToString(state) + override fun decodeState(json: String): SpiderState = this.json.decodeFromString(json) + override fun encodeMove(move: SpiderMove): String = json.encodeToString(move) + override fun decodeMove(json: String): SpiderMove = this.json.decodeFromString(json) + + fun restart(state: SpiderState): SpiderState = deal(state.options, state.seed) +} diff --git a/app/src/test/java/org/prolibertate/games/game/solitaire/SolitaireRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/solitaire/SolitaireRulesTest.kt new file mode 100644 index 0000000..135b479 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/solitaire/SolitaireRulesTest.kt @@ -0,0 +1,351 @@ +package org.prolibertate.games.game.solitaire + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.cards.Rank +import org.prolibertate.games.game.cards.Suit +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig +import org.prolibertate.games.game.freecell.COLUMNS as FREECELL_COLUMNS +import org.prolibertate.games.game.freecell.FreeCellOptions +import org.prolibertate.games.game.freecell.FreeCellRules +import org.prolibertate.games.game.freecell.MoveTo +import org.prolibertate.games.game.freecell.Place +import org.prolibertate.games.game.freecell.maxRunLength +import org.prolibertate.games.game.pyramid.PYRAMID_SIZE +import org.prolibertate.games.game.pyramid.PyramidOptions +import org.prolibertate.games.game.pyramid.PyramidRules +import org.prolibertate.games.game.pyramid.PyramidZone +import org.prolibertate.games.game.pyramid.TARGET +import org.prolibertate.games.game.pyramid.coveredBy +import org.prolibertate.games.game.pyramid.isExposed +import org.prolibertate.games.game.pyramid.rowOf +import org.prolibertate.games.game.pyramid.valueOf +import org.prolibertate.games.game.spider.COLUMNS as SPIDER_COLUMNS +import org.prolibertate.games.game.spider.DealRow +import org.prolibertate.games.game.spider.RUN_LENGTH +import org.prolibertate.games.game.spider.SpiderOptions +import org.prolibertate.games.game.spider.SpiderRules +import org.prolibertate.games.game.spider.isCompleteRun +import org.prolibertate.games.game.spider.landsOn +import org.prolibertate.games.game.spider.sameSuitRun +import org.prolibertate.games.game.spider.spiderDeck + +/** + * The three patiences added together, tested together. + * + * They share the ace-low ordering and little else, so what is worth pinning is + * each one's own shape: how many cards it deals, what it will let travel, and + * what counts as finished. + */ +class SolitaireRulesTest { + + private val json = Json { encodeDefaults = true } + + private fun soloConfig(gameId: String, optionsJson: String, seed: Long = 3L) = TableConfig( + gameId = gameId, + seats = listOf(PlayerSlot(seat = 0, name = "P0", kind = PlayerKind.HUMAN_LOCAL, team = 0)), + optionsJson = optionsJson, + seed = seed, + ) + + private fun c(rank: Rank, suit: Suit) = Card(rank, suit) + + // ---- shared ------------------------------------------------------------ + + @Test + fun `the ace is low for all of them`() { + assertEquals(1, Rank.ACE.patienceOrder) + assertEquals(11, Rank.JACK.patienceOrder) + assertEquals(13, Rank.KING.patienceOrder) + assertTrue(buildsDownAlternating(c(Rank.ACE, Suit.HEARTS), c(Rank.TWO, Suit.SPADES))) + assertTrue(buildsDownInSuit(c(Rank.ACE, Suit.SPADES), c(Rank.TWO, Suit.SPADES))) + assertFalse(buildsDownInSuit(c(Rank.ACE, Suit.HEARTS), c(Rank.TWO, Suit.SPADES))) + } + + // ---- FreeCell ---------------------------------------------------------- + + @Test + fun `FreeCell deals the whole pack face up`() { + val state = FreeCellRules.initialState( + soloConfig("freecell", json.encodeToString(FreeCellOptions())), + ) + assertEquals(FREECELL_COLUMNS, state.tableau.size) + // Fifty-two into eight is four sevens and four sixes. + assertEquals(listOf(7, 7, 7, 7, 6, 6, 6, 6), state.tableau.map { it.size }) + val all = state.tableau.flatten() + assertEquals(52, all.size) + assertEquals(52, all.distinct().size) + assertEquals(4, state.freeCellCount) + assertEquals(0, state.emptyColumns) + } + + @Test + fun `how much can travel depends on cells and spaces`() { + // One card per free cell plus the card itself, doubled for each empty + // column that can be borrowed as a staging pile. + assertEquals(5, maxRunLength(freeCells = 4, emptyColumns = 0, intoEmptyColumn = false)) + assertEquals(10, maxRunLength(freeCells = 4, emptyColumns = 1, intoEmptyColumn = false)) + assertEquals(20, maxRunLength(freeCells = 4, emptyColumns = 2, intoEmptyColumn = false)) + // Moving into an empty column cannot also stage through it. + assertEquals(5, maxRunLength(freeCells = 4, emptyColumns = 1, intoEmptyColumn = true)) + assertEquals(1, maxRunLength(freeCells = 0, emptyColumns = 0, intoEmptyColumn = false)) + } + + @Test + fun `FreeCell refuses a table for more than one`() { + val two = TableConfig( + gameId = "freecell", + seats = (0..1).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) }, + optionsJson = json.encodeToString(FreeCellOptions()), + seed = 1L, + ) + assertThrows { FreeCellRules.initialState(two) } + } + + @Test + fun `FreeCell keeps every card wherever it is put`() { + var state = FreeCellRules.initialState( + soloConfig("freecell", json.encodeToString(FreeCellOptions())), + ) + val ai = FirstLegalAi() + var guard = 120 + while (!FreeCellRules.isFinished(state) && guard-- > 0) { + val legal = FreeCellRules.legalMoves(state, 0) + state = FreeCellRules.applyMove(state, 0, ai.chooseMove(state, 0, legal)) + val all = state.tableau.flatten() + state.foundations.flatten() + state.cells.filterNotNull() + assertEquals("no card lost or copied", 52, all.size) + assertEquals(52, all.distinct().size) + } + } + + @Test + fun `a card in a cell can come back out`() { + val state = FreeCellRules.initialState( + soloConfig("freecell", json.encodeToString(FreeCellOptions())), + ) + val toCell = FreeCellRules.legalMoves(state, 0) + .filterIsInstance() + .first { it.to.kind == org.prolibertate.games.game.freecell.CellKind.CELL } + val parked = FreeCellRules.applyMove(state, 0, toCell) + assertEquals(3, parked.freeCellCount) + assertTrue( + "the parked card must be playable again", + FreeCellRules.legalMoves(parked, 0) + .filterIsInstance() + .any { it.from == Place.cell(toCell.to.index) }, + ) + } + + // ---- Spider ------------------------------------------------------------ + + @Test + fun `a Spider pack is always a hundred and four`() { + listOf(1, 2, 4).forEach { suits -> + val deck = spiderDeck(suits) + assertEquals("$suits suits", 104, deck.size) + assertEquals("$suits suits", suits, deck.map { it.suit }.distinct().size) + // Eight of every rank, however the suits were shared out. + assertEquals(8, deck.count { it.rank == Rank.KING }) + } + } + + @Test + fun `Spider deals fifty-four and keeps fifty back`() { + val state = SpiderRules.initialState( + soloConfig("spider", json.encodeToString(SpiderOptions())), + ) + assertEquals(SPIDER_COLUMNS, state.tableau.size) + assertEquals(listOf(6, 6, 6, 6, 5, 5, 5, 5, 5, 5), state.tableau.map { it.faceDown.size + it.faceUp.size }) + state.tableau.forEach { assertEquals("only the last is turned up", 1, it.faceUp.size) } + assertEquals(50, state.stock.size) + assertEquals(0, state.completed) + } + + @Test + fun `only a same-suit run travels, but anything lands on a higher card`() { + val spades = listOf(c(Rank.TEN, Suit.SPADES), c(Rank.NINE, Suit.SPADES)) + val mixed = listOf(c(Rank.TEN, Suit.SPADES), c(Rank.NINE, Suit.HEARTS)) + assertTrue(sameSuitRun(spades)) + assertFalse("a mixed run cannot travel as one", sameSuitRun(mixed)) + // Landing is looser than travelling: any suit, one rank higher. + assertTrue(landsOn(c(Rank.NINE, Suit.HEARTS), c(Rank.TEN, Suit.SPADES))) + assertFalse(landsOn(c(Rank.NINE, Suit.HEARTS), c(Rank.JACK, Suit.SPADES))) + } + + @Test + fun `a complete run is king down to ace in one suit`() { + val run = Rank.standard.sortedByDescending { it.patienceOrder }.map { c(it, Suit.SPADES) } + assertEquals(RUN_LENGTH, run.size) + assertTrue(isCompleteRun(run)) + // One card of the wrong suit and it is not going anywhere. + val broken = run.toMutableList().also { it[5] = c(it[5].rank, Suit.HEARTS) } + assertFalse(isCompleteRun(broken)) + assertFalse(isCompleteRun(run.drop(1))) + } + + @Test + fun `a row cannot be dealt onto an empty column`() { + val state = SpiderRules.initialState( + soloConfig("spider", json.encodeToString(SpiderOptions())), + ) + assertTrue(state.canDeal) + assertTrue(SpiderRules.legalMoves(state, 0).contains(DealRow)) + + val emptied = state.copy( + tableau = state.tableau.toMutableList().also { + it[0] = org.prolibertate.games.game.spider.SpiderPile() + }, + ) + assertFalse(emptied.canDeal) + assertFalse(SpiderRules.legalMoves(emptied, 0).contains(DealRow)) + } + + @Test + fun `dealing a row puts one card on every column`() { + val state = SpiderRules.initialState( + soloConfig("spider", json.encodeToString(SpiderOptions())), + ) + val before = state.tableau.map { it.faceUp.size } + val dealt = SpiderRules.applyMove(state, 0, DealRow) + assertEquals(40, dealt.stock.size) + dealt.tableau.forEachIndexed { index, pile -> + assertEquals(before[index] + 1, pile.faceUp.size) + } + } + + // ---- Pyramid ----------------------------------------------------------- + + @Test + fun `the pyramid is twenty-eight cards in seven rows`() { + assertEquals(28, PYRAMID_SIZE) + assertEquals(0, rowOf(0)) + assertEquals(1, rowOf(1)) + assertEquals(1, rowOf(2)) + assertEquals(2, rowOf(3)) + assertEquals(6, rowOf(27)) + + val state = PyramidRules.initialState( + soloConfig("pyramid", json.encodeToString(PyramidOptions())), + ) + assertEquals(PYRAMID_SIZE, state.pyramid.size) + assertEquals(24, state.stock.size) + assertTrue(state.pyramid.all { it != null }) + } + + @Test + fun `a card is buried until both the cards on it are gone`() { + // The apex rests on the two cards of the second row. + assertEquals(listOf(1, 2), coveredBy(0)) + // The bottom row rests on nothing. + assertTrue(coveredBy(27).isEmpty()) + + val pyramid = MutableList(PYRAMID_SIZE) { c(Rank.FIVE, Suit.SPADES) } + assertFalse("the apex starts buried", isExposed(pyramid, 0)) + pyramid[1] = null + assertFalse("one of the two is not enough", isExposed(pyramid, 0)) + pyramid[2] = null + assertTrue(isExposed(pyramid, 0)) + // A place already taken is not exposed, it is gone. + pyramid[0] = null + assertFalse(isExposed(pyramid, 0)) + } + + @Test + fun `pairs make thirteen, and a king makes it alone`() { + assertEquals(1, valueOf(c(Rank.ACE, Suit.SPADES))) + assertEquals(11, valueOf(c(Rank.JACK, Suit.SPADES))) + assertEquals(TARGET, valueOf(c(Rank.KING, Suit.SPADES))) + assertEquals(TARGET, valueOf(c(Rank.ACE, Suit.SPADES)) + valueOf(c(Rank.QUEEN, Suit.HEARTS))) + assertEquals(TARGET, valueOf(c(Rank.SIX, Suit.CLUBS)) + valueOf(c(Rank.SEVEN, Suit.DIAMONDS))) + } + + @Test + fun `only the bottom row is in play at the start`() { + val state = PyramidRules.initialState( + soloConfig("pyramid", json.encodeToString(PyramidOptions())), + ) + val reachable = PyramidRules.available(state) + val fromPyramid = reachable.filter { it.first.zone == PyramidZone.PYRAMID } + assertEquals("the seven of the bottom row", 7, fromPyramid.size) + assertTrue(fromPyramid.all { rowOf(it.first.index) == 6 }) + // Nothing has been turned yet, so the waste offers nothing. + assertTrue(reachable.none { it.first.zone == PyramidZone.WASTE }) + } + + @Test + fun `drawing turns a card and the waste can be turned back`() { + var state = PyramidRules.initialState( + soloConfig("pyramid", json.encodeToString(PyramidOptions(redeals = 1))), + ) + val stock = state.stock.size + state = PyramidRules.applyMove(state, 0, org.prolibertate.games.game.pyramid.DrawCard) + assertEquals(stock - 1, state.stock.size) + assertEquals(1, state.waste.size) + + var guard = 40 + while (state.stock.isNotEmpty() && guard-- > 0) { + state = PyramidRules.applyMove(state, 0, org.prolibertate.games.game.pyramid.DrawCard) + } + assertTrue(state.canRedeal) + val turned = PyramidRules.applyMove(state, 0, org.prolibertate.games.game.pyramid.RecycleWaste) + assertTrue(turned.waste.isEmpty()) + assertEquals(1, turned.redealsUsed) + assertFalse("only the one redeal was allowed", turned.canRedeal) + } + + @Test + fun `a cleared pyramid is won`() { + val state = PyramidRules.initialState( + soloConfig("pyramid", json.encodeToString(PyramidOptions())), + ) + val cleared = state.copy(pyramid = List(PYRAMID_SIZE) { null }) + assertTrue(cleared.isWon) + assertTrue(PyramidRules.isFinished(cleared)) + assertNull(PyramidRules.currentSeat(cleared)) + assertTrue(PyramidRules.summary(cleared).contains("Cleared")) + } + + // ---- codecs ------------------------------------------------------------ + + @Test + fun `each of them survives a round trip`() { + val freecell = FreeCellRules.initialState( + soloConfig("freecell", json.encodeToString(FreeCellOptions())), + ) + assertEquals(freecell, FreeCellRules.decodeState(FreeCellRules.encodeState(freecell))) + + val spider = SpiderRules.initialState( + soloConfig("spider", json.encodeToString(SpiderOptions(suits = 4))), + ) + assertEquals(spider, SpiderRules.decodeState(SpiderRules.encodeState(spider))) + assertEquals(DealRow, SpiderRules.decodeMove(SpiderRules.encodeMove(DealRow))) + + val pyramid = PyramidRules.initialState( + soloConfig("pyramid", json.encodeToString(PyramidOptions())), + ) + assertEquals(pyramid, PyramidRules.decodeState(PyramidRules.encodeState(pyramid))) + val king = org.prolibertate.games.game.pyramid.TakeKing(PyramidSpotAt(3)) + assertEquals(king, PyramidRules.decodeMove(PyramidRules.encodeMove(king))) + } + + private fun PyramidSpotAt(index: Int) = + org.prolibertate.games.game.pyramid.PyramidSpot(PyramidZone.PYRAMID, index) + + private fun assertThrows(block: () -> Unit) { + try { + block() + throw AssertionError("expected the rules to reject that") + } catch (expected: IllegalArgumentException) { + // Rejected, which is the point. + } + } +} From deaf85938c92795db064f8484bcb8bf56d29b0fc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 16:07:39 +0000 Subject: [PATCH 12/16] Lay the Klondike table out the way a real one is Three changes, all about where things are and how much room they take. Columns now overlap by a third rather than sitting one under another. A column of thirteen took more vertical space than a phone has; overlapped, it fits, and a third is the most that can be hidden while every card still shows its rank and suit. Absolute offsets rather than negative spacing, because the column has to work out its own height anyway to reserve the right room. The cards turned off the stock fan sideways by the same third. Drawing three and showing one hid two cards the player is entitled to see -- they are face up on the table. Only the last is playable, which the rules already enforce; the other two are there to be read. The stock and the cards turned from it move to the bottom left, under the hand that turns them, and the foundations keep the top on their own. That is where both live on a real table, and it puts the pile you tap most within reach of a thumb rather than at the far corner of the screen. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC --- .../games/ui/game/KlondikeScreen.kt | 198 +++++++++++++----- 1 file changed, 150 insertions(+), 48 deletions(-) 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 index 1bd3ec3..af7920b 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/KlondikeScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/KlondikeScreen.kt @@ -9,8 +9,9 @@ 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.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -31,15 +32,18 @@ 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.cards.Card 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.TableauPile 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.CARD_ASPECT import org.prolibertate.games.ui.common.CardBackView import org.prolibertate.games.ui.common.PlayingCardView import org.prolibertate.games.ui.theme.FeltGreenDark @@ -47,18 +51,39 @@ 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 + +private val CARD_WIDTH: Dp = 44.dp +private val CARD_HEIGHT: Dp = CARD_WIDTH / CARD_ASPECT + +/** + * How far down each card in a column sits from the one above it. + * + * Two thirds of a card, so each is overlapped by a third. That is the most that + * can be hidden while every card still shows its rank and suit, and it is what + * keeps a column of thirteen on the screen instead of running off the bottom. + */ +private val STACK_STEP: Dp = CARD_HEIGHT * 2f / 3f + +/** The same overlap sideways, for the three cards turned off the stock. */ +private val FAN_STEP: Dp = CARD_WIDTH * 2f / 3f + +/** At most three are ever turned at once, so at most three are ever fanned. */ +private const val MAX_FANNED = 3 /** * 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. + * Laid out the way a real one is: the homes along the top where cards are going, + * the columns filling the middle, and the pack in the bottom corner under your + * hand. Columns overlap by a third so a long one still fits, and the cards + * turned off the stock fan sideways by the same third so all three can be seen + * rather than only the one on top. * - * 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. + * Play is tap-to-take then tap-to-put rather than dragging: across seven columns + * of overlapping cards on a phone, a drag 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. */ @Composable fun KlondikeScreen( @@ -92,7 +117,6 @@ fun KlondikeScreen( 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() @@ -115,7 +139,7 @@ fun KlondikeScreen( ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> Column( modifier = modifier.verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { Text( text = when { @@ -127,26 +151,8 @@ fun KlondikeScreen( fontWeight = FontWeight.Bold, ) + // The homes, along the top where they are being filled towards. 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)), @@ -162,28 +168,43 @@ fun KlondikeScreen( Row( modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.Top, ) { 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) } + ColumnOfCards( + pile = pile, + held = held, + index = index, + isTarget = targets.contains(Spot.tableau(index)), + onTap = { count -> take(Spot.tableau(index), count) }, + ) + } + } + + // The pack sits at the bottom left, under the hand that turns it. + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + 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 "") } } + WasteFan( + waste = current.waste, + fanned = minOf(current.options.drawCount, MAX_FANNED), + highlighted = held?.first == Spot.waste, + onTap = { take(Spot.waste, 1) }, + ) } if (finished) { @@ -203,10 +224,91 @@ fun KlondikeScreen( } } +/** + * One column, overlapped by a third. + * + * Absolute offsets rather than negative spacing: the height has to be worked out + * anyway so the column reserves the right room, and once that is known placing + * each card is the simpler half. + */ @Composable -private fun Slot(highlighted: Boolean, onClick: () -> Unit, content: @Composable () -> Unit) { +private fun ColumnOfCards( + pile: TableauPile, + held: Pair?, + index: Int, + isTarget: Boolean, + onTap: (Int) -> Unit, +) { + val total = pile.faceDown.size + pile.faceUp.size + if (total == 0) { + Slot(highlighted = isTarget, onClick = { onTap(1) }) { Empty() } + return + } Box( modifier = Modifier + .width(CARD_WIDTH) + .height(CARD_HEIGHT + STACK_STEP * (total - 1)), + ) { + pile.faceDown.forEachIndexed { depth, _ -> + CardBackView( + width = CARD_WIDTH, + modifier = Modifier.offset(y = STACK_STEP * depth), + ) + } + pile.faceUp.forEachIndexed { depth, card -> + // Taking a card takes everything resting on it. + val count = pile.faceUp.size - depth + val row = pile.faceDown.size + depth + Slot( + modifier = Modifier.offset(y = STACK_STEP * row), + highlighted = held == (Spot.tableau(index) to count) || + (depth == pile.faceUp.lastIndex && isTarget), + onClick = { onTap(count) }, + ) { PlayingCardView(card = card, width = CARD_WIDTH) } + } + } +} + +/** + * The cards turned off the stock, fanned sideways. + * + * Drawing three and showing one would hide two cards the player is entitled to + * see — they are face up on the table. Only the last is playable, which the + * rules already enforce; the other two are there to be read. + */ +@Composable +private fun WasteFan( + waste: List, + fanned: Int, + highlighted: Boolean, + onTap: () -> Unit, +) { + val shown = waste.takeLast(fanned) + if (shown.isEmpty()) { + Slot(highlighted = false, onClick = onTap) { Empty() } + return + } + Box(modifier = Modifier.width(CARD_WIDTH + FAN_STEP * (shown.size - 1))) { + shown.forEachIndexed { position, card -> + val isTop = position == shown.lastIndex + Slot( + modifier = Modifier.offset(x = FAN_STEP * position), + highlighted = isTop && highlighted, + onClick = { if (isTop) onTap() }, + ) { PlayingCardView(card = card, width = CARD_WIDTH) } + } + } +} + +@Composable +private fun Slot( + highlighted: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Box( + modifier = modifier .width(CARD_WIDTH) .border( border = BorderStroke(if (highlighted) 3.dp else 0.dp, WallaceGold), @@ -224,7 +326,7 @@ private fun Empty(label: String = "") { Box( modifier = Modifier .width(CARD_WIDTH) - .aspectRatio(0.7f) + .height(CARD_HEIGHT) .background(FeltGreenDark, RoundedCornerShape(4.dp)), contentAlignment = Alignment.Center, ) { From 4e7aa98b61cf067af6b66b8ca9093a9f9f81b2ed Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 16:14:06 +0000 Subject: [PATCH 13/16] A cell can strand a card, and the test said otherwise The test asserted that a card parked in a free cell can always be played again. It cannot. A cell holds a card nothing on the tableau will take and that is not yet ready for a foundation, and it stays there -- that is the whole danger of the cells rather than a fault in them, and the reason four of them is a game worth playing. What is worth pinning is what parking actually does: the card leaves the column and is in the cell afterwards. The one card a cell can never strand is an ace, since a foundation will always have it, so that gets a test of its own. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC --- .../game/solitaire/SolitaireRulesTest.kt | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/app/src/test/java/org/prolibertate/games/game/solitaire/SolitaireRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/solitaire/SolitaireRulesTest.kt index 135b479..c4d2d48 100644 --- a/app/src/test/java/org/prolibertate/games/game/solitaire/SolitaireRulesTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/solitaire/SolitaireRulesTest.kt @@ -128,20 +128,37 @@ class SolitaireRulesTest { } @Test - fun `a card in a cell can come back out`() { + fun `parking a card takes it off the column and into the cell`() { val state = FreeCellRules.initialState( soloConfig("freecell", json.encodeToString(FreeCellOptions())), ) val toCell = FreeCellRules.legalMoves(state, 0) .filterIsInstance() .first { it.to.kind == org.prolibertate.games.game.freecell.CellKind.CELL } + val column = toCell.from.index + val card = state.tableau[column].last() + val parked = FreeCellRules.applyMove(state, 0, toCell) assertEquals(3, parked.freeCellCount) + assertEquals(card, parked.cells[toCell.to.index]) + assertEquals(state.tableau[column].size - 1, parked.tableau[column].size) + // Deliberately not asserting the card can come back out. A parked card + // that nothing will take is stranded, and that is the whole danger of + // the cells rather than a fault in them. + } + + @Test + fun `an ace in a cell can always go home`() { + val base = FreeCellRules.initialState( + soloConfig("freecell", json.encodeToString(FreeCellOptions())), + ) + val ace = c(Rank.ACE, Suit.SPADES) + val held = base.copy(cells = listOf(ace, null, null, null)) assertTrue( - "the parked card must be playable again", - FreeCellRules.legalMoves(parked, 0) - .filterIsInstance() - .any { it.from == Place.cell(toCell.to.index) }, + "an ace is the one card a cell can never strand", + FreeCellRules.legalMoves(held, 0).contains( + MoveTo(Place.cell(0), Place.foundation(Suit.SPADES.ordinal), 1), + ), ) } From 459670012e1a9e93a7f5468dae6aacdf72888fb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 16:23:17 +0000 Subject: [PATCH 14/16] Screens for FreeCell, Spider and Pyramid, and all three into the catalogue Seven games now, and the three new ones are tappable. The furniture they share moves into CardTable: a place a card sits, an empty place that can still be tapped into, and the two dialogs for leaving. Klondike keeps its own copies rather than being rewritten to use them -- it was written first, its constants are tuned to seven columns, and its private helpers cannot collide with these. FreeCell puts the cells on the left and the homes on the right, which is what makes the cells read as somewhere to put things rather than somewhere cards come from. Spider shows the two numbers the game actually turns on above the columns: runs gone away, and rows still in the stock waiting to bury everything. Pyramid is drawn as it is dealt, each row offset half a card so every card sits in the notch between the two below it. The shape is the rules -- a card is only in play once both the cards resting on it have gone -- so seeing it at a glance is most of playing it. Tapping a king takes it alone; tapping anything else holds it and lights up only what would make thirteen with it. All three restart by rebuilding the controller, as Klondike and peg solitaire do, because a blocked deal is genuinely finished and advanceIdle never runs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC --- .../org/prolibertate/games/game/Catalog.kt | 33 +++ .../prolibertate/games/ui/GameSetupScreen.kt | 63 +++++ .../org/prolibertate/games/ui/PlayScreen.kt | 64 +++++ .../prolibertate/games/ui/game/CardTable.kt | 96 +++++++ .../games/ui/game/FreeCellScreen.kt | 188 +++++++++++++ .../games/ui/game/PyramidScreen.kt | 260 ++++++++++++++++++ .../games/ui/game/SpiderScreen.kt | 191 +++++++++++++ .../prolibertate/games/game/CatalogTest.kt | 3 + 8 files changed, 898 insertions(+) create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/CardTable.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/FreeCellScreen.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/PyramidScreen.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/SpiderScreen.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 14a48a8..306e0b1 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -147,6 +147,39 @@ object GameCatalog { "and a pack that decides most of it before you start.", available = true, ), + GameDescriptor( + id = FREECELL, + title = "FreeCell", + category = GameCategory.CARD, + minPlayers = 1, + maxPlayers = 1, + teamBased = false, + blurb = "Every card face up from the start, and four cells to park what is " + + "in the way. Nearly every deal can be won by someone good enough.", + available = true, + ), + GameDescriptor( + id = SPIDER, + title = "Spider", + category = GameCategory.CARD, + minPlayers = 1, + maxPlayers = 1, + teamBased = false, + blurb = "Two packs, ten columns, eight runs to build from king to ace. " + + "One suit is a pastime; four is a fight.", + available = true, + ), + GameDescriptor( + id = PYRAMID, + title = "Pyramid", + category = GameCategory.CARD, + minPlayers = 1, + maxPlayers = 1, + teamBased = false, + blurb = "Take cards away in pairs that make thirteen, kings alone. " + + "Nothing is built and nothing goes home — the pyramid just has to go.", + available = true, + ), GameDescriptor( id = SEQUENCE, title = "Sequence", 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 58ee816..80632ea 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -42,6 +42,9 @@ 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.freecell.FreeCellOptions +import org.prolibertate.games.game.pyramid.PyramidOptions +import org.prolibertate.games.game.spider.SpiderOptions import org.prolibertate.games.game.yahtzee.YahtzeeOptions import org.prolibertate.games.game.pegsolitaire.PegBoard import org.prolibertate.games.game.pegsolitaire.PegGoal @@ -98,6 +101,9 @@ fun GameSetupScreen( var pegs by remember { mutableStateOf(PegSolitaireOptions()) } var yahtzee by remember { mutableStateOf(YahtzeeOptions()) } var klondike by remember { mutableStateOf(KlondikeOptions()) } + var freecell by remember { mutableStateOf(FreeCellOptions()) } + var spider by remember { mutableStateOf(SpiderOptions()) } + var pyramid by remember { mutableStateOf(PyramidOptions()) } // 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) } @@ -125,6 +131,9 @@ fun GameSetupScreen( GameCatalog.PEG_SOLITAIRE -> setupJson.encodeToString(pegs) GameCatalog.YAHTZEE -> setupJson.encodeToString(yahtzee) GameCatalog.KLONDIKE -> setupJson.encodeToString(klondike) + GameCatalog.FREECELL -> setupJson.encodeToString(freecell) + GameCatalog.SPIDER -> setupJson.encodeToString(spider) + GameCatalog.PYRAMID -> setupJson.encodeToString(pyramid) else -> "{}" } val seatCount = seatCountFor(descriptor.id, optionsJson) @@ -184,6 +193,12 @@ fun GameSetupScreen( GameCatalog.KLONDIKE -> KlondikeOptionsEditor(klondike) { klondike = it } + GameCatalog.FREECELL -> FreeCellOptionsEditor(freecell) { freecell = it } + + GameCatalog.SPIDER -> SpiderOptionsEditor(spider) { spider = it } + + GameCatalog.PYRAMID -> PyramidOptionsEditor(pyramid) { pyramid = it } + else -> Text("No options yet for this game.") } @@ -1078,3 +1093,51 @@ private fun KlondikeOptionsEditor(options: KlondikeOptions, onChange: (KlondikeO ) } } + +@Composable +private fun FreeCellOptionsEditor(options: FreeCellOptions, onChange: (FreeCellOptions) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ChipRow( + label = "Free cells", + values = (1..6).toList(), + selected = options.freeCells, + display = { "$it" }, + onSelect = { onChange(options.copy(freeCells = it)) }, + ) + ToggleRow( + title = "Move a run in one go", + subtitle = "The rules move a run one card at a time through the cells. This " + + "does the shuffling for you, which is bookkeeping rather than a decision.", + checked = options.allowSupermoves, + onChange = { onChange(options.copy(allowSupermoves = it)) }, + ) + } +} + +@Composable +private fun SpiderOptionsEditor(options: SpiderOptions, onChange: (SpiderOptions) -> Unit) { + ChipRow( + label = "Suits", + values = listOf(1, 2, 4), + selected = options.suits, + display = { + when (it) { + 1 -> "One — a pastime" + 2 -> "Two" + else -> "Four — a fight" + } + }, + onSelect = { onChange(options.copy(suits = it)) }, + ) +} + +@Composable +private fun PyramidOptionsEditor(options: PyramidOptions, onChange: (PyramidOptions) -> Unit) { + ChipRow( + label = "Turns through the pack", + values = listOf(0, 1, 2, 3), + selected = options.redeals, + display = { if (it == 0) "One pass only" else "$it turns back" }, + onSelect = { onChange(options.copy(redeals = 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 4b93b30..3359b69 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,16 @@ 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.freecell.FreeCellMove +import org.prolibertate.games.game.freecell.FreeCellRules +import org.prolibertate.games.game.freecell.FreeCellState +import org.prolibertate.games.game.pyramid.PyramidMove +import org.prolibertate.games.game.pyramid.PyramidRules +import org.prolibertate.games.game.pyramid.PyramidState +import org.prolibertate.games.game.solitaire.FirstLegalAi +import org.prolibertate.games.game.spider.SpiderMove +import org.prolibertate.games.game.spider.SpiderRules +import org.prolibertate.games.game.spider.SpiderState import org.prolibertate.games.game.klondike.KlondikeAi import org.prolibertate.games.game.klondike.KlondikeMove import org.prolibertate.games.game.klondike.KlondikeRules @@ -102,13 +112,16 @@ 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.FreeCellScreen import org.prolibertate.games.ui.game.KlondikeScreen +import org.prolibertate.games.ui.game.PyramidScreen 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.SpiderScreen import org.prolibertate.games.ui.game.YahtzeeScreen import org.prolibertate.games.ui.game.PiratesScreen import org.prolibertate.games.ui.game.PresidentScreen @@ -559,6 +572,57 @@ fun PlayScreen( ) } + GameCatalog.FREECELL -> { + var generation by remember(route) { mutableIntStateOf(0) } + val controller = remember(route, generation) { + MatchController( + rules = FreeCellRules, + ai = FirstLegalAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + ) + } + StartMatch(env, controller, route) + FreeCellScreen(controller = controller, onRestart = { generation++ }, onExit = onExit) + } + + GameCatalog.SPIDER -> { + var generation by remember(route) { mutableIntStateOf(0) } + val controller = remember(route, generation) { + MatchController( + rules = SpiderRules, + ai = FirstLegalAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + ) + } + StartMatch(env, controller, route) + SpiderScreen(controller = controller, onRestart = { generation++ }, onExit = onExit) + } + + GameCatalog.PYRAMID -> { + var generation by remember(route) { mutableIntStateOf(0) } + val controller = remember(route, generation) { + MatchController( + rules = PyramidRules, + ai = FirstLegalAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + ) + } + StartMatch(env, controller, route) + PyramidScreen(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/CardTable.kt b/app/src/main/java/org/prolibertate/games/ui/game/CardTable.kt new file mode 100644 index 0000000..b296437 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/CardTable.kt @@ -0,0 +1,96 @@ +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.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.prolibertate.games.ui.common.CARD_ASPECT +import org.prolibertate.games.ui.theme.FeltGreenDark +import org.prolibertate.games.ui.theme.Parchment +import org.prolibertate.games.ui.theme.WallaceGold + +/** + * The furniture every patience table needs. + * + * FreeCell, Spider and Pyramid lay their cards out quite differently but they + * are all made of the same three things: a place a card sits, an empty place + * that can still be tapped into, and the two dialogs for leaving. Klondike keeps + * its own copies — it was written first and its layout constants are tuned to + * seven columns — but nothing new needs to repeat them. + */ +val TABLE_CARD_WIDTH: Dp = 44.dp +val TABLE_CARD_HEIGHT: Dp = TABLE_CARD_WIDTH / CARD_ASPECT + +/** Overlapped by a third, which hides as little as a stack can afford to. */ +val TABLE_STACK_STEP: Dp = TABLE_CARD_HEIGHT * 2f / 3f + +@Composable +fun TableSlot( + highlighted: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + width: Dp = TABLE_CARD_WIDTH, + content: @Composable () -> Unit, +) { + Box( + modifier = modifier + .width(width) + .border( + border = BorderStroke(if (highlighted) 3.dp else 0.dp, WallaceGold), + shape = RoundedCornerShape(4.dp), + ) + .clickable { onClick() }, + ) { + content() + } +} + +/** A place with no card on it, drawn so it is still somewhere to aim at. */ +@Composable +fun EmptySpace(label: String = "", width: Dp = TABLE_CARD_WIDTH) { + Box( + modifier = Modifier + .width(width) + .height(width / CARD_ASPECT) + .background(FeltGreenDark, RoundedCornerShape(4.dp)), + contentAlignment = Alignment.Center, + ) { + if (label.isNotEmpty()) { + Text(label, color = Parchment.copy(alpha = 0.5f)) + } + } +} + +@Composable +fun PatienceEndDialog(what: String, onEnd: () -> Unit, onKeepPlaying: () -> Unit) { + AlertDialog( + onDismissRequest = onKeepPlaying, + title = { Text("End this game?") }, + text = { Text(what) }, + confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } }, + dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } }, + ) +} + +@Composable +fun PatienceLeftDialog(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/FreeCellScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/FreeCellScreen.kt new file mode 100644 index 0000000..b2d58ab --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/FreeCellScreen.kt @@ -0,0 +1,188 @@ +package org.prolibertate.games.ui.game + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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 org.prolibertate.games.game.cards.Suit +import org.prolibertate.games.game.freecell.FreeCellMove +import org.prolibertate.games.game.freecell.FreeCellState +import org.prolibertate.games.game.freecell.MoveTo +import org.prolibertate.games.game.freecell.Place +import org.prolibertate.games.net.MatchController +import org.prolibertate.games.ui.ScreenScaffold +import org.prolibertate.games.ui.common.PlayingCardView + +private const val TITLE = "FreeCell" + +/** + * The FreeCell table. + * + * Cells on the left, homes on the right, eight columns below — the standard + * arrangement, and the one that makes the cells read as somewhere to put things + * rather than somewhere cards come from. Everything is face up from the first + * move, so the whole game is on the screen and the only thing hidden is what to + * do about it. + */ +@Composable +fun FreeCellScreen( + 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 { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) { + PatienceEndDialog("The deal is left as it stands.", onExit) { confirmingEnd = false } + } + } + return + } + + val moves = legal.filterIsInstance() + val finished = legal.isEmpty() + 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(place: Place, count: Int) { + val inHand = held + held = when { + inHand != null && targets.contains(place) -> { + controller.submit(MoveTo(inHand.first, place, inHand.second)) + null + } + + inHand?.first == place -> null + sources.contains(place to count) -> place to count + else -> null + } + } + + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.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, " + + "${current.freeCellCount} cells free." + }, + fontWeight = FontWeight.Bold, + ) + + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + current.cells.forEachIndexed { index, card -> + TableSlot( + highlighted = held?.first == Place.cell(index) || + targets.contains(Place.cell(index)), + onClick = { take(Place.cell(index), 1) }, + ) { + card?.let { PlayingCardView(card = it, width = TABLE_CARD_WIDTH) } + ?: EmptySpace(label = "·") + } + } + current.foundations.forEachIndexed { index, pile -> + TableSlot( + highlighted = targets.contains(Place.foundation(index)), + onClick = { take(Place.foundation(index), 1) }, + ) { + pile.lastOrNull()?.let { PlayingCardView(card = it, width = TABLE_CARD_WIDTH) } + ?: EmptySpace(label = Suit.entries[index].symbol) + } + } + } + + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.Top, + ) { + current.tableau.forEachIndexed { index, column -> + if (column.isEmpty()) { + TableSlot( + highlighted = targets.contains(Place.column(index)), + onClick = { take(Place.column(index), 1) }, + ) { EmptySpace() } + } else { + Box( + modifier = Modifier + .width(TABLE_CARD_WIDTH) + .height(TABLE_CARD_HEIGHT + TABLE_STACK_STEP * (column.size - 1)), + ) { + column.forEachIndexed { depth, card -> + // Taking a card takes everything resting on it. + val count = column.size - depth + TableSlot( + modifier = Modifier.offset(y = TABLE_STACK_STEP * depth), + highlighted = held == (Place.column(index) to count) || + (depth == column.lastIndex && targets.contains(Place.column(index))), + onClick = { take(Place.column(index), count) }, + ) { PlayingCardView(card = card, width = TABLE_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 { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) { + PatienceEndDialog("The deal is left as it stands.", onExit) { confirmingEnd = false } + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/PyramidScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/PyramidScreen.kt new file mode 100644 index 0000000..4701a5f --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/PyramidScreen.kt @@ -0,0 +1,260 @@ +package org.prolibertate.games.ui.game + +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.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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.pyramid.DrawCard +import org.prolibertate.games.game.pyramid.PYRAMID_SIZE +import org.prolibertate.games.game.pyramid.PyramidMove +import org.prolibertate.games.game.pyramid.PyramidSpot +import org.prolibertate.games.game.pyramid.PyramidState +import org.prolibertate.games.game.pyramid.PyramidZone +import org.prolibertate.games.game.pyramid.RecycleWaste +import org.prolibertate.games.game.pyramid.ROWS +import org.prolibertate.games.game.pyramid.TakeKing +import org.prolibertate.games.game.pyramid.TakePair +import org.prolibertate.games.game.pyramid.rowStart +import org.prolibertate.games.game.pyramid.valueOf +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 + +private const val TITLE = "Pyramid" + +/** Cards are small here: the widest row is seven of them side by side. */ +private val PYRAMID_CARD: Dp = 40.dp +private val PYRAMID_CARD_HEIGHT: Dp = PYRAMID_CARD * 7f / 5f + +/** Rows overlap vertically so the whole pyramid fits above the pack. */ +private val ROW_STEP: Dp = PYRAMID_CARD_HEIGHT * 2f / 3f + +/** + * The pyramid. + * + * Drawn as it is dealt: seven rows, each one offset half a card so every card + * sits on the two below it, and rows overlapping by a third so all seven fit. + * The shape is the rules — a card is only in play once both the cards resting on + * it have gone, and being able to see that at a glance is most of playing it. + * + * Tapping a king takes it alone. Tapping any other card holds it, and tapping + * its partner takes the pair; the ones that would pair with what is held are the + * only cards that light up. + */ +@Composable +fun PyramidScreen( + 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 { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) { + PatienceEndDialog("The pyramid is left as it stands.", onExit) { confirmingEnd = false } + } + } + return + } + + val kings = legal.filterIsInstance().map { it.spot }.toSet() + val pairs = legal.filterIsInstance() + val canDraw = legal.contains(DrawCard) + val canRedeal = legal.contains(RecycleWaste) + val finished = legal.isEmpty() + + // Everything that could pair with what is in hand. + val partners = held?.let { spot -> + pairs.mapNotNull { + when (spot) { + it.first -> it.second + it.second -> it.first + else -> null + } + }.toSet() + }.orEmpty() + val pairable = pairs.flatMap { listOf(it.first, it.second) }.toSet() + + fun tap(spot: PyramidSpot) { + held = when { + kings.contains(spot) && held == null -> { + controller.submit(TakeKing(spot)) + null + } + + held != null && partners.contains(spot) -> { + controller.submit(TakePair(held!!, spot)) + null + } + + held == spot -> null + pairable.contains(spot) -> spot + else -> null + } + } + + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = when { + current.isWon -> "Pyramid cleared, in ${current.moves} moves." + finished -> "Blocked — nothing pairs to thirteen." + held != null -> "Tap the card that makes it thirteen." + else -> "${current.pyramid.count { it != null }} of $PYRAMID_SIZE left." + }, + fontWeight = FontWeight.Bold, + ) + + PyramidView( + state = current, + held = held, + kings = kings, + partners = partners, + pairable = pairable, + onTap = ::tap, + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TableSlot( + highlighted = false, + width = PYRAMID_CARD, + onClick = { + if (canDraw) { + controller.submit(DrawCard) + } else if (canRedeal) { + controller.submit(RecycleWaste) + } + }, + ) { + if (current.stock.isNotEmpty()) { + CardBackView(width = PYRAMID_CARD) + } else { + EmptySpace(label = if (canRedeal) "↻" else "", width = PYRAMID_CARD) + } + } + val wasteSpot = PyramidSpot(PyramidZone.WASTE) + TableSlot( + highlighted = held == wasteSpot || + partners.contains(wasteSpot) || + kings.contains(wasteSpot), + width = PYRAMID_CARD, + onClick = { tap(wasteSpot) }, + ) { + current.waste.lastOrNull() + ?.let { PlayingCardView(card = it, width = PYRAMID_CARD) } + ?: EmptySpace(width = PYRAMID_CARD) + } + Text( + text = "${current.stock.size} in hand, " + + "${current.options.redeals - current.redealsUsed} turns left", + style = MaterialTheme.typography.bodySmall, + ) + } + + 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 { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) { + PatienceEndDialog("The pyramid is left as it stands.", onExit) { confirmingEnd = false } + } + } +} + +@Composable +private fun PyramidView( + state: PyramidState, + held: PyramidSpot?, + kings: Set, + partners: Set, + pairable: Set, + onTap: (PyramidSpot) -> Unit, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(PYRAMID_CARD_HEIGHT + ROW_STEP * (ROWS - 1)), + ) { + // Each row is half a card narrower on each side than the one below, so + // every card sits in the notch between the two under it. + for (row in 0 until ROWS) { + val cardsInRow = row + 1 + for (column in 0 until cardsInRow) { + val index = rowStart(row) + column + val card = state.pyramid[index] ?: continue + val spot = PyramidSpot(PyramidZone.PYRAMID, index) + val playable = pairable.contains(spot) || kings.contains(spot) + Box( + modifier = Modifier.offset( + // Centred on the widest row, which is seven wide. + x = PYRAMID_CARD * (ROWS - cardsInRow) / 2f + PYRAMID_CARD * column, + y = ROW_STEP * row, + ), + ) { + TableSlot( + highlighted = held == spot || partners.contains(spot), + width = PYRAMID_CARD, + onClick = { if (playable) onTap(spot) }, + ) { + PlayingCardView( + card = card, + width = PYRAMID_CARD, + enabled = playable, + caption = valueOf(card).toString(), + ) + } + } + } + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/SpiderScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/SpiderScreen.kt new file mode 100644 index 0000000..2abab04 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/SpiderScreen.kt @@ -0,0 +1,191 @@ +package org.prolibertate.games.ui.game + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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 org.prolibertate.games.game.spider.DealRow +import org.prolibertate.games.game.spider.MoveRun +import org.prolibertate.games.game.spider.RUNS_TO_WIN +import org.prolibertate.games.game.spider.SpiderMove +import org.prolibertate.games.game.spider.SpiderState +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 + +private const val TITLE = "Spider" + +/** + * The Spider table. + * + * Ten columns of a hundred and four cards, so it scrolls sideways and the cards + * are the same third-overlapped stacks as everywhere else. What is worth showing + * above them is the two numbers that decide the game: how many runs have gone + * away, and how many rows are still in the stock waiting to bury everything. + */ +@Composable +fun SpiderScreen( + 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 { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) { + PatienceEndDialog("The deal is left as it stands.", onExit) { confirmingEnd = false } + } + } + return + } + + val runs = legal.filterIsInstance() + val canDeal = legal.contains(DealRow) + val finished = legal.isEmpty() + val targets = held?.let { (from, count) -> + runs.filter { it.from == from && it.count == count }.map { it.to }.toSet() + }.orEmpty() + val sources = runs.map { it.from to it.count }.toSet() + + fun take(column: Int, count: Int) { + val inHand = held + held = when { + inHand != null && targets.contains(column) -> { + controller.submit(MoveRun(inHand.first, column, inHand.second)) + null + } + + inHand?.first == column && inHand.second == count -> null + sources.contains(column to count) -> column to count + else -> null + } + } + + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = when { + current.isWon -> "All eight runs, in ${current.moves} moves." + finished -> "Blocked — nothing left to try." + held != null -> "Tap the column it goes to." + else -> "${current.completed} of $RUNS_TO_WIN runs away." + }, + fontWeight = FontWeight.Bold, + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TableSlot( + highlighted = false, + onClick = { if (canDeal) controller.submit(DealRow) }, + ) { + if (current.stock.isNotEmpty()) { + CardBackView(width = TABLE_CARD_WIDTH) + } else { + EmptySpace() + } + } + Text( + text = "${current.stock.size / 10} rows left" + + if (!canDeal && current.stock.isNotEmpty()) " — fill every column first" else "", + style = MaterialTheme.typography.bodySmall, + ) + } + + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.Top, + ) { + current.tableau.forEachIndexed { index, pile -> + val total = pile.faceDown.size + pile.faceUp.size + if (total == 0) { + TableSlot( + highlighted = targets.contains(index), + onClick = { take(index, 1) }, + ) { EmptySpace() } + } else { + Box( + modifier = Modifier + .width(TABLE_CARD_WIDTH) + .height(TABLE_CARD_HEIGHT + TABLE_STACK_STEP * (total - 1)), + ) { + pile.faceDown.forEachIndexed { depth, _ -> + CardBackView( + width = TABLE_CARD_WIDTH, + modifier = Modifier.offset(y = TABLE_STACK_STEP * depth), + ) + } + pile.faceUp.forEachIndexed { depth, card -> + val count = pile.faceUp.size - depth + val row = pile.faceDown.size + depth + TableSlot( + modifier = Modifier.offset(y = TABLE_STACK_STEP * row), + highlighted = held == (index to count) || + (depth == pile.faceUp.lastIndex && targets.contains(index)), + onClick = { take(index, count) }, + ) { PlayingCardView(card = card, width = TABLE_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 { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) { + PatienceEndDialog("The deal is left as it stands.", onExit) { confirmingEnd = false } + } + } +} 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 45ed393..2c23ca4 100644 --- a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt @@ -75,6 +75,9 @@ class CatalogTest { GameCatalog.PEG_SOLITAIRE, GameCatalog.YAHTZEE, GameCatalog.KLONDIKE, + GameCatalog.FREECELL, + GameCatalog.SPIDER, + GameCatalog.PYRAMID, GameCatalog.WIZARD, GameCatalog.CHESS, GameCatalog.TAYU, From b12a0ed3c97bb3346788289dd156558bd92179f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 17:16:16 +0000 Subject: [PATCH 15/16] A menu tree, and games that can be in more than one branch of it Three sections on the main screen -- Solitaire, Card Games, Board Games -- and Trick-taking sitting under Card Games rather than beside it, because it is a kind of card game and not a rival to one. GameDescriptor carries a set of menus rather than one category. A patience is solitaire and a card game; Euchre is a card game and a trick-taking one; peg solitaire is solitaire and a board game. Each is listed in every place it belongs, which is the point: a game reachable one way and not the other is a game somebody cannot find. GameMenu reads its parent from a `when` rather than taking it as a constructor argument. An enum entry cannot refer to another entry while the class is still initialising, and the lookup afterwards costs nothing. Counting a section counts a set rather than adding lists together, since a game in both Card Games and Trick-taking is one game and not two. hasAnything asks about a menu and its children together, so a section is only hidden when there is nothing under it anywhere -- the same rule that used to keep a heading from being stranded over an empty category. The dev-build "coming soon" behaviour is unchanged and still tested. What could not survive the change is the old test that counted games across categories and expected the total to equal the catalogue: with a game in several menus that sum is meaningly larger. It is now a union, which is what it was really checking -- that no game is stranded in no menu at all. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC --- .../org/prolibertate/games/game/Catalog.kt | 106 +++++++++---- .../java/org/prolibertate/games/ui/AppRoot.kt | 149 +++++++++++++++--- .../prolibertate/games/game/CatalogTest.kt | 65 ++++++-- 3 files changed, 250 insertions(+), 70 deletions(-) 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 306e0b1..ec88e0b 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -1,22 +1,39 @@ package org.prolibertate.games.game /** - * The catalogue the main menu is built from. + * The menus the main screen is built from. * - * Every game the app intends to ship appears here, whether or not its rules - * engine exists yet. [GameDescriptor.available] is what the menu keys off to - * decide between "play" and "coming soon", so adding a game is a matter of - * flipping that flag once its engine and screen land. + * A tree rather than a list, because "trick-taking" is a kind of card game + * rather than a rival to it. [parent] is read from a `when` rather than passed + * to the constructor: an enum entry cannot refer to another entry while the + * class is still initialising, and a lookup afterwards is free. */ -enum class GameCategory(val label: String) { - CARD("Playing Card Games"), - BOARD("Board Games"), +enum class GameMenu(val label: String, val blurb: String) { + SOLITAIRE("Solitaire", "Played alone, against the deal or the board."), + CARDS("Card Games", "Anything played with a pack."), + TRICK_TAKING("Trick-taking", "Lead, follow, and count what you took."), + BOARD("Board Games", "Boards, pieces and dice."), + ; + + val parent: GameMenu? get() = when (this) { + TRICK_TAKING -> CARDS + else -> null + } + + val children: List get() = entries.filter { it.parent == this } + + val isTopLevel: Boolean get() = parent == null } data class GameDescriptor( val id: String, val title: String, - val category: GameCategory, + /** + * Every menu this game appears under. A patience is both solitaire and a + * card game, and Euchre is both a card game and a trick-taking one — so + * this is a set, and the same game is listed in each place it belongs. + */ + val menus: Set, val minPlayers: Int, val maxPlayers: Int, /** True when players are grouped into partnerships rather than playing solo. */ @@ -25,6 +42,14 @@ data class GameDescriptor( val available: Boolean, ) +/** + * The catalogue the menus are built from. + * + * Every game the app intends to ship appears here, whether or not its rules + * engine exists yet. [GameDescriptor.available] is what the menu keys off to + * decide between "play" and "coming soon", so adding a game is a matter of + * flipping that flag once its engine and screen land. + */ object GameCatalog { const val EUCHRE = "euchre" @@ -56,7 +81,7 @@ object GameCatalog { GameDescriptor( id = EUCHRE, title = "Euchre", - category = GameCategory.CARD, + menus = setOf(GameMenu.CARDS, GameMenu.TRICK_TAKING), minPlayers = 4, maxPlayers = 4, teamBased = true, @@ -66,7 +91,7 @@ object GameCatalog { GameDescriptor( id = KAISER, title = "Kaiser", - category = GameCategory.CARD, + menus = setOf(GameMenu.CARDS, GameMenu.TRICK_TAKING), minPlayers = 4, maxPlayers = 4, teamBased = true, @@ -76,7 +101,7 @@ object GameCatalog { GameDescriptor( id = PRESIDENT, title = "President", - category = GameCategory.CARD, + menus = setOf(GameMenu.CARDS), minPlayers = 3, maxPlayers = 7, teamBased = false, @@ -86,7 +111,7 @@ object GameCatalog { GameDescriptor( id = GOLF, title = "Golf", - category = GameCategory.CARD, + menus = setOf(GameMenu.CARDS), minPlayers = 2, maxPlayers = 6, teamBased = false, @@ -96,7 +121,7 @@ object GameCatalog { GameDescriptor( id = WIZARD, title = "Wizard", - category = GameCategory.CARD, + menus = setOf(GameMenu.CARDS, GameMenu.TRICK_TAKING), minPlayers = 3, maxPlayers = 6, teamBased = false, @@ -106,7 +131,7 @@ object GameCatalog { GameDescriptor( id = CRAZY_EIGHTS, title = "Crazy 8s", - category = GameCategory.CARD, + menus = setOf(GameMenu.CARDS), minPlayers = 2, maxPlayers = 6, teamBased = false, @@ -116,7 +141,7 @@ object GameCatalog { GameDescriptor( id = CRIBBAGE, title = "Cribbage", - category = GameCategory.CARD, + menus = setOf(GameMenu.CARDS), minPlayers = 2, maxPlayers = 4, // Only at four, where the two pairs share a board between them. @@ -128,7 +153,7 @@ object GameCatalog { GameDescriptor( id = HEARTS, title = "Hearts", - category = GameCategory.CARD, + menus = setOf(GameMenu.CARDS, GameMenu.TRICK_TAKING), minPlayers = 4, maxPlayers = 4, teamBased = false, @@ -139,7 +164,7 @@ object GameCatalog { GameDescriptor( id = KLONDIKE, title = "Klondike", - category = GameCategory.CARD, + menus = setOf(GameMenu.SOLITAIRE, GameMenu.CARDS), minPlayers = 1, maxPlayers = 1, teamBased = false, @@ -150,7 +175,7 @@ object GameCatalog { GameDescriptor( id = FREECELL, title = "FreeCell", - category = GameCategory.CARD, + menus = setOf(GameMenu.SOLITAIRE, GameMenu.CARDS), minPlayers = 1, maxPlayers = 1, teamBased = false, @@ -161,7 +186,7 @@ object GameCatalog { GameDescriptor( id = SPIDER, title = "Spider", - category = GameCategory.CARD, + menus = setOf(GameMenu.SOLITAIRE, GameMenu.CARDS), minPlayers = 1, maxPlayers = 1, teamBased = false, @@ -172,7 +197,7 @@ object GameCatalog { GameDescriptor( id = PYRAMID, title = "Pyramid", - category = GameCategory.CARD, + menus = setOf(GameMenu.SOLITAIRE, GameMenu.CARDS), minPlayers = 1, maxPlayers = 1, teamBased = false, @@ -183,7 +208,7 @@ object GameCatalog { GameDescriptor( id = SEQUENCE, title = "Sequence", - category = GameCategory.BOARD, + menus = setOf(GameMenu.BOARD, GameMenu.CARDS), minPlayers = 2, maxPlayers = 12, teamBased = true, @@ -193,7 +218,7 @@ object GameCatalog { GameDescriptor( id = MASTERMIND, title = "Mastermind", - category = GameCategory.BOARD, + menus = setOf(GameMenu.BOARD), minPlayers = 2, maxPlayers = 2, teamBased = false, @@ -204,7 +229,7 @@ object GameCatalog { GameDescriptor( id = BACKGAMMON, title = "Backgammon", - category = GameCategory.BOARD, + menus = setOf(GameMenu.BOARD), minPlayers = 2, maxPlayers = 2, teamBased = false, @@ -214,7 +239,7 @@ object GameCatalog { GameDescriptor( id = CHESS, title = "Chess", - category = GameCategory.BOARD, + menus = setOf(GameMenu.BOARD), minPlayers = 2, maxPlayers = 2, teamBased = false, @@ -224,7 +249,7 @@ object GameCatalog { GameDescriptor( id = CHECKERS, title = "Checkers", - category = GameCategory.BOARD, + menus = setOf(GameMenu.BOARD), minPlayers = 2, maxPlayers = 2, teamBased = false, @@ -234,7 +259,7 @@ object GameCatalog { GameDescriptor( id = MORRIS, title = "Nine Men's Morris", - category = GameCategory.BOARD, + menus = setOf(GameMenu.BOARD), minPlayers = 2, maxPlayers = 2, teamBased = false, @@ -244,7 +269,7 @@ object GameCatalog { GameDescriptor( id = PEG_SOLITAIRE, title = "Peg Solitaire", - category = GameCategory.BOARD, + menus = setOf(GameMenu.SOLITAIRE, GameMenu.BOARD), // The first game here played alone. Everything downstream reads the // seat count from these two numbers, so one seat is all it takes. minPlayers = 1, @@ -257,7 +282,7 @@ object GameCatalog { GameDescriptor( id = YAHTZEE, title = "Yahtzee", - category = GameCategory.BOARD, + menus = setOf(GameMenu.BOARD), minPlayers = 1, maxPlayers = 6, teamBased = false, @@ -268,7 +293,7 @@ object GameCatalog { GameDescriptor( id = PIRATES, title = "Pirates and Bulgars", - category = GameCategory.BOARD, + menus = setOf(GameMenu.BOARD), minPlayers = 2, maxPlayers = 2, teamBased = false, @@ -279,7 +304,7 @@ object GameCatalog { GameDescriptor( id = TAYU, title = "Ta Yü", - category = GameCategory.BOARD, + menus = setOf(GameMenu.BOARD), minPlayers = 2, maxPlayers = 4, // Two play solo, four play in partnerships across the two axes. @@ -301,13 +326,24 @@ object GameCatalog { * production build lists only what can actually be played, so nobody * downloads a release and taps into a dead end. */ - fun byCategory( - category: GameCategory, + fun inMenu( + menu: GameMenu, includeComingSoon: Boolean = true, ): List = all.filter { - it.category == category && (includeComingSoon || it.available) + it.menus.contains(menu) && (includeComingSoon || it.available) } - /** Everything currently playable, regardless of category. */ + /** + * Whether a menu is worth showing at all. + * + * A section with nothing in it and no populated child under it is a heading + * over an empty room, which is what a production build would get if every + * game in a menu were still unfinished. + */ + fun hasAnything(menu: GameMenu, includeComingSoon: Boolean = true): Boolean = + inMenu(menu, includeComingSoon).isNotEmpty() || + menu.children.any { inMenu(it, includeComingSoon).isNotEmpty() } + + /** Everything currently playable, whatever menu it is under. */ val playable: List get() = all.filter { it.available } } diff --git a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt index e140fc3..59e8693 100644 --- a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt +++ b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.material3.Button @@ -36,7 +37,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.prolibertate.games.R import org.prolibertate.games.game.GameCatalog -import org.prolibertate.games.game.GameCategory +import org.prolibertate.games.game.GameMenu import org.prolibertate.games.game.GameDescriptor import org.prolibertate.games.game.engine.TableConfig import org.prolibertate.games.net.LobbyController @@ -64,6 +65,9 @@ class AppEnv( */ sealed interface Route { data object Menu : Route + + /** One section of the menu: its games, and any sub-section under it. */ + data class Section(val menu: GameMenu) : Route data object Settings : Route data object Scorekeeper : Route data class Setup(val gameId: String) : Route @@ -152,12 +156,20 @@ private fun AppContent( // Dev builds show the games that are not finished yet; production // releases list only what can actually be played. showComingSoon = showComingSoon, - onPickGame = { push(Route.Setup(it.id)) }, + onPickSection = { push(Route.Section(it)) }, onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) }, onScorekeeper = { push(Route.Scorekeeper) }, onSettings = { push(Route.Settings) }, ) + is Route.Section -> SectionScreen( + menu = route.menu, + showComingSoon = showComingSoon, + onPickGame = { push(Route.Setup(it.id)) }, + onPickSection = { push(Route.Section(it)) }, + onBack = pop, + ) + is Route.Settings -> SettingsScreen( env = env, settings = settings, @@ -206,7 +218,7 @@ private fun AppContent( @Composable fun MainMenuScreen( showComingSoon: Boolean, - onPickGame: (GameDescriptor) -> Unit, + onPickSection: (GameMenu) -> Unit, onJoinGame: () -> Unit, onScorekeeper: () -> Unit, onSettings: () -> Unit, @@ -248,28 +260,127 @@ fun MainMenuScreen( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxSize(), ) { - GameCategory.entries.forEach { category -> - val games = GameCatalog.byCategory(category, includeComingSoon = showComingSoon) - // Don't leave a heading stranded over nothing. - if (games.isEmpty()) return@forEach - - item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) { - Text( - text = category.label, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), - ) - } - items(games) { game -> - GameTile(game = game, onClick = { onPickGame(game) }) - } + // Only the top of the tree. Trick-taking is reached through + // Card Games, which is where somebody looking for it would go. + val sections = GameMenu.entries.filter { + it.isTopLevel && GameCatalog.hasAnything(it, includeComingSoon = showComingSoon) + } + items(sections) { section -> + SectionTile( + menu = section, + count = countIn(section, showComingSoon), + onClick = { onPickSection(section) }, + ) } } } } } + +/** + * How many games a section offers, counting whatever is under it as well. + * + * A game in both a section and its sub-section is one game, not two, so this + * counts a set rather than adding the lists together. + */ +private fun countIn(menu: GameMenu, showComingSoon: Boolean): Int = + (GameCatalog.inMenu(menu, showComingSoon) + + menu.children.flatMap { GameCatalog.inMenu(it, showComingSoon) }) + .toSet().size + +/** + * One section of the menu. + * + * Sub-sections come first and its own games after, so Card Games opens on + * "Trick-taking" and then everything played with a pack — including the + * trick-taking games themselves, which belong in both places and are listed in + * both. A game reachable one way and not the other would just be a game + * somebody could not find. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SectionScreen( + menu: GameMenu, + showComingSoon: Boolean, + onPickGame: (GameDescriptor) -> Unit, + onPickSection: (GameMenu) -> Unit, + onBack: () -> Unit, +) { + val children = menu.children.filter { + GameCatalog.hasAnything(it, includeComingSoon = showComingSoon) + } + val games = GameCatalog.inMenu(menu, includeComingSoon = showComingSoon) + + ScreenScaffold(title = menu.label, onBack = onBack) { modifier -> + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 172.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier.fillMaxSize(), + ) { + item(span = { GridItemSpan(maxLineSpan) }) { + Text( + text = menu.blurb, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + items(children) { child -> + SectionTile( + menu = child, + count = countIn(child, showComingSoon), + onClick = { onPickSection(child) }, + ) + } + if (games.isNotEmpty() && children.isNotEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + Text( + text = "All ${menu.label.lowercase()}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), + ) + } + } + items(games) { game -> + GameTile(game = game, onClick = { onPickGame(game) }) + } + } + } +} + +/** A way into a section, rather than into a game. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SectionTile(menu: GameMenu, count: Int, onClick: () -> Unit) { + Card( + onClick = onClick, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = menu.label, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = menu.blurb, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 4.dp), + ) + Text( + text = if (count == 1) "1 game" else "$count games", + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(top = 8.dp), + ) + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun GameTile(game: GameDescriptor, onClick: () -> Unit) { 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 2c23ca4..674a2a4 100644 --- a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt @@ -16,21 +16,54 @@ class CatalogTest { @Test fun `a production build lists only games that can be played`() { - GameCategory.entries.forEach { category -> - val shown = GameCatalog.byCategory(category, includeComingSoon = false) + GameMenu.entries.forEach { menu -> + val shown = GameCatalog.inMenu(menu, includeComingSoon = false) assertTrue( - "$category offers an unfinished game in a release build", + "$menu offers an unfinished game in a release build", shown.all { it.available }, ) } } @Test - fun `a dev build lists the whole catalogue`() { - val shown = GameCategory.entries.flatMap { - GameCatalog.byCategory(it, includeComingSoon = true) + fun `every game is reachable from at least one menu`() { + // Counting is no good now that a game can be in several menus: the + // union is what matters, and a game in none of them would be in the + // catalogue but on no screen. + val reachable = GameMenu.entries.flatMap { + GameCatalog.inMenu(it, includeComingSoon = true) + }.toSet() + assertEquals(GameCatalog.all.toSet(), reachable) + GameCatalog.all.forEach { + assertTrue("${it.id} is in no menu", it.menus.isNotEmpty()) } - assertEquals(GameCatalog.all.size, shown.size) + } + + @Test + fun `a game can be in more than one menu`() { + // The whole point of the set. A patience is solitaire and a card game; + // Euchre is a card game and a trick-taking one. + val klondike = GameCatalog.byId(GameCatalog.KLONDIKE)!! + assertTrue(klondike.menus.containsAll(setOf(GameMenu.SOLITAIRE, GameMenu.CARDS))) + assertTrue(GameCatalog.inMenu(GameMenu.SOLITAIRE).contains(klondike)) + assertTrue(GameCatalog.inMenu(GameMenu.CARDS).contains(klondike)) + } + + @Test + fun `trick-taking sits under cards, and its games are card games too`() { + assertEquals(GameMenu.CARDS, GameMenu.TRICK_TAKING.parent) + assertEquals(listOf(GameMenu.TRICK_TAKING), GameMenu.CARDS.children) + assertTrue(GameMenu.TRICK_TAKING.isTopLevel.not()) + assertTrue(GameMenu.SOLITAIRE.isTopLevel) + + val tricks = GameCatalog.inMenu(GameMenu.TRICK_TAKING) + assertTrue("there should be some", tricks.isNotEmpty()) + // A sub-menu that listed something its parent did not would be a game + // you could reach one way and not the other. + assertTrue( + "every trick-taking game is also a card game", + tricks.all { it.menus.contains(GameMenu.CARDS) }, + ) } @Test @@ -42,19 +75,19 @@ class CatalogTest { // engine is written. assertTrue(GameCatalog.all.all { it.available }) assertEquals( - GameCatalog.byCategory(GameCategory.BOARD, includeComingSoon = false), - GameCatalog.byCategory(GameCategory.BOARD, includeComingSoon = true), + GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = false), + GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = true), ) } @Test - fun `hiding unfinished games still leaves something to play in each category`() { - // A category filtered down to nothing would leave a stranded heading, - // and a menu with no playable game at all would be a broken release. - GameCategory.entries.forEach { category -> - assertFalse( - "$category has nothing playable in a release build", - GameCatalog.byCategory(category, includeComingSoon = false).isEmpty(), + fun `hiding unfinished games still leaves something to play in every menu`() { + // A menu filtered down to nothing would leave a stranded heading, and a + // release where a whole section was empty would be a broken one. + GameMenu.entries.forEach { menu -> + assertTrue( + "$menu has nothing playable in a release build", + GameCatalog.hasAnything(menu, includeComingSoon = false), ) } assertTrue(GameCatalog.playable.isNotEmpty()) From e5e1d134a7ca73d3e5245d5cdcfe6d333b036157 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 17:32:35 +0000 Subject: [PATCH 16/16] One centred column for the main menu The games moved a level down when the menus became a tree, so the top screen now holds three sections and two buttons. Spread across an adaptive grid that read as a page half empty rather than as a menu. One column, held in the middle, capped at 340dp so a tablet gets a menu rather than a row of very wide buttons. Join and Scorekeeper sit below the sections and run the same width, so the column stays a column all the way down rather than turning into a wrapped row at the top. The grid is still what the section screens use, where there are enough games for it to earn its place. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC --- .../java/org/prolibertate/games/ui/AppRoot.kt | 66 +++++++++++-------- 1 file changed, 37 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt index 59e8693..5c2c3bd 100644 --- a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt +++ b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt @@ -2,14 +2,15 @@ package org.prolibertate.games.ui import androidx.activity.compose.BackHandler import androidx.compose.foundation.background +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid @@ -214,7 +215,7 @@ private fun AppContent( // Main menu // --------------------------------------------------------------------------- -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun MainMenuScreen( showComingSoon: Boolean, @@ -233,45 +234,52 @@ fun MainMenuScreen( ) } ) { padding -> - Column( + // Only the top of the tree. Trick-taking is reached through Card Games, + // which is where somebody looking for it would go. + val sections = GameMenu.entries.filter { + it.isTopLevel && GameCatalog.hasAnything(it, includeComingSoon = showComingSoon) + } + + // One column, held in the middle. There are only ever a handful of + // entries here now that the games themselves live a level down, and a + // short list spread across a grid reads as a page half empty rather + // than as a menu. Capped in width so it does not become a row of very + // wide buttons on a tablet. + Box( modifier = Modifier .fillMaxSize() .padding(padding) .padding(horizontal = 12.dp), + contentAlignment = Alignment.Center, ) { - // Wraps rather than running off the edge: two buttons and a narrow - // phone would otherwise push the second one past the screen. - FlowRow( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - OutlinedButton(onClick = onJoinGame) { Text("Join a game nearby") } - // Not a game, and deliberately not in the catalogue: it keeps - // score for whatever is being played on the actual table. - OutlinedButton(onClick = onScorekeeper) { Text("Scorekeeper") } - } - - // The column count follows the available width, so a phone in - // portrait gets two and a tablet in landscape gets four or more. - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 172.dp), + Column( + modifier = Modifier + .widthIn(max = 340.dp) + .fillMaxWidth() + .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, ) { - // Only the top of the tree. Trick-taking is reached through - // Card Games, which is where somebody looking for it would go. - val sections = GameMenu.entries.filter { - it.isTopLevel && GameCatalog.hasAnything(it, includeComingSoon = showComingSoon) - } - items(sections) { section -> + sections.forEach { section -> SectionTile( menu = section, count = countIn(section, showComingSoon), onClick = { onPickSection(section) }, ) } + + // Below the games, and full width like them, so the column + // stays one column all the way down. + OutlinedButton( + onClick = onJoinGame, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) { Text("Join a game nearby") } + // Not a game, and deliberately not in the catalogue: it keeps + // score for whatever is being played on the actual table. + OutlinedButton( + onClick = onScorekeeper, + modifier = Modifier.fillMaxWidth(), + ) { Text("Scorekeeper") } } } }