From 88948eac9ec71bcc3a44702312c82ad6afcf39d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 05:11:00 +0000 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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