FreeCell, Spider and Pyramid, as rules

Three more patiences, and they are three different games rather than three
settings of one. FreeCell builds down in alternating colours with everything
face up; Spider builds down in rank but only moves runs of one suit; Pyramid
builds nothing at all and takes cards away in pairs that make thirteen.

FreeCell's supermove is arithmetic rather than a rule. A run is really moved one
card at a time through the cells and the spaces, so what can travel is what
those could have held: one per free cell plus the card itself, doubled for every
empty column usable as a staging pile. Moving into an empty column cannot also
stage through it, which is the fiddly case and is tested on its own.

Spider separates landing from travelling, which is the whole difficulty of the
four-suit game: a card lands on anything a rank higher, but only a same-suit run
moves as a unit. The pack is a hundred and four cards whatever the suit count --
eight copies of one suit, or two of each of four -- so the deal keeps its shape.

Pyramid blanks the places it takes cards from rather than compacting them. A
place is what tells the two cards above it that they are free, so an index that
shifted would uncover the wrong cards.

All three read the ace-low ordering from game.solitaire rather than defining it,
which is the point of having put it there.

The AI is deliberately dim -- it takes the first move on offer. A greedy
patience player that looked clever would be worse, because it would be trusted
as a hint, and a hint that walks a run back and forth is more annoying than no
hint at all. Klondike has a real one because its move ordering is obvious;
these three do not, and pretending otherwise would be the wrong kind of helpful.

