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
@@ -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.
}
}
}