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..840b2fe 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,14 @@ 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 PEG_SOLITAIRE = "pegsolitaire" const val SEQUENCE = "sequence" const val MASTERMIND = "mastermind" const val BACKGAMMON = "backgammon" @@ -116,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", @@ -177,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/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/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/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..7c7b05c 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 @@ -81,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 @@ -430,6 +444,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) } } 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. + } + } +} 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. + } + } +}