Ids only for now. Catalogue entries land with the screens.

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 16:05:03 +00:00
parent 7569f0b891
commit 40ea68f4c0
9 changed files with 1184 additions and 0 deletions
@@ -40,6 +40,9 @@ object GameCatalog {
const val YAHTZEE = "yahtzee"
const val KLONDIKE = "klondike"
const val FREECELL = "freecell"
const val SPIDER = "spider"
const val PYRAMID = "pyramid"
const val SEQUENCE = "sequence"
const val MASTERMIND = "mastermind"
const val BACKGAMMON = "backgammon"
@@ -0,0 +1,81 @@
package org.prolibertate.games.game.freecell
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.prolibertate.games.game.cards.Card
const val COLUMNS: Int = 8
const val FOUNDATIONS: Int = 4
@Serializable
enum class CellKind { CELL, TABLEAU, FOUNDATION }
@Serializable
data class Place(val kind: CellKind, val index: Int) {
companion object {
fun cell(index: Int) = Place(CellKind.CELL, index)
fun column(index: Int) = Place(CellKind.TABLEAU, index)
fun foundation(index: Int) = Place(CellKind.FOUNDATION, index)
}
}
@Serializable
data class FreeCellOptions(
/**
* Four cells is the game as everyone knows it. Fewer is the same game made
* hard, since the cells are the only place a card can wait.
*/
val freeCells: Int = 4,
/**
* Move a run in one gesture rather than a card at a time. The rules do not
* actually allow it — a run is moved by shuffling cards through the cells —
* but every computer version does it for you, because doing it by hand is
* bookkeeping rather than a decision.
*/
val allowSupermoves: Boolean = true,
) {
init {
require(freeCells in 1..6) { "One to six cells" }
}
}
@Serializable
data class FreeCellState(
val options: FreeCellOptions,
val seed: Long,
/** Null where a cell is empty. */
val cells: List<Card?>,
val foundations: List<List<Card>>,
/** Every card is face up in FreeCell; there is nothing buried to turn. */
val tableau: List<List<Card>>,
val moves: Int,
val log: List<String>,
) {
val freeCellCount: Int get() = cells.count { it == null }
val emptyColumns: Int get() = tableau.count { it.isEmpty() }
val isWon: Boolean get() = foundations.all { it.size == 13 }
}
@Serializable
sealed interface FreeCellMove
@Serializable
@SerialName("move")
data class MoveTo(val from: Place, val to: Place, val count: Int = 1) : FreeCellMove
/**
* How many cards can travel together.
*
* A run is really moved one card at a time through the free cells and the empty
* columns, so what can be shifted is what those could have held: one per free
* cell plus the card itself, doubled for every empty column that could be used
* as a staging pile. Moving *into* an empty column cannot also use it, which is
* the fiddly part and the reason [intoEmptyColumn] exists.
*/
fun maxRunLength(freeCells: Int, emptyColumns: Int, intoEmptyColumn: Boolean): Int {
val usable = if (intoEmptyColumn) emptyColumns - 1 else emptyColumns
if (usable < 0) return 1
var limit = freeCells + 1
repeat(usable) { limit *= 2 }
return limit
}
@@ -0,0 +1,193 @@
package org.prolibertate.games.game.freecell
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 org.prolibertate.games.game.solitaire.acceptsOnFoundation
import org.prolibertate.games.game.solitaire.buildsDownAlternating
import org.prolibertate.games.game.solitaire.isRun
import kotlin.random.Random
/**
* FreeCell as a pure state machine. See RULES-freecell.md.
*
* The whole pack is face up from the first move, so unlike Klondike there is
* nothing hidden and nothing to be lucky about — very nearly every deal can be
* won by someone who plays it well enough.
*/
object FreeCellRules : GameRules<FreeCellState, FreeCellMove> {
override val gameId: String = GameCatalog.FREECELL
const val SOLO_SEAT: Int = 0
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
override fun initialState(config: TableConfig): FreeCellState {
val options = json.decodeFromString<FreeCellOptions>(config.optionsJson)
require(config.seats.size == 1) { "FreeCell seats one, not ${config.seats.size}" }
return deal(options, config.seed)
}
private fun deal(options: FreeCellOptions, seed: Long): FreeCellState {
val deck = Decks.standard52().shuffledWith(Random(seed))
// Fifty-two into eight leaves four columns of seven and four of six.
val columns = mutableListOf<List<Card>>()
var at = 0
repeat(COLUMNS) { index ->
val size = if (index < 4) 7 else 6
columns += deck.subList(at, at + size).toList()
at += size
}
return FreeCellState(
options = options,
seed = seed,
cells = List(options.freeCells) { null },
foundations = List(FOUNDATIONS) { emptyList() },
tableau = columns,
moves = 0,
log = listOf("Dealt, with ${options.freeCells} cells."),
)
}
override fun currentSeat(state: FreeCellState): Int? =
if (isFinished(state)) null else SOLO_SEAT
/** The longest run sitting on the end of a column, by the build rule. */
fun movableTail(column: List<Card>): List<Card> {
if (column.isEmpty()) return emptyList()
var take = 1
while (take < column.size) {
val candidate = column.takeLast(take + 1)
if (!isRun(candidate, ::buildsDownAlternating)) break
take++
}
return column.takeLast(take)
}
override fun legalMoves(state: FreeCellState, seat: Int): List<FreeCellMove> {
if (seat != SOLO_SEAT || state.isWon) return emptyList()
val moves = mutableListOf<FreeCellMove>()
fun offer(from: Place, cards: List<Card>) {
val head = cards.first()
if (cards.size == 1) {
val foundation = head.suit.ordinal
if (acceptsOnFoundation(state.foundations[foundation], head)) {
moves += MoveTo(from, Place.foundation(foundation), 1)
}
state.cells.indices.forEach { cell ->
if (state.cells[cell] == null && from.kind != CellKind.CELL) {
moves += MoveTo(from, Place.cell(cell), 1)
}
}
}
state.tableau.indices.forEach { column ->
if (from.kind == CellKind.TABLEAU && from.index == column) return@forEach
val target = state.tableau[column]
val fits = target.lastOrNull()?.let { buildsDownAlternating(head, it) } ?: true
if (!fits) return@forEach
// Moving a whole column into an empty one is a no-op that would
// let a game shuffle for ever.
if (target.isEmpty() && from.kind == CellKind.TABLEAU &&
cards.size == state.tableau[from.index].size
) {
return@forEach
}
val allowed = maxRunLength(
freeCells = state.freeCellCount,
emptyColumns = state.emptyColumns,
intoEmptyColumn = target.isEmpty(),
).let { if (state.options.allowSupermoves) it else 1 }
if (cards.size <= allowed) moves += MoveTo(from, Place.column(column), cards.size)
}
}
state.cells.forEachIndexed { index, card ->
card?.let { offer(Place.cell(index), listOf(it)) }
}
state.tableau.forEachIndexed { index, column ->
val tail = movableTail(column)
// Every length of the tail run can travel, not just the whole of it.
for (count in 1..tail.size) {
offer(Place.column(index), tail.takeLast(count))
}
}
return moves
}
override fun applyMove(state: FreeCellState, seat: Int, move: FreeCellMove): FreeCellState {
require(seat == SOLO_SEAT) { "FreeCell has one seat" }
require(legalMoves(state, seat).contains(move)) { "$move is not legal here" }
move as MoveTo
val cards = when (move.from.kind) {
CellKind.CELL -> listOfNotNull(state.cells[move.from.index])
CellKind.TABLEAU -> state.tableau[move.from.index].takeLast(move.count)
CellKind.FOUNDATION -> state.foundations[move.from.index].takeLast(move.count)
}
var next = when (move.from.kind) {
CellKind.CELL -> state.copy(cells = state.cells.replacing(move.from.index, null))
CellKind.TABLEAU -> state.copy(
tableau = state.tableau.replacing(
move.from.index,
state.tableau[move.from.index].dropLast(move.count),
),
)
CellKind.FOUNDATION -> state.copy(
foundations = state.foundations.replacing(
move.from.index,
state.foundations[move.from.index].dropLast(move.count),
),
)
}
next = when (move.to.kind) {
CellKind.CELL -> next.copy(cells = next.cells.replacing(move.to.index, cards.single()))
CellKind.TABLEAU -> next.copy(
tableau = next.tableau.replacing(
move.to.index,
next.tableau[move.to.index] + cards,
),
)
CellKind.FOUNDATION -> next.copy(
foundations = next.foundations.replacing(
move.to.index,
next.foundations[move.to.index] + cards,
),
)
}
return next.copy(
moves = state.moves + 1,
log = if (next.isWon) next.log + "Out, in ${state.moves + 1} moves." else next.log,
)
}
private fun <T> List<T>.replacing(index: Int, value: T): List<T> =
toMutableList().also { it[index] = value }
override fun isFinished(state: FreeCellState): Boolean =
state.isWon || legalMoves(state, SOLO_SEAT).isEmpty()
override fun summary(state: FreeCellState): 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"
}
override fun viewFor(state: FreeCellState, seat: Int): FreeCellState = state
override fun encodeState(state: FreeCellState): String = json.encodeToString(state)
override fun decodeState(json: String): FreeCellState = this.json.decodeFromString(json)
override fun encodeMove(move: FreeCellMove): String = json.encodeToString(move)
override fun decodeMove(json: String): FreeCellMove = this.json.decodeFromString(json)
fun restart(state: FreeCellState): FreeCellState = deal(state.options, state.seed)
}
@@ -0,0 +1,98 @@
package org.prolibertate.games.game.pyramid
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.solitaire.patienceOrder
/** Seven rows of one to seven cards. */
const val ROWS: Int = 7
const val PYRAMID_SIZE: Int = ROWS * (ROWS + 1) / 2
/** A pair has to make this, and a king makes it alone. */
const val TARGET: Int = 13
@Serializable
enum class PyramidZone { PYRAMID, WASTE }
@Serializable
data class PyramidSpot(val zone: PyramidZone, val index: Int = 0)
@Serializable
data class PyramidOptions(
/** How many times the waste may be turned back over. */
val redeals: Int = 2,
) {
init {
require(redeals >= 0) { "redeals cannot be negative" }
}
}
@Serializable
data class PyramidState(
val options: PyramidOptions,
val seed: Long,
/** Twenty-eight places; null where the card has been taken. */
val pyramid: List<Card?>,
val stock: List<Card>,
val waste: List<Card>,
val redealsUsed: Int,
val moves: Int,
val log: List<String>,
) {
val isWon: Boolean get() = pyramid.all { it == null }
val canRedeal: Boolean
get() = stock.isEmpty() && waste.isNotEmpty() && redealsUsed < options.redeals
}
@Serializable
sealed interface PyramidMove
@Serializable
@SerialName("draw")
data object DrawCard : PyramidMove
@Serializable
@SerialName("redeal")
data object RecycleWaste : PyramidMove
/** A king is worth thirteen on its own, so it needs no partner. */
@Serializable
@SerialName("king")
data class TakeKing(val spot: PyramidSpot) : PyramidMove
@Serializable
@SerialName("pair")
data class TakePair(val first: PyramidSpot, val second: PyramidSpot) : PyramidMove
/** Ace one through king thirteen, which is what the pairing is counted in. */
fun valueOf(card: Card): Int = card.rank.patienceOrder
/** Where a row starts in the flattened pyramid. */
fun rowStart(row: Int): Int = row * (row + 1) / 2
/** Which row a place is in. */
fun rowOf(index: Int): Int {
var row = 0
while (rowStart(row + 1) <= index) row++
return row
}
/**
* The two places resting on [index], if any.
*
* A card is only in play once both of them are gone, which is the whole shape of
* the game: the pyramid has to be taken apart from the bottom.
*/
fun coveredBy(index: Int): List<Int> {
val row = rowOf(index)
if (row == ROWS - 1) return emptyList()
val offset = index - rowStart(row)
val below = rowStart(row + 1) + offset
return listOf(below, below + 1)
}
/** Whether the card at [index] has nothing left sitting on it. */
fun isExposed(pyramid: List<Card?>, index: Int): Boolean =
pyramid[index] != null && coveredBy(index).all { pyramid[it] == null }
@@ -0,0 +1,150 @@
package org.prolibertate.games.game.pyramid
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
/**
* Pyramid as a pure state machine. See RULES-pyramid.md.
*
* The odd one out among the patiences here: nothing is built and nothing goes
* home in order. Cards are taken away in pairs that make thirteen, and the only
* structure is which of them are still buried.
*/
object PyramidRules : GameRules<PyramidState, PyramidMove> {
override val gameId: String = GameCatalog.PYRAMID
const val SOLO_SEAT: Int = 0
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
override fun initialState(config: TableConfig): PyramidState {
val options = json.decodeFromString<PyramidOptions>(config.optionsJson)
require(config.seats.size == 1) { "Pyramid seats one, not ${config.seats.size}" }
return deal(options, config.seed)
}
private fun deal(options: PyramidOptions, seed: Long): PyramidState {
val deck = Decks.standard52().shuffledWith(Random(seed))
return PyramidState(
options = options,
seed = seed,
pyramid = deck.take(PYRAMID_SIZE),
stock = deck.drop(PYRAMID_SIZE),
waste = emptyList(),
redealsUsed = 0,
moves = 0,
log = listOf("Dealt."),
)
}
override fun currentSeat(state: PyramidState): Int? =
if (isFinished(state)) null else SOLO_SEAT
/** Every card that can be reached: the exposed pyramid, and the waste's top. */
fun available(state: PyramidState): List<Pair<PyramidSpot, Card>> {
val fromPyramid = state.pyramid.indices
.filter { isExposed(state.pyramid, it) }
.map { PyramidSpot(PyramidZone.PYRAMID, it) to state.pyramid[it]!! }
val fromWaste = state.waste.lastOrNull()
?.let { listOf(PyramidSpot(PyramidZone.WASTE) to it) }
.orEmpty()
return fromPyramid + fromWaste
}
override fun legalMoves(state: PyramidState, seat: Int): List<PyramidMove> {
if (seat != SOLO_SEAT || state.isWon) return emptyList()
val moves = mutableListOf<PyramidMove>()
if (state.stock.isNotEmpty()) moves += DrawCard
if (state.canRedeal) moves += RecycleWaste
val reachable = available(state)
reachable.forEach { (spot, card) ->
if (valueOf(card) == TARGET) moves += TakeKing(spot)
}
// Each pair once: the second card is only ever looked for after the
// first, so a pair cannot be offered both ways round.
for (i in reachable.indices) {
for (j in i + 1 until reachable.size) {
val (spotA, cardA) = reachable[i]
val (spotB, cardB) = reachable[j]
if (valueOf(cardA) + valueOf(cardB) == TARGET) {
moves += TakePair(spotA, spotB)
}
}
}
return moves
}
override fun applyMove(state: PyramidState, seat: Int, move: PyramidMove): PyramidState {
require(seat == SOLO_SEAT) { "Pyramid has one seat" }
require(legalMoves(state, seat).contains(move)) { "$move is not legal here" }
val next = when (move) {
DrawCard -> state.copy(
stock = state.stock.dropLast(1),
waste = state.waste + state.stock.last(),
)
RecycleWaste -> state.copy(
stock = state.waste.reversed(),
waste = emptyList(),
redealsUsed = state.redealsUsed + 1,
log = state.log + "Waste turned back over.",
)
is TakeKing -> remove(state, listOf(move.spot))
is TakePair -> remove(state, listOf(move.first, move.second))
}
return next.copy(
moves = state.moves + 1,
log = if (next.isWon) next.log + "Pyramid cleared, in ${state.moves + 1} moves." else next.log,
)
}
/**
* Takes cards off the table.
*
* The pyramid places are blanked rather than compacted, because a place is
* what tells the two cards above it that they are free — an index that
* shifted would uncover the wrong cards.
*/
private fun remove(state: PyramidState, spots: List<PyramidSpot>): PyramidState {
val pyramid = state.pyramid.toMutableList()
var waste = state.waste
spots.forEach { spot ->
when (spot.zone) {
PyramidZone.PYRAMID -> pyramid[spot.index] = null
// Only ever the top card, so dropping the last is enough.
PyramidZone.WASTE -> waste = waste.dropLast(1)
}
}
return state.copy(pyramid = pyramid.toList(), waste = waste)
}
override fun isFinished(state: PyramidState): Boolean =
state.isWon || legalMoves(state, SOLO_SEAT).isEmpty()
override fun summary(state: PyramidState): String = when {
state.isWon -> "Cleared in ${state.moves} moves"
isFinished(state) -> "Blocked, ${state.pyramid.count { it != null }} cards left"
else -> "${state.pyramid.count { it != null }} of $PYRAMID_SIZE left"
}
override fun viewFor(state: PyramidState, seat: Int): PyramidState = state
override fun encodeState(state: PyramidState): String = json.encodeToString(state)
override fun decodeState(json: String): PyramidState = this.json.decodeFromString(json)
override fun encodeMove(move: PyramidMove): String = json.encodeToString(move)
override fun decodeMove(json: String): PyramidMove = this.json.decodeFromString(json)
fun restart(state: PyramidState): PyramidState = deal(state.options, state.seed)
}
@@ -0,0 +1,24 @@
package org.prolibertate.games.game.solitaire
import org.prolibertate.games.game.engine.GameAi
/**
* Takes the first move on offer.
*
* Every patience here has one seat and it belongs to the person playing, so
* nothing asks this during an ordinary game. [MatchController] needs an AI to
* construct, though, and a seat that could be left empty has to be playable by
* something.
*
* It is deliberately not clever. A greedy patience player that looked good would
* be worse than this: it would be trusted as a hint, and a hint that walks a run
* back and forth is more annoying than no hint at all. Klondike has a real one
* because its move ordering is obvious; these three do not, and pretending
* otherwise would be the wrong kind of helpful.
*/
class FirstLegalAi<S : Any, M : Any> : GameAi<S, M> {
override fun chooseMove(state: S, seat: Int, legal: List<M>): M {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
return legal.first()
}
}
@@ -0,0 +1,107 @@
package org.prolibertate.games.game.spider
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
import org.prolibertate.games.game.solitaire.patienceOrder
const val COLUMNS: Int = 10
/** Eight complete runs, king down to ace, is the whole pack cleared. */
const val RUNS_TO_WIN: Int = 8
const val RUN_LENGTH: Int = 13
@Serializable
data class SpiderOptions(
/**
* How many suits the two packs are made of. One suit is the game people
* actually finish; four is the real thing and is brutal. Two sits between
* them. The pack is always a hundred and four cards either way — only how
* many different suits those cards are drawn from changes.
*/
val suits: Int = 1,
) {
init {
require(suits == 1 || suits == 2 || suits == 4) { "Spider is played with one, two or four suits" }
}
}
@Serializable
data class SpiderPile(
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 SpiderState(
val options: SpiderOptions,
val seed: Long,
val stock: List<Card>,
val tableau: List<SpiderPile>,
/** Runs sent away complete. Eight of them wins. */
val completed: Int,
val moves: Int,
val log: List<String>,
) {
val isWon: Boolean get() = completed == RUNS_TO_WIN
/**
* A row cannot be dealt onto an empty column — the rule that stops a deal
* from being used to dodge a position with nowhere to put anything.
*/
val canDeal: Boolean get() = stock.isNotEmpty() && tableau.none { it.isEmpty }
}
@Serializable
sealed interface SpiderMove
/** One card face up onto every column at once. */
@Serializable
@SerialName("deal")
data object DealRow : SpiderMove
@Serializable
@SerialName("move")
data class MoveRun(val from: Int, val to: Int, val count: Int) : SpiderMove
/**
* Down by one in the same suit.
*
* Spider lets a card *land* on any suit a rank higher, but only a same-suit run
* may travel as a unit — which is the whole difficulty of the four-suit game.
*/
fun sameSuitRun(cards: List<Card>): Boolean =
cards.isNotEmpty() && cards.zipWithNext().all { (upper, lower) ->
lower.suit == upper.suit && lower.rank.patienceOrder == upper.rank.patienceOrder - 1
}
/** What a column will take on top: anything one rank higher, of any suit. */
fun landsOn(card: Card, onto: Card): Boolean =
card.rank.patienceOrder == onto.rank.patienceOrder - 1
/** King down to ace in one suit, which is a run ready to go away. */
fun isCompleteRun(cards: List<Card>): Boolean =
cards.size == RUN_LENGTH &&
cards.first().rank == Rank.KING &&
cards.last().rank == Rank.ACE &&
sameSuitRun(cards)
/**
* A hundred and four cards drawn from [suits] suits.
*
* One suit means eight identical spade packs; four means two of each ordinary
* suit. The count never changes, so the deal is the same shape whatever the
* table chose.
*/
fun spiderDeck(suits: Int): List<Card> {
val used = Suit.entries.take(suits)
val copies = 8 / suits
return used.flatMap { suit ->
List(copies) { Rank.standard.map { Card(it, suit) } }.flatten()
}
}
@@ -0,0 +1,177 @@
package org.prolibertate.games.game.spider
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.shuffledWith
import org.prolibertate.games.game.engine.GameRules
import org.prolibertate.games.game.engine.TableConfig
import kotlin.random.Random
/**
* Spider as a pure state machine. See RULES-spider.md.
*/
object SpiderRules : GameRules<SpiderState, SpiderMove> {
override val gameId: String = GameCatalog.SPIDER
const val SOLO_SEAT: Int = 0
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
override fun initialState(config: TableConfig): SpiderState {
val options = json.decodeFromString<SpiderOptions>(config.optionsJson)
require(config.seats.size == 1) { "Spider seats one, not ${config.seats.size}" }
return deal(options, config.seed)
}
private fun deal(options: SpiderOptions, seed: Long): SpiderState {
val deck = spiderDeck(options.suits).shuffledWith(Random(seed)).toMutableList()
// Fifty-four cards go out: six to the first four columns, five to the
// rest. The remaining fifty are the five rows still to come.
val piles = (0 until COLUMNS).map { index ->
val size = if (index < 4) 6 else 5
val taken = List(size) { deck.removeAt(deck.size - 1) }
SpiderPile(faceDown = taken.dropLast(1), faceUp = listOf(taken.last()))
}
return SpiderState(
options = options,
seed = seed,
stock = deck.toList(),
tableau = piles,
completed = 0,
moves = 0,
log = listOf("Dealt, ${options.suits} suit${if (options.suits == 1) "" else "s"}."),
)
}
override fun currentSeat(state: SpiderState): Int? =
if (isFinished(state)) null else SOLO_SEAT
/** The longest same-suit descending run on the end of a column. */
fun movableTail(pile: SpiderPile): List<Card> {
if (pile.faceUp.isEmpty()) return emptyList()
var take = 1
while (take < pile.faceUp.size) {
if (!sameSuitRun(pile.faceUp.takeLast(take + 1))) break
take++
}
return pile.faceUp.takeLast(take)
}
override fun legalMoves(state: SpiderState, seat: Int): List<SpiderMove> {
if (seat != SOLO_SEAT || state.isWon) return emptyList()
val moves = mutableListOf<SpiderMove>()
if (state.canDeal) moves += DealRow
state.tableau.forEachIndexed { from, pile ->
val tail = movableTail(pile)
for (count in 1..tail.size) {
val run = tail.takeLast(count)
val head = run.first()
state.tableau.indices.forEach { to ->
if (to == from) return@forEach
val target = state.tableau[to]
val fits = target.top?.let { landsOn(head, it) } ?: true
if (!fits) return@forEach
// Emptying one column only to fill another gains nothing.
if (target.isEmpty && pile.faceDown.isEmpty() && count == pile.faceUp.size) {
return@forEach
}
moves += MoveRun(from, to, count)
}
}
}
return moves
}
override fun applyMove(state: SpiderState, seat: Int, move: SpiderMove): SpiderState {
require(seat == SOLO_SEAT) { "Spider has one seat" }
require(legalMoves(state, seat).contains(move)) { "$move is not legal here" }
val next = when (move) {
DealRow -> dealRow(state)
is MoveRun -> moveRun(state, move)
}
return next.copy(
moves = state.moves + 1,
log = if (next.isWon) next.log + "Out, in ${state.moves + 1} moves." else next.log,
)
}
private fun dealRow(state: SpiderState): SpiderState {
var stock = state.stock
val piles = state.tableau.map { pile ->
val card = stock.lastOrNull() ?: return@map pile
stock = stock.dropLast(1)
pile.copy(faceUp = pile.faceUp + card)
}
// A dealt row can complete a run in any column it landed on.
return sweep(state.copy(stock = stock, tableau = piles), piles.indices.toList())
}
private fun moveRun(state: SpiderState, move: MoveRun): SpiderState {
val source = state.tableau[move.from]
val run = source.faceUp.takeLast(move.count)
val left = source.faceUp.dropLast(move.count)
val stripped = if (left.isEmpty() && source.faceDown.isNotEmpty()) {
SpiderPile(source.faceDown.dropLast(1), listOf(source.faceDown.last()))
} else {
source.copy(faceUp = left)
}
val target = state.tableau[move.to]
val piles = state.tableau.toMutableList().also {
it[move.from] = stripped
it[move.to] = target.copy(faceUp = target.faceUp + run)
}
return sweep(state.copy(tableau = piles), listOf(move.to))
}
/**
* Sends away any king-to-ace run finished by the last move.
*
* Only the columns that changed are looked at, and only their ends: a
* complete run can only ever be sitting on the bottom of a pile, because
* anything laid on an ace would have to be lower than one.
*/
private fun sweep(state: SpiderState, touched: List<Int>): SpiderState {
var piles = state.tableau
var completed = state.completed
var notes = state.log
touched.forEach { index ->
val pile = piles[index]
if (pile.faceUp.size < RUN_LENGTH) return@forEach
if (!isCompleteRun(pile.faceUp.takeLast(RUN_LENGTH))) return@forEach
val left = pile.faceUp.dropLast(RUN_LENGTH)
val stripped = if (left.isEmpty() && pile.faceDown.isNotEmpty()) {
SpiderPile(pile.faceDown.dropLast(1), listOf(pile.faceDown.last()))
} else {
pile.copy(faceUp = left)
}
piles = piles.toMutableList().also { it[index] = stripped }
completed++
notes = notes + "A run went away — $completed of $RUNS_TO_WIN."
}
return state.copy(tableau = piles, completed = completed, log = notes)
}
override fun isFinished(state: SpiderState): Boolean =
state.isWon || legalMoves(state, SOLO_SEAT).isEmpty()
override fun summary(state: SpiderState): String = when {
state.isWon -> "All eight runs, in ${state.moves} moves"
isFinished(state) -> "Blocked with ${state.completed} runs away"
else -> "${state.completed} of $RUNS_TO_WIN runs away"
}
override fun viewFor(state: SpiderState, seat: Int): SpiderState = state
override fun encodeState(state: SpiderState): String = json.encodeToString(state)
override fun decodeState(json: String): SpiderState = this.json.decodeFromString(json)
override fun encodeMove(move: SpiderMove): String = json.encodeToString(move)
override fun decodeMove(json: String): SpiderMove = this.json.decodeFromString(json)
fun restart(state: SpiderState): SpiderState = deal(state.options, state.seed)
}
@@ -0,0 +1,351 @@
package org.prolibertate.games.game.solitaire
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
import org.prolibertate.games.game.freecell.COLUMNS as FREECELL_COLUMNS
import org.prolibertate.games.game.freecell.FreeCellOptions
import org.prolibertate.games.game.freecell.FreeCellRules
import org.prolibertate.games.game.freecell.MoveTo
import org.prolibertate.games.game.freecell.Place
import org.prolibertate.games.game.freecell.maxRunLength
import org.prolibertate.games.game.pyramid.PYRAMID_SIZE
import org.prolibertate.games.game.pyramid.PyramidOptions
import org.prolibertate.games.game.pyramid.PyramidRules
import org.prolibertate.games.game.pyramid.PyramidZone
import org.prolibertate.games.game.pyramid.TARGET
import org.prolibertate.games.game.pyramid.coveredBy
import org.prolibertate.games.game.pyramid.isExposed
import org.prolibertate.games.game.pyramid.rowOf
import org.prolibertate.games.game.pyramid.valueOf
import org.prolibertate.games.game.spider.COLUMNS as SPIDER_COLUMNS
import org.prolibertate.games.game.spider.DealRow
import org.prolibertate.games.game.spider.RUN_LENGTH
import org.prolibertate.games.game.spider.SpiderOptions
import org.prolibertate.games.game.spider.SpiderRules
import org.prolibertate.games.game.spider.isCompleteRun
import org.prolibertate.games.game.spider.landsOn
import org.prolibertate.games.game.spider.sameSuitRun
import org.prolibertate.games.game.spider.spiderDeck
/**
* The three patiences added together, tested together.
*
* They share the ace-low ordering and little else, so what is worth pinning is
* each one's own shape: how many cards it deals, what it will let travel, and
* what counts as finished.
*/
class SolitaireRulesTest {
private val json = Json { encodeDefaults = true }
private fun soloConfig(gameId: String, optionsJson: String, seed: Long = 3L) = TableConfig(
gameId = gameId,
seats = listOf(PlayerSlot(seat = 0, name = "P0", kind = PlayerKind.HUMAN_LOCAL, team = 0)),
optionsJson = optionsJson,
seed = seed,
)
private fun c(rank: Rank, suit: Suit) = Card(rank, suit)
// ---- shared ------------------------------------------------------------
@Test
fun `the ace is low for all of them`() {
assertEquals(1, Rank.ACE.patienceOrder)
assertEquals(11, Rank.JACK.patienceOrder)
assertEquals(13, Rank.KING.patienceOrder)
assertTrue(buildsDownAlternating(c(Rank.ACE, Suit.HEARTS), c(Rank.TWO, Suit.SPADES)))
assertTrue(buildsDownInSuit(c(Rank.ACE, Suit.SPADES), c(Rank.TWO, Suit.SPADES)))
assertFalse(buildsDownInSuit(c(Rank.ACE, Suit.HEARTS), c(Rank.TWO, Suit.SPADES)))
}
// ---- FreeCell ----------------------------------------------------------
@Test
fun `FreeCell deals the whole pack face up`() {
val state = FreeCellRules.initialState(
soloConfig("freecell", json.encodeToString(FreeCellOptions())),
)
assertEquals(FREECELL_COLUMNS, state.tableau.size)
// Fifty-two into eight is four sevens and four sixes.
assertEquals(listOf(7, 7, 7, 7, 6, 6, 6, 6), state.tableau.map { it.size })
val all = state.tableau.flatten()
assertEquals(52, all.size)
assertEquals(52, all.distinct().size)
assertEquals(4, state.freeCellCount)
assertEquals(0, state.emptyColumns)
}
@Test
fun `how much can travel depends on cells and spaces`() {
// One card per free cell plus the card itself, doubled for each empty
// column that can be borrowed as a staging pile.
assertEquals(5, maxRunLength(freeCells = 4, emptyColumns = 0, intoEmptyColumn = false))
assertEquals(10, maxRunLength(freeCells = 4, emptyColumns = 1, intoEmptyColumn = false))
assertEquals(20, maxRunLength(freeCells = 4, emptyColumns = 2, intoEmptyColumn = false))
// Moving into an empty column cannot also stage through it.
assertEquals(5, maxRunLength(freeCells = 4, emptyColumns = 1, intoEmptyColumn = true))
assertEquals(1, maxRunLength(freeCells = 0, emptyColumns = 0, intoEmptyColumn = false))
}
@Test
fun `FreeCell refuses a table for more than one`() {
val two = TableConfig(
gameId = "freecell",
seats = (0..1).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
optionsJson = json.encodeToString(FreeCellOptions()),
seed = 1L,
)
assertThrows { FreeCellRules.initialState(two) }
}
@Test
fun `FreeCell keeps every card wherever it is put`() {
var state = FreeCellRules.initialState(
soloConfig("freecell", json.encodeToString(FreeCellOptions())),
)
val ai = FirstLegalAi<org.prolibertate.games.game.freecell.FreeCellState,
org.prolibertate.games.game.freecell.FreeCellMove>()
var guard = 120
while (!FreeCellRules.isFinished(state) && guard-- > 0) {
val legal = FreeCellRules.legalMoves(state, 0)
state = FreeCellRules.applyMove(state, 0, ai.chooseMove(state, 0, legal))
val all = state.tableau.flatten() + state.foundations.flatten() + state.cells.filterNotNull()
assertEquals("no card lost or copied", 52, all.size)
assertEquals(52, all.distinct().size)
}
}
@Test
fun `a card in a cell can come back out`() {
val state = FreeCellRules.initialState(
soloConfig("freecell", json.encodeToString(FreeCellOptions())),
)
val toCell = FreeCellRules.legalMoves(state, 0)
.filterIsInstance<MoveTo>()
.first { it.to.kind == org.prolibertate.games.game.freecell.CellKind.CELL }
val parked = FreeCellRules.applyMove(state, 0, toCell)
assertEquals(3, parked.freeCellCount)
assertTrue(
"the parked card must be playable again",
FreeCellRules.legalMoves(parked, 0)
.filterIsInstance<MoveTo>()
.any { it.from == Place.cell(toCell.to.index) },
)
}
// ---- Spider ------------------------------------------------------------
@Test
fun `a Spider pack is always a hundred and four`() {
listOf(1, 2, 4).forEach { suits ->
val deck = spiderDeck(suits)
assertEquals("$suits suits", 104, deck.size)
assertEquals("$suits suits", suits, deck.map { it.suit }.distinct().size)
// Eight of every rank, however the suits were shared out.
assertEquals(8, deck.count { it.rank == Rank.KING })
}
}
@Test
fun `Spider deals fifty-four and keeps fifty back`() {
val state = SpiderRules.initialState(
soloConfig("spider", json.encodeToString(SpiderOptions())),
)
assertEquals(SPIDER_COLUMNS, state.tableau.size)
assertEquals(listOf(6, 6, 6, 6, 5, 5, 5, 5, 5, 5), state.tableau.map { it.faceDown.size + it.faceUp.size })
state.tableau.forEach { assertEquals("only the last is turned up", 1, it.faceUp.size) }
assertEquals(50, state.stock.size)
assertEquals(0, state.completed)
}
@Test
fun `only a same-suit run travels, but anything lands on a higher card`() {
val spades = listOf(c(Rank.TEN, Suit.SPADES), c(Rank.NINE, Suit.SPADES))
val mixed = listOf(c(Rank.TEN, Suit.SPADES), c(Rank.NINE, Suit.HEARTS))
assertTrue(sameSuitRun(spades))
assertFalse("a mixed run cannot travel as one", sameSuitRun(mixed))
// Landing is looser than travelling: any suit, one rank higher.
assertTrue(landsOn(c(Rank.NINE, Suit.HEARTS), c(Rank.TEN, Suit.SPADES)))
assertFalse(landsOn(c(Rank.NINE, Suit.HEARTS), c(Rank.JACK, Suit.SPADES)))
}
@Test
fun `a complete run is king down to ace in one suit`() {
val run = Rank.standard.sortedByDescending { it.patienceOrder }.map { c(it, Suit.SPADES) }
assertEquals(RUN_LENGTH, run.size)
assertTrue(isCompleteRun(run))
// One card of the wrong suit and it is not going anywhere.
val broken = run.toMutableList().also { it[5] = c(it[5].rank, Suit.HEARTS) }
assertFalse(isCompleteRun(broken))
assertFalse(isCompleteRun(run.drop(1)))
}
@Test
fun `a row cannot be dealt onto an empty column`() {
val state = SpiderRules.initialState(
soloConfig("spider", json.encodeToString(SpiderOptions())),
)
assertTrue(state.canDeal)
assertTrue(SpiderRules.legalMoves(state, 0).contains(DealRow))
val emptied = state.copy(
tableau = state.tableau.toMutableList().also {
it[0] = org.prolibertate.games.game.spider.SpiderPile()
},
)
assertFalse(emptied.canDeal)
assertFalse(SpiderRules.legalMoves(emptied, 0).contains(DealRow))
}
@Test
fun `dealing a row puts one card on every column`() {
val state = SpiderRules.initialState(
soloConfig("spider", json.encodeToString(SpiderOptions())),
)
val before = state.tableau.map { it.faceUp.size }
val dealt = SpiderRules.applyMove(state, 0, DealRow)
assertEquals(40, dealt.stock.size)
dealt.tableau.forEachIndexed { index, pile ->
assertEquals(before[index] + 1, pile.faceUp.size)
}
}
// ---- Pyramid -----------------------------------------------------------
@Test
fun `the pyramid is twenty-eight cards in seven rows`() {
assertEquals(28, PYRAMID_SIZE)
assertEquals(0, rowOf(0))
assertEquals(1, rowOf(1))
assertEquals(1, rowOf(2))
assertEquals(2, rowOf(3))
assertEquals(6, rowOf(27))
val state = PyramidRules.initialState(
soloConfig("pyramid", json.encodeToString(PyramidOptions())),
)
assertEquals(PYRAMID_SIZE, state.pyramid.size)
assertEquals(24, state.stock.size)
assertTrue(state.pyramid.all { it != null })
}
@Test
fun `a card is buried until both the cards on it are gone`() {
// The apex rests on the two cards of the second row.
assertEquals(listOf(1, 2), coveredBy(0))
// The bottom row rests on nothing.
assertTrue(coveredBy(27).isEmpty())
val pyramid = MutableList<Card?>(PYRAMID_SIZE) { c(Rank.FIVE, Suit.SPADES) }
assertFalse("the apex starts buried", isExposed(pyramid, 0))
pyramid[1] = null
assertFalse("one of the two is not enough", isExposed(pyramid, 0))
pyramid[2] = null
assertTrue(isExposed(pyramid, 0))
// A place already taken is not exposed, it is gone.
pyramid[0] = null
assertFalse(isExposed(pyramid, 0))
}
@Test
fun `pairs make thirteen, and a king makes it alone`() {
assertEquals(1, valueOf(c(Rank.ACE, Suit.SPADES)))
assertEquals(11, valueOf(c(Rank.JACK, Suit.SPADES)))
assertEquals(TARGET, valueOf(c(Rank.KING, Suit.SPADES)))
assertEquals(TARGET, valueOf(c(Rank.ACE, Suit.SPADES)) + valueOf(c(Rank.QUEEN, Suit.HEARTS)))
assertEquals(TARGET, valueOf(c(Rank.SIX, Suit.CLUBS)) + valueOf(c(Rank.SEVEN, Suit.DIAMONDS)))
}
@Test
fun `only the bottom row is in play at the start`() {
val state = PyramidRules.initialState(
soloConfig("pyramid", json.encodeToString(PyramidOptions())),
)
val reachable = PyramidRules.available(state)
val fromPyramid = reachable.filter { it.first.zone == PyramidZone.PYRAMID }
assertEquals("the seven of the bottom row", 7, fromPyramid.size)
assertTrue(fromPyramid.all { rowOf(it.first.index) == 6 })
// Nothing has been turned yet, so the waste offers nothing.
assertTrue(reachable.none { it.first.zone == PyramidZone.WASTE })
}
@Test
fun `drawing turns a card and the waste can be turned back`() {
var state = PyramidRules.initialState(
soloConfig("pyramid", json.encodeToString(PyramidOptions(redeals = 1))),
)
val stock = state.stock.size
state = PyramidRules.applyMove(state, 0, org.prolibertate.games.game.pyramid.DrawCard)
assertEquals(stock - 1, state.stock.size)
assertEquals(1, state.waste.size)
var guard = 40
while (state.stock.isNotEmpty() && guard-- > 0) {
state = PyramidRules.applyMove(state, 0, org.prolibertate.games.game.pyramid.DrawCard)
}
assertTrue(state.canRedeal)
val turned = PyramidRules.applyMove(state, 0, org.prolibertate.games.game.pyramid.RecycleWaste)
assertTrue(turned.waste.isEmpty())
assertEquals(1, turned.redealsUsed)
assertFalse("only the one redeal was allowed", turned.canRedeal)
}
@Test
fun `a cleared pyramid is won`() {
val state = PyramidRules.initialState(
soloConfig("pyramid", json.encodeToString(PyramidOptions())),
)
val cleared = state.copy(pyramid = List(PYRAMID_SIZE) { null })
assertTrue(cleared.isWon)
assertTrue(PyramidRules.isFinished(cleared))
assertNull(PyramidRules.currentSeat(cleared))
assertTrue(PyramidRules.summary(cleared).contains("Cleared"))
}
// ---- codecs ------------------------------------------------------------
@Test
fun `each of them survives a round trip`() {
val freecell = FreeCellRules.initialState(
soloConfig("freecell", json.encodeToString(FreeCellOptions())),
)
assertEquals(freecell, FreeCellRules.decodeState(FreeCellRules.encodeState(freecell)))
val spider = SpiderRules.initialState(
soloConfig("spider", json.encodeToString(SpiderOptions(suits = 4))),
)
assertEquals(spider, SpiderRules.decodeState(SpiderRules.encodeState(spider)))
assertEquals(DealRow, SpiderRules.decodeMove(SpiderRules.encodeMove(DealRow)))
val pyramid = PyramidRules.initialState(
soloConfig("pyramid", json.encodeToString(PyramidOptions())),
)
assertEquals(pyramid, PyramidRules.decodeState(PyramidRules.encodeState(pyramid)))
val king = org.prolibertate.games.game.pyramid.TakeKing(PyramidSpotAt(3))
assertEquals(king, PyramidRules.decodeMove(PyramidRules.encodeMove(king)))
}
private fun PyramidSpotAt(index: Int) =
org.prolibertate.games.game.pyramid.PyramidSpot(PyramidZone.PYRAMID, index)
private fun assertThrows(block: () -> Unit) {
try {
block()
throw AssertionError("expected the rules to reject that")
} catch (expected: IllegalArgumentException) {
// Rejected, which is the point.
}
}
}