diff --git a/app/src/main/java/org/prolibertate/games/game/Catalog.kt b/app/src/main/java/org/prolibertate/games/game/Catalog.kt index c38d813..14dc42c 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -49,6 +49,7 @@ object GameCatalog { * joins [all] when its screen lands. */ const val YAHTZEE = "yahtzee" + const val KLONDIKE = "klondike" const val SEQUENCE = "sequence" const val MASTERMIND = "mastermind" const val BACKGAMMON = "backgammon" diff --git a/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeAi.kt b/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeAi.kt new file mode 100644 index 0000000..b542471 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeAi.kt @@ -0,0 +1,50 @@ +package org.prolibertate.games.game.klondike + +import org.prolibertate.games.game.engine.GameAi + +/** + * Plays the deal the way a person does when they are not thinking hard. + * + * Nothing asks it to during an ordinary game — the only seat belongs to the + * player — but a seat has to be playable by something, and this is what a + * "suggest a move" button would offer. + * + * The order matters more than the numbers. Sending a card home always wins, + * turning one up comes next, and aimless shuffling between columns scores below + * drawing so it is only reached when there is nothing else at all. That last + * ordering is what stops it walking a run back and forth between two columns for + * ever. + */ +object KlondikeAi : GameAi { + + override fun chooseMove(state: KlondikeState, seat: Int, legal: List): KlondikeMove { + require(legal.isNotEmpty()) { "No legal move for seat $seat" } + return legal.maxBy { worth(state, it) } + } + + private fun worth(state: KlondikeState, move: KlondikeMove): Int = when (move) { + Draw -> 30 + Redeal -> 20 + is MoveCards -> when { + move.to.kind == SpotKind.FOUNDATION -> 100 + // Taking a card back off a foundation is occasionally right and + // usually not, so it sits below doing nothing much at all. + move.from.kind == SpotKind.FOUNDATION -> -50 + move.from.kind == SpotKind.TABLEAU && turnsACardUp(state, move) -> 80 + move.from.kind == SpotKind.TABLEAU && emptiesAColumn(state, move) -> 60 + move.from.kind == SpotKind.WASTE -> 40 + else -> 15 + } + } + + /** True when the move strips a column back to its buried cards. */ + private fun turnsACardUp(state: KlondikeState, move: MoveCards): Boolean { + val pile = state.tableau[move.from.index] + return move.count == pile.faceUp.size && pile.faceDown.isNotEmpty() + } + + private fun emptiesAColumn(state: KlondikeState, move: MoveCards): Boolean { + val pile = state.tableau[move.from.index] + return move.count == pile.faceUp.size && pile.faceDown.isEmpty() + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeModel.kt b/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeModel.kt new file mode 100644 index 0000000..cf8ed09 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeModel.kt @@ -0,0 +1,146 @@ +package org.prolibertate.games.game.klondike + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.cards.Rank +import org.prolibertate.games.game.cards.Suit + +const val TABLEAU_PILES: Int = 7 +const val FOUNDATIONS: Int = 4 + +/** + * Where a card can be, and which one of them. + * + * A kind and an index rather than a sealed hierarchy: every spot in this game is + * one of four things and at most one number identifies it, so a pair says + * everything a subclass per kind would, and serialises as two plain fields. + */ +@Serializable +enum class SpotKind { STOCK, WASTE, TABLEAU, FOUNDATION } + +@Serializable +data class Spot(val kind: SpotKind, val index: Int = 0) { + companion object { + val stock = Spot(SpotKind.STOCK) + val waste = Spot(SpotKind.WASTE) + fun tableau(index: Int) = Spot(SpotKind.TABLEAU, index) + fun foundation(index: Int) = Spot(SpotKind.FOUNDATION, index) + } +} + +/** + * A tableau pile: what is still turned down, and the run turned up on top of it. + * + * Kept as two lists because the split is the whole tension of the game. A pile + * is only worth what has been turned up, and the buried part is the thing being + * played for. + */ +@Serializable +data class TableauPile( + val faceDown: List = emptyList(), + val faceUp: List = emptyList(), +) { + val isEmpty: Boolean get() = faceDown.isEmpty() && faceUp.isEmpty() + val top: Card? get() = faceUp.lastOrNull() +} + +@Serializable +data class KlondikeOptions( + /** + * One card off the stock at a time, or three. Three is the harder game and + * the older one; one is what most people mean by patience now. + */ + val drawCount: Int = 1, + /** + * How many times the waste may be turned back into the stock. Null is as + * often as you like; zero is a single pass through the pack, which is the + * gambling version. + */ + val redealLimit: Int? = null, + /** + * Only a king may be moved into an empty column. Turning this off is a + * common kindness that makes far more deals winnable. + */ + val kingsOnlyInSpaces: Boolean = true, +) { + init { + require(drawCount in 1..3) { "Draw one to three" } + require(redealLimit == null || redealLimit >= 0) { "redealLimit cannot be negative" } + } +} + +@Serializable +data class KlondikeState( + val options: KlondikeOptions, + val seed: Long, + /** Face down, dealt from the end. */ + val stock: List, + /** Face up; the last card is the one in play. */ + val waste: List, + /** One per suit, in [Suit] order, each running ace upwards. */ + val foundations: List>, + val tableau: List, + val redealsUsed: Int, + val moves: Int, + val log: List, +) { + /** Every foundation full is the game won. */ + val isWon: Boolean get() = foundations.all { it.size == Rank.standard.size } + + val canRedeal: Boolean + get() = stock.isEmpty() && + waste.isNotEmpty() && + (options.redealLimit == null || redealsUsed < options.redealLimit) +} + +@Serializable +sealed interface KlondikeMove + +/** Turns the next card or three off the stock and onto the waste. */ +@Serializable +@SerialName("draw") +data object Draw : KlondikeMove + +/** Turns the waste back over to make a new stock, once the stock is out. */ +@Serializable +@SerialName("redeal") +data object Redeal : KlondikeMove + +/** + * Moves [count] cards from one spot to another. + * + * [count] is only ever more than one when moving between tableau columns, where + * a whole descending run travels together. + */ +@Serializable +@SerialName("move") +data class MoveCards(val from: Spot, val to: Spot, val count: Int = 1) : KlondikeMove + +/** Foundations are indexed by suit, so a card always knows which one it wants. */ +fun foundationFor(card: Card): Int = card.suit.ordinal + +/** Ace first, then strictly upwards in the same suit. */ +fun acceptsOnFoundation(foundation: List, card: Card): Boolean { + val top = foundation.lastOrNull() + ?: return card.rank == Rank.ACE + return card.suit == top.suit && card.rank.order == top.rank.order + 1 +} + +/** Down by one, and in the other colour. */ +fun buildsDown(lower: Card, onto: Card): Boolean = + lower.rank.order == onto.rank.order - 1 && lower.suit.isRed != onto.suit.isRed + +/** + * Whether [cards] is a run that can travel as one: each card a step below the + * one before it and in the opposite colour. + */ +fun isMovableRun(cards: List): Boolean = + cards.isNotEmpty() && cards.zipWithNext().all { (upper, lower) -> buildsDown(lower, upper) } + +/** What an empty column will take. */ +fun acceptsInSpace(card: Card, kingsOnly: Boolean): Boolean = + !kingsOnly || card.rank == Rank.KING + +/** Suit order is the foundation order, so this names them for the screen. */ +fun foundationSuit(index: Int): Suit = Suit.entries[index] diff --git a/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeRules.kt b/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeRules.kt new file mode 100644 index 0000000..ce1c175 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeRules.kt @@ -0,0 +1,249 @@ +package org.prolibertate.games.game.klondike + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.prolibertate.games.game.GameCatalog +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.cards.Decks +import org.prolibertate.games.game.cards.shuffledWith +import org.prolibertate.games.game.engine.GameRules +import org.prolibertate.games.game.engine.TableConfig +import kotlin.random.Random + +/** + * Klondike as a pure state machine. See RULES-klondike.md. + * + * One seat, like peg solitaire: the player is not playing against anybody, only + * against the deal. + */ +object KlondikeRules : GameRules { + + override val gameId: String = GameCatalog.KLONDIKE + + const val SOLO_SEAT: Int = 0 + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + override fun initialState(config: TableConfig): KlondikeState { + val options = json.decodeFromString(config.optionsJson) + require(config.seats.size == 1) { "Klondike seats one, not ${config.seats.size}" } + return deal(options, config.seed) + } + + private fun deal(options: KlondikeOptions, seed: Long): KlondikeState { + val deck = Decks.standard52().shuffledWith(Random(seed)).toMutableList() + val piles = (0 until TABLEAU_PILES).map { index -> + // Column n gets n+1 cards, and only the last of them is turned up. + val taken = List(index + 1) { deck.removeAt(deck.size - 1) } + TableauPile(faceDown = taken.dropLast(1), faceUp = listOf(taken.last())) + } + return KlondikeState( + options = options, + seed = seed, + stock = deck.toList(), + waste = emptyList(), + foundations = List(FOUNDATIONS) { emptyList() }, + tableau = piles, + redealsUsed = 0, + moves = 0, + log = listOf("Dealt. Drawing ${options.drawCount} at a time."), + ) + } + + override fun currentSeat(state: KlondikeState): Int? = + if (isFinished(state)) null else SOLO_SEAT + + override fun legalMoves(state: KlondikeState, seat: Int): List { + if (seat != SOLO_SEAT || state.isWon) return emptyList() + val moves = mutableListOf() + if (state.stock.isNotEmpty()) moves += Draw + if (state.canRedeal) moves += Redeal + + state.waste.lastOrNull()?.let { card -> + val foundation = foundationFor(card) + if (acceptsOnFoundation(state.foundations[foundation], card)) { + moves += MoveCards(Spot.waste, Spot.foundation(foundation)) + } + state.tableau.indices.forEach { pile -> + if (tableauAccepts(state, pile, card)) { + moves += MoveCards(Spot.waste, Spot.tableau(pile)) + } + } + } + + state.tableau.forEachIndexed { index, pile -> + pile.top?.let { card -> + val foundation = foundationFor(card) + if (acceptsOnFoundation(state.foundations[foundation], card)) { + moves += MoveCards(Spot.tableau(index), Spot.foundation(foundation)) + } + } + for (count in 1..pile.faceUp.size) { + val run = pile.faceUp.takeLast(count) + if (!isMovableRun(run)) continue + val head = run.first() + state.tableau.indices.forEach { target -> + if (target == index || !tableauAccepts(state, target, head)) return@forEach + // Shifting a whole column into an empty one gains nothing and + // would let a game shuffle back and forth for ever, so it is + // not offered as a move at all. + val wholeColumn = pile.faceDown.isEmpty() && count == pile.faceUp.size + if (wholeColumn && state.tableau[target].isEmpty) return@forEach + moves += MoveCards(Spot.tableau(index), Spot.tableau(target), count) + } + } + } + + state.foundations.forEachIndexed { index, foundation -> + foundation.lastOrNull()?.let { card -> + state.tableau.indices.forEach { pile -> + if (tableauAccepts(state, pile, card)) { + moves += MoveCards(Spot.foundation(index), Spot.tableau(pile)) + } + } + } + } + return moves + } + + /** Whether column [index] will take [card] on top of it. */ + fun tableauAccepts(state: KlondikeState, index: Int, card: Card): Boolean { + val pile = state.tableau[index] + val top = pile.top ?: return acceptsInSpace(card, state.options.kingsOnlyInSpaces) + return buildsDown(card, top) + } + + override fun applyMove(state: KlondikeState, seat: Int, move: KlondikeMove): KlondikeState { + require(seat == SOLO_SEAT) { "Klondike has one seat" } + require(legalMoves(state, seat).contains(move)) { "$move is not legal here" } + val next = when (move) { + Draw -> draw(state) + Redeal -> redeal(state) + is MoveCards -> moveCards(state, move) + } + val won = next.isWon + return next.copy( + moves = state.moves + 1, + log = if (won) next.log + "Out, in ${next.moves + 1} moves." else next.log, + ) + } + + private fun draw(state: KlondikeState): KlondikeState { + val count = minOf(state.options.drawCount, state.stock.size) + val taken = state.stock.takeLast(count) + return state.copy( + stock = state.stock.dropLast(count), + // Turning a packet over reverses it, so the card that was deepest of + // the three is the one left showing. + waste = state.waste + taken.reversed(), + ) + } + + private fun redeal(state: KlondikeState): KlondikeState = state.copy( + // The waste is turned face down as one, so its top card ends up at the + // bottom of the new stock. + stock = state.waste.reversed(), + waste = emptyList(), + redealsUsed = state.redealsUsed + 1, + log = state.log + "Waste turned back over.", + ) + + private fun moveCards(state: KlondikeState, move: MoveCards): KlondikeState { + val cards = cardsAt(state, move.from, move.count) + val without = removeFrom(state, move.from, move.count) + return addTo(without, move.to, cards) + } + + private fun cardsAt(state: KlondikeState, from: Spot, count: Int): List = when (from.kind) { + SpotKind.WASTE -> state.waste.takeLast(count) + SpotKind.TABLEAU -> state.tableau[from.index].faceUp.takeLast(count) + SpotKind.FOUNDATION -> state.foundations[from.index].takeLast(count) + SpotKind.STOCK -> throw IllegalArgumentException("Cards are not taken from the stock by hand") + } + + private fun removeFrom(state: KlondikeState, from: Spot, count: Int): KlondikeState = + when (from.kind) { + SpotKind.WASTE -> state.copy(waste = state.waste.dropLast(count)) + + SpotKind.TABLEAU -> { + val pile = state.tableau[from.index] + val left = pile.faceUp.dropLast(count) + // Emptying the turned-up run exposes whatever was under it, and + // turning that card is the point of most of the game. + val flipped = if (left.isEmpty() && pile.faceDown.isNotEmpty()) { + TableauPile( + faceDown = pile.faceDown.dropLast(1), + faceUp = listOf(pile.faceDown.last()), + ) + } else { + pile.copy(faceUp = left) + } + state.copy(tableau = state.tableau.replacing(from.index, flipped)) + } + + SpotKind.FOUNDATION -> state.copy( + foundations = state.foundations.replacing( + from.index, + state.foundations[from.index].dropLast(count), + ), + ) + + SpotKind.STOCK -> throw IllegalArgumentException("Nothing is taken off the stock by hand") + } + + private fun addTo(state: KlondikeState, to: Spot, cards: List): KlondikeState = + when (to.kind) { + SpotKind.TABLEAU -> { + val pile = state.tableau[to.index] + state.copy( + tableau = state.tableau.replacing( + to.index, + pile.copy(faceUp = pile.faceUp + cards), + ), + ) + } + + SpotKind.FOUNDATION -> state.copy( + foundations = state.foundations.replacing( + to.index, + state.foundations[to.index] + cards, + ), + ) + + SpotKind.WASTE, SpotKind.STOCK -> + throw IllegalArgumentException("Nothing is put back on the ${to.kind}") + } + + private fun List.replacing(index: Int, value: T): List = + toMutableList().also { it[index] = value } + + /** + * Won, or with nothing left to try. + * + * A game can dry up entirely: no stock, no redeal left, and not a card that + * will go anywhere. That is a finish as much as winning is, and the screen + * needs to be able to say so rather than leaving the player hunting. + */ + override fun isFinished(state: KlondikeState): Boolean = + state.isWon || legalMoves(state, SOLO_SEAT).isEmpty() + + override fun summary(state: KlondikeState): String = when { + state.isWon -> "Out in ${state.moves} moves" + isFinished(state) -> "Blocked, ${state.foundations.sumOf { it.size }} cards home" + else -> "${state.foundations.sumOf { it.size }} of 52 home" + } + + /** One seat, and it is the player's, so there is nothing to strip. */ + override fun viewFor(state: KlondikeState, seat: Int): KlondikeState = state + + override fun encodeState(state: KlondikeState): String = json.encodeToString(state) + override fun decodeState(json: String): KlondikeState = this.json.decodeFromString(json) + override fun encodeMove(move: KlondikeMove): String = json.encodeToString(move) + override fun decodeMove(json: String): KlondikeMove = this.json.decodeFromString(json) + + /** Deals the same pack again, so a lost game can be replayed from the start. */ + fun restart(state: KlondikeState): KlondikeState = deal(state.options, state.seed) +} diff --git a/app/src/test/java/org/prolibertate/games/game/klondike/KlondikeRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/klondike/KlondikeRulesTest.kt new file mode 100644 index 0000000..e9ada33 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/klondike/KlondikeRulesTest.kt @@ -0,0 +1,308 @@ +package org.prolibertate.games.game.klondike + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.cards.Rank +import org.prolibertate.games.game.cards.Suit +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig + +class KlondikeRulesTest { + + private val json = Json { encodeDefaults = true } + + private fun config(options: KlondikeOptions = KlondikeOptions(), seed: Long = 5L) = TableConfig( + gameId = "klondike", + seats = listOf(PlayerSlot(seat = 0, name = "P0", kind = PlayerKind.HUMAN_LOCAL, team = 0)), + optionsJson = json.encodeToString(options), + seed = seed, + ) + + private fun c(rank: Rank, suit: Suit) = Card(rank, suit) + + // ---- the deal ---------------------------------------------------------- + + @Test + fun `the deal is seven columns and a stock of twenty-four`() { + val state = KlondikeRules.initialState(config()) + assertEquals(TABLEAU_PILES, state.tableau.size) + state.tableau.forEachIndexed { index, pile -> + assertEquals("column ${index + 1}", index + 1, pile.faceDown.size + pile.faceUp.size) + assertEquals("only the last card is turned up", 1, pile.faceUp.size) + } + assertEquals(24, state.stock.size) + assertTrue(state.waste.isEmpty()) + assertTrue(state.foundations.all { it.isEmpty() }) + } + + @Test + fun `the pack is dealt whole and no card is dealt twice`() { + val state = KlondikeRules.initialState(config()) + val all = state.stock + state.waste + state.tableau.flatMap { it.faceDown + it.faceUp } + assertEquals(52, all.size) + assertEquals(52, all.distinct().size) + } + + @Test + fun `a table for more than one seat is refused`() { + val two = TableConfig( + gameId = "klondike", + seats = (0..1).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) }, + optionsJson = json.encodeToString(KlondikeOptions()), + seed = 1L, + ) + assertThrows { KlondikeRules.initialState(two) } + } + + // ---- stock and waste --------------------------------------------------- + + @Test + fun `drawing one turns one card up`() { + val state = KlondikeRules.initialState(config(KlondikeOptions(drawCount = 1))) + val drawn = KlondikeRules.applyMove(state, 0, Draw) + assertEquals(23, drawn.stock.size) + assertEquals(1, drawn.waste.size) + assertEquals("the top of the stock is the card turned", state.stock.last(), drawn.waste.last()) + } + + @Test + fun `drawing three leaves the deepest of them showing`() { + // Turning a packet of three over reverses it, so the card that was + // furthest down is the one in play. + val state = KlondikeRules.initialState(config(KlondikeOptions(drawCount = 3))) + val drawn = KlondikeRules.applyMove(state, 0, Draw) + assertEquals(21, drawn.stock.size) + assertEquals(3, drawn.waste.size) + val top3 = state.stock.takeLast(3) + assertEquals(top3.reversed(), drawn.waste) + assertEquals(top3.first(), drawn.waste.last()) + } + + @Test + fun `the waste turns back over into a new stock`() { + var state = KlondikeRules.initialState(config(KlondikeOptions(drawCount = 3))) + repeat(8) { state = KlondikeRules.applyMove(state, 0, Draw) } + assertTrue(state.stock.isEmpty()) + assertEquals(24, state.waste.size) + assertTrue(state.canRedeal) + + val wasteBefore = state.waste + val turned = KlondikeRules.applyMove(state, 0, Redeal) + assertEquals(wasteBefore.reversed(), turned.stock) + assertTrue(turned.waste.isEmpty()) + assertEquals(1, turned.redealsUsed) + } + + @Test + fun `a redeal limit of zero means one pass through the pack`() { + var state = KlondikeRules.initialState( + config(KlondikeOptions(drawCount = 3, redealLimit = 0)), + ) + repeat(8) { state = KlondikeRules.applyMove(state, 0, Draw) } + assertFalse("no turning it back over", state.canRedeal) + assertFalse(KlondikeRules.legalMoves(state, 0).contains(Redeal)) + } + + // ---- building ---------------------------------------------------------- + + @Test + fun `foundations run ace upwards in one suit`() { + assertTrue(acceptsOnFoundation(emptyList(), c(Rank.ACE, Suit.SPADES))) + assertFalse(acceptsOnFoundation(emptyList(), c(Rank.TWO, Suit.SPADES))) + + val ace = listOf(c(Rank.ACE, Suit.SPADES)) + assertTrue(acceptsOnFoundation(ace, c(Rank.TWO, Suit.SPADES))) + assertFalse("wrong suit", acceptsOnFoundation(ace, c(Rank.TWO, Suit.HEARTS))) + assertFalse("skips a rank", acceptsOnFoundation(ace, c(Rank.THREE, Suit.SPADES))) + } + + @Test + fun `columns build down and in the other colour`() { + assertTrue(buildsDown(c(Rank.NINE, Suit.HEARTS), c(Rank.TEN, Suit.SPADES))) + assertTrue(buildsDown(c(Rank.NINE, Suit.DIAMONDS), c(Rank.TEN, Suit.CLUBS))) + assertFalse("same colour", buildsDown(c(Rank.NINE, Suit.HEARTS), c(Rank.TEN, Suit.DIAMONDS))) + assertFalse("wrong way up", buildsDown(c(Rank.JACK, Suit.HEARTS), c(Rank.TEN, Suit.SPADES))) + } + + @Test + fun `a run travels only if it is a run`() { + assertTrue( + isMovableRun( + listOf( + c(Rank.TEN, Suit.SPADES), + c(Rank.NINE, Suit.HEARTS), + c(Rank.EIGHT, Suit.CLUBS), + ), + ), + ) + assertFalse( + "colours repeat", + isMovableRun(listOf(c(Rank.TEN, Suit.SPADES), c(Rank.NINE, Suit.CLUBS))), + ) + assertTrue("one card is a run", isMovableRun(listOf(c(Rank.TEN, Suit.SPADES)))) + assertFalse(isMovableRun(emptyList())) + } + + @Test + fun `an empty column takes a king, or anything if the table says so`() { + assertTrue(acceptsInSpace(c(Rank.KING, Suit.SPADES), kingsOnly = true)) + assertFalse(acceptsInSpace(c(Rank.QUEEN, Suit.SPADES), kingsOnly = true)) + assertTrue(acceptsInSpace(c(Rank.TWO, Suit.SPADES), kingsOnly = false)) + } + + // ---- moving ------------------------------------------------------------ + + @Test + fun `emptying a column turns the card underneath up`() { + val base = KlondikeRules.initialState(config()) + // Column 1 holds one buried card and a red six; column 2's black seven + // will take it. + val state = base.copy( + tableau = base.tableau.toMutableList().also { + it[1] = TableauPile( + faceDown = listOf(c(Rank.ACE, Suit.CLUBS)), + faceUp = listOf(c(Rank.SIX, Suit.HEARTS)), + ) + it[2] = TableauPile(faceUp = listOf(c(Rank.SEVEN, Suit.SPADES))) + }, + ) + val move = MoveCards(Spot.tableau(1), Spot.tableau(2)) + assertTrue(KlondikeRules.legalMoves(state, 0).contains(move)) + + val after = KlondikeRules.applyMove(state, 0, move) + assertEquals(listOf(c(Rank.ACE, Suit.CLUBS)), after.tableau[1].faceUp) + assertTrue("nothing left buried", after.tableau[1].faceDown.isEmpty()) + assertEquals( + listOf(c(Rank.SEVEN, Suit.SPADES), c(Rank.SIX, Suit.HEARTS)), + after.tableau[2].faceUp, + ) + } + + @Test + fun `shifting a whole column into an empty one is not offered`() { + // It gains nothing and would let a game shuffle back and forth for ever. + val base = KlondikeRules.initialState(config()) + val state = base.copy( + tableau = base.tableau.toMutableList().also { + it[0] = TableauPile(faceUp = listOf(c(Rank.KING, Suit.SPADES))) + it[1] = TableauPile() + }, + ) + assertFalse( + KlondikeRules.legalMoves(state, 0) + .contains(MoveCards(Spot.tableau(0), Spot.tableau(1))), + ) + } + + @Test + fun `an illegal move is refused rather than applied`() { + val state = KlondikeRules.initialState(config()) + // Nothing is ever taken off the stock by hand, and an empty foundation + // has nothing to give — neither depends on how the pack fell. + assertThrows { + KlondikeRules.applyMove(state, 0, MoveCards(Spot.stock, Spot.tableau(0))) + } + assertThrows { + KlondikeRules.applyMove(state, 0, MoveCards(Spot.foundation(0), Spot.tableau(0))) + } + assertThrows { KlondikeRules.applyMove(state, 1, Draw) } + } + + // ---- finishing --------------------------------------------------------- + + @Test + fun `four full foundations is out`() { + val base = KlondikeRules.initialState(config()) + val full = Suit.entries.map { suit -> Rank.standard.map { Card(it, suit) } } + val won = base.copy(foundations = full, tableau = List(TABLEAU_PILES) { TableauPile() }) + assertTrue(won.isWon) + assertTrue(KlondikeRules.isFinished(won)) + assertNull(KlondikeRules.currentSeat(won)) + assertTrue(KlondikeRules.summary(won).contains("Out")) + } + + @Test + fun `a deal with nothing left to try is finished too`() { + val base = KlondikeRules.initialState(config()) + // No stock, no waste, and two columns that cannot touch each other. + val stuck = base.copy( + stock = emptyList(), + waste = emptyList(), + tableau = List(TABLEAU_PILES) { index -> + when (index) { + 0 -> TableauPile(faceUp = listOf(c(Rank.FIVE, Suit.SPADES))) + 1 -> TableauPile(faceUp = listOf(c(Rank.NINE, Suit.CLUBS))) + else -> TableauPile(faceUp = listOf(c(Rank.SEVEN, Suit.CLUBS))) + } + }, + ) + assertFalse(stuck.isWon) + assertTrue(KlondikeRules.isFinished(stuck)) + assertTrue(KlondikeRules.summary(stuck).contains("Blocked")) + } + + @Test + fun `a deal can be played a long way without the rules complaining`() { + var state = KlondikeRules.initialState(config()) + var guard = 400 + while (!KlondikeRules.isFinished(state) && guard-- > 0) { + val legal = KlondikeRules.legalMoves(state, 0) + state = KlondikeRules.applyMove(state, 0, KlondikeAi.chooseMove(state, 0, legal)) + // Every card is somewhere, and only ever in one place. + val all = state.stock + state.waste + + state.foundations.flatten() + + state.tableau.flatMap { it.faceDown + it.faceUp } + assertEquals("no card lost or copied", 52, all.size) + assertEquals(52, all.distinct().size) + } + } + + // ---- housekeeping ------------------------------------------------------ + + @Test + fun `restart deals the same pack again`() { + val state = KlondikeRules.initialState(config()) + val played = KlondikeRules.applyMove(state, 0, Draw) + val again = KlondikeRules.restart(played) + assertEquals(state.tableau, again.tableau) + assertEquals(state.stock, again.stock) + assertEquals(0, again.moves) + } + + @Test + fun `nothing is hidden from the only player`() { + val state = KlondikeRules.initialState(config()) + assertEquals(state, KlondikeRules.viewFor(state, 0)) + } + + @Test + fun `state and moves survive a round trip`() { + val state = KlondikeRules.applyMove(KlondikeRules.initialState(config()), 0, Draw) + assertEquals(state, KlondikeRules.decodeState(KlondikeRules.encodeState(state))) + + listOf( + Draw, + Redeal, + MoveCards(Spot.waste, Spot.foundation(2)), + MoveCards(Spot.tableau(3), Spot.tableau(5), 4), + ).forEach { + assertEquals(it, KlondikeRules.decodeMove(KlondikeRules.encodeMove(it))) + } + } + + private fun assertThrows(block: () -> Unit) { + try { + block() + throw AssertionError("expected the rules to reject that") + } catch (expected: IllegalArgumentException) { + // Rejected, which is the point. + } + } +}