Klondike, the first of the card solitaires

Seven columns, four foundations and a stock, played by one person against the
deal. Three axes of variation, which is what most people mean by house rules
for patience: one card off the stock or three, how often the waste may be
turned back over, and whether an empty column will take anything or only a
king. A single pass through the pack is redealLimit = 0.

A spot is a kind and an index rather than a class per kind. Every place a card
can be in this game is one of four things identified by at most one number, so
a pair says everything a hierarchy would and crosses the wire as two plain
fields.

A tableau pile keeps its face-down and face-up parts separate, because that
split is the game: a column is only worth what has been turned up, and what is
buried is the thing being played for. Stripping the turned-up run off a column
turns the next card, which is where most of a deal is won.

Shifting a whole column into an empty one is not offered as a move at all. It
achieves nothing and would let a game walk back and forth for ever -- which
matters here more than usual, because isFinished is "won, or nothing left to
try", and a game that can always shuffle pointlessly is never blocked.

Turning a packet over reverses it, so drawing three leaves the deepest of the
three showing, and a redeal puts the top of the waste at the bottom of the new
stock. Both are tested, because getting either backwards is invisible until
somebody counts the cards.

The AI orders its preferences rather than scoring finely: home always, then
turning a card up, then emptying a column, and aimless shuffling between
columns below drawing, so it is only reached when there is nothing else. That
ordering is what keeps it from walking a run between two columns for ever.

Not in GameCatalog yet -- id now, entry with the screen.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC
This commit is contained in:
Claude
2026-08-08 15:23:47 +00:00
parent 68f8da0379
commit 2b09bcdde8
5 changed files with 754 additions and 0 deletions
@@ -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"
@@ -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<KlondikeState, KlondikeMove> {
override fun chooseMove(state: KlondikeState, seat: Int, legal: List<KlondikeMove>): 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()
}
}
@@ -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<Card> = emptyList(),
val faceUp: List<Card> = 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<Card>,
/** Face up; the last card is the one in play. */
val waste: List<Card>,
/** One per suit, in [Suit] order, each running ace upwards. */
val foundations: List<List<Card>>,
val tableau: List<TableauPile>,
val redealsUsed: Int,
val moves: Int,
val log: List<String>,
) {
/** 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: 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<Card>): 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]
@@ -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<KlondikeState, KlondikeMove> {
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<KlondikeOptions>(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<KlondikeMove> {
if (seat != SOLO_SEAT || state.isWon) return emptyList()
val moves = mutableListOf<KlondikeMove>()
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<Card> = 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<Card>): 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 <T> List<T>.replacing(index: Int, value: T): List<T> =
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)
}
@@ -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<KlondikeMove>(
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.
}
}
}