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 f96b203..14a48a8 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -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" diff --git a/app/src/main/java/org/prolibertate/games/game/freecell/FreeCellModel.kt b/app/src/main/java/org/prolibertate/games/game/freecell/FreeCellModel.kt new file mode 100644 index 0000000..620c9e8 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/freecell/FreeCellModel.kt @@ -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, + val foundations: List>, + /** Every card is face up in FreeCell; there is nothing buried to turn. */ + val tableau: List>, + val moves: Int, + val log: List, +) { + 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 +} diff --git a/app/src/main/java/org/prolibertate/games/game/freecell/FreeCellRules.kt b/app/src/main/java/org/prolibertate/games/game/freecell/FreeCellRules.kt new file mode 100644 index 0000000..c082c0c --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/freecell/FreeCellRules.kt @@ -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 { + + 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(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>() + 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): List { + 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 { + if (seat != SOLO_SEAT || state.isWon) return emptyList() + val moves = mutableListOf() + + fun offer(from: Place, cards: List) { + 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 List.replacing(index: Int, value: T): List = + 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) +} diff --git a/app/src/main/java/org/prolibertate/games/game/pyramid/PyramidModel.kt b/app/src/main/java/org/prolibertate/games/game/pyramid/PyramidModel.kt new file mode 100644 index 0000000..59fcabd --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/pyramid/PyramidModel.kt @@ -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, + val stock: List, + val waste: List, + val redealsUsed: Int, + val moves: Int, + val log: List, +) { + 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 { + 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, index: Int): Boolean = + pyramid[index] != null && coveredBy(index).all { pyramid[it] == null } diff --git a/app/src/main/java/org/prolibertate/games/game/pyramid/PyramidRules.kt b/app/src/main/java/org/prolibertate/games/game/pyramid/PyramidRules.kt new file mode 100644 index 0000000..98aed3d --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/pyramid/PyramidRules.kt @@ -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 { + + 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(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> { + 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 { + if (seat != SOLO_SEAT || state.isWon) return emptyList() + val moves = mutableListOf() + 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): 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) +} diff --git a/app/src/main/java/org/prolibertate/games/game/solitaire/HintAi.kt b/app/src/main/java/org/prolibertate/games/game/solitaire/HintAi.kt new file mode 100644 index 0000000..682067e --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/solitaire/HintAi.kt @@ -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 : GameAi { + override fun chooseMove(state: S, seat: Int, legal: List): M { + require(legal.isNotEmpty()) { "No legal move for seat $seat" } + return legal.first() + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/spider/SpiderModel.kt b/app/src/main/java/org/prolibertate/games/game/spider/SpiderModel.kt new file mode 100644 index 0000000..e8fc7fb --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/spider/SpiderModel.kt @@ -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 = emptyList(), + val faceUp: List = 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, + val tableau: List, + /** Runs sent away complete. Eight of them wins. */ + val completed: Int, + val moves: Int, + val log: List, +) { + 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): 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): 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 { + val used = Suit.entries.take(suits) + val copies = 8 / suits + return used.flatMap { suit -> + List(copies) { Rank.standard.map { Card(it, suit) } }.flatten() + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/spider/SpiderRules.kt b/app/src/main/java/org/prolibertate/games/game/spider/SpiderRules.kt new file mode 100644 index 0000000..c16ac0e --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/spider/SpiderRules.kt @@ -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 { + + 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(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 { + 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 { + if (seat != SOLO_SEAT || state.isWon) return emptyList() + val moves = mutableListOf() + 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): 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) +} diff --git a/app/src/test/java/org/prolibertate/games/game/solitaire/SolitaireRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/solitaire/SolitaireRulesTest.kt new file mode 100644 index 0000000..135b479 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/solitaire/SolitaireRulesTest.kt @@ -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() + 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() + .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() + .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(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. + } + } +}