From 40ea68f4c0b455347850b3b909fa6e315ee3353e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 16:05:03 +0000 Subject: [PATCH 1/4] 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 Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC --- .../org/prolibertate/games/game/Catalog.kt | 3 + .../games/game/freecell/FreeCellModel.kt | 81 ++++ .../games/game/freecell/FreeCellRules.kt | 193 ++++++++++ .../games/game/pyramid/PyramidModel.kt | 98 +++++ .../games/game/pyramid/PyramidRules.kt | 150 ++++++++ .../games/game/solitaire/HintAi.kt | 24 ++ .../games/game/spider/SpiderModel.kt | 107 ++++++ .../games/game/spider/SpiderRules.kt | 177 +++++++++ .../game/solitaire/SolitaireRulesTest.kt | 351 ++++++++++++++++++ 9 files changed, 1184 insertions(+) create mode 100644 app/src/main/java/org/prolibertate/games/game/freecell/FreeCellModel.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/freecell/FreeCellRules.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/pyramid/PyramidModel.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/pyramid/PyramidRules.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/solitaire/HintAi.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/spider/SpiderModel.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/spider/SpiderRules.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/solitaire/SolitaireRulesTest.kt 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. + } + } +} From deaf85938c92795db064f8484bcb8bf56d29b0fc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 16:07:39 +0000 Subject: [PATCH 2/4] Lay the Klondike table out the way a real one is Three changes, all about where things are and how much room they take. Columns now overlap by a third rather than sitting one under another. A column of thirteen took more vertical space than a phone has; overlapped, it fits, and a third is the most that can be hidden while every card still shows its rank and suit. Absolute offsets rather than negative spacing, because the column has to work out its own height anyway to reserve the right room. The cards turned off the stock fan sideways by the same third. Drawing three and showing one hid two cards the player is entitled to see -- they are face up on the table. Only the last is playable, which the rules already enforce; the other two are there to be read. The stock and the cards turned from it move to the bottom left, under the hand that turns them, and the foundations keep the top on their own. That is where both live on a real table, and it puts the pile you tap most within reach of a thumb rather than at the far corner of the screen. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC --- .../games/ui/game/KlondikeScreen.kt | 198 +++++++++++++----- 1 file changed, 150 insertions(+), 48 deletions(-) diff --git a/app/src/main/java/org/prolibertate/games/ui/game/KlondikeScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/KlondikeScreen.kt index 1bd3ec3..af7920b 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/KlondikeScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/KlondikeScreen.kt @@ -9,8 +9,9 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -31,15 +32,18 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import org.prolibertate.games.game.cards.Card import org.prolibertate.games.game.klondike.Draw import org.prolibertate.games.game.klondike.KlondikeMove import org.prolibertate.games.game.klondike.KlondikeState import org.prolibertate.games.game.klondike.MoveCards import org.prolibertate.games.game.klondike.Redeal import org.prolibertate.games.game.klondike.Spot +import org.prolibertate.games.game.klondike.TableauPile import org.prolibertate.games.game.klondike.foundationSuit import org.prolibertate.games.net.MatchController import org.prolibertate.games.ui.ScreenScaffold +import org.prolibertate.games.ui.common.CARD_ASPECT import org.prolibertate.games.ui.common.CardBackView import org.prolibertate.games.ui.common.PlayingCardView import org.prolibertate.games.ui.theme.FeltGreenDark @@ -47,18 +51,39 @@ import org.prolibertate.games.ui.theme.Parchment import org.prolibertate.games.ui.theme.WallaceGold private const val TITLE = "Klondike" -private val CARD_WIDTH: Dp = 40.dp + +private val CARD_WIDTH: Dp = 44.dp +private val CARD_HEIGHT: Dp = CARD_WIDTH / CARD_ASPECT + +/** + * How far down each card in a column sits from the one above it. + * + * Two thirds of a card, so each is overlapped by a third. That is the most that + * can be hidden while every card still shows its rank and suit, and it is what + * keeps a column of thirteen on the screen instead of running off the bottom. + */ +private val STACK_STEP: Dp = CARD_HEIGHT * 2f / 3f + +/** The same overlap sideways, for the three cards turned off the stock. */ +private val FAN_STEP: Dp = CARD_WIDTH * 2f / 3f + +/** At most three are ever turned at once, so at most three are ever fanned. */ +private const val MAX_FANNED = 3 /** * The patience table. * - * Play is tap-to-take then tap-to-put, rather than dragging: on a phone a drag - * over seven columns of overlapping cards is mostly a test of aim. Tapping a - * face-up card takes it and everything sitting on it, so a run travels the way - * it would on a table. + * Laid out the way a real one is: the homes along the top where cards are going, + * the columns filling the middle, and the pack in the bottom corner under your + * hand. Columns overlap by a third so a long one still fits, and the cards + * turned off the stock fan sideways by the same third so all three can be seen + * rather than only the one on top. * - * Which taps do anything is read straight off the legal moves the rules offer, - * so the screen never has to know why a move is allowed — only that it is. + * Play is tap-to-take then tap-to-put rather than dragging: across seven columns + * of overlapping cards on a phone, a drag is mostly a test of aim. Tapping a + * face-up card takes it and everything resting on it, so a run travels the way + * it would on a table. Which taps do anything is read off the legal moves the + * rules offer, so the screen never has to know why a move is allowed. */ @Composable fun KlondikeScreen( @@ -92,7 +117,6 @@ fun KlondikeScreen( val canRedeal = legal.contains(Redeal) val finished = legal.isEmpty() - // Where the cards in hand could go, and which spots could be picked up. val targets = held?.let { (from, count) -> moves.filter { it.from == from && it.count == count }.map { it.to }.toSet() }.orEmpty() @@ -115,7 +139,7 @@ fun KlondikeScreen( ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> Column( modifier = modifier.verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { Text( text = when { @@ -127,26 +151,8 @@ fun KlondikeScreen( fontWeight = FontWeight.Bold, ) + // The homes, along the top where they are being filled towards. Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - // Stock, waste, and the four homes across the top. - Slot( - highlighted = false, - onClick = { if (canDraw) controller.submit(Draw) else if (canRedeal) controller.submit(Redeal) }, - ) { - if (current.stock.isNotEmpty()) { - CardBackView(width = CARD_WIDTH) - } else { - Empty(label = if (canRedeal) "↻" else "") - } - } - Slot( - highlighted = held?.first == Spot.waste, - onClick = { take(Spot.waste, 1) }, - ) { - current.waste.lastOrNull() - ?.let { PlayingCardView(card = it, width = CARD_WIDTH) } - ?: Empty() - } current.foundations.forEachIndexed { index, pile -> Slot( highlighted = targets.contains(Spot.foundation(index)), @@ -162,28 +168,43 @@ fun KlondikeScreen( Row( modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.Top, ) { current.tableau.forEachIndexed { index, pile -> - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - if (pile.isEmpty) { - Slot( - highlighted = targets.contains(Spot.tableau(index)), - onClick = { take(Spot.tableau(index), 1) }, - ) { Empty() } - } - pile.faceDown.forEach { CardBackView(width = CARD_WIDTH) } - pile.faceUp.forEachIndexed { depth, card -> - // Taking a card takes everything resting on it, so - // the count is however many are below it in the run. - val count = pile.faceUp.size - depth - Slot( - highlighted = held == (Spot.tableau(index) to count) || - (depth == pile.faceUp.lastIndex && targets.contains(Spot.tableau(index))), - onClick = { take(Spot.tableau(index), count) }, - ) { PlayingCardView(card = card, width = CARD_WIDTH) } + ColumnOfCards( + pile = pile, + held = held, + index = index, + isTarget = targets.contains(Spot.tableau(index)), + onTap = { count -> take(Spot.tableau(index), count) }, + ) + } + } + + // The pack sits at the bottom left, under the hand that turns it. + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Slot( + highlighted = false, + onClick = { + if (canDraw) { + controller.submit(Draw) + } else if (canRedeal) { + controller.submit(Redeal) } + }, + ) { + if (current.stock.isNotEmpty()) { + CardBackView(width = CARD_WIDTH) + } else { + Empty(label = if (canRedeal) "↻" else "") } } + WasteFan( + waste = current.waste, + fanned = minOf(current.options.drawCount, MAX_FANNED), + highlighted = held?.first == Spot.waste, + onTap = { take(Spot.waste, 1) }, + ) } if (finished) { @@ -203,10 +224,91 @@ fun KlondikeScreen( } } +/** + * One column, overlapped by a third. + * + * Absolute offsets rather than negative spacing: the height has to be worked out + * anyway so the column reserves the right room, and once that is known placing + * each card is the simpler half. + */ @Composable -private fun Slot(highlighted: Boolean, onClick: () -> Unit, content: @Composable () -> Unit) { +private fun ColumnOfCards( + pile: TableauPile, + held: Pair?, + index: Int, + isTarget: Boolean, + onTap: (Int) -> Unit, +) { + val total = pile.faceDown.size + pile.faceUp.size + if (total == 0) { + Slot(highlighted = isTarget, onClick = { onTap(1) }) { Empty() } + return + } Box( modifier = Modifier + .width(CARD_WIDTH) + .height(CARD_HEIGHT + STACK_STEP * (total - 1)), + ) { + pile.faceDown.forEachIndexed { depth, _ -> + CardBackView( + width = CARD_WIDTH, + modifier = Modifier.offset(y = STACK_STEP * depth), + ) + } + pile.faceUp.forEachIndexed { depth, card -> + // Taking a card takes everything resting on it. + val count = pile.faceUp.size - depth + val row = pile.faceDown.size + depth + Slot( + modifier = Modifier.offset(y = STACK_STEP * row), + highlighted = held == (Spot.tableau(index) to count) || + (depth == pile.faceUp.lastIndex && isTarget), + onClick = { onTap(count) }, + ) { PlayingCardView(card = card, width = CARD_WIDTH) } + } + } +} + +/** + * The cards turned off the stock, fanned sideways. + * + * Drawing three and showing one would hide two cards the player is entitled to + * see — they are face up on the table. Only the last is playable, which the + * rules already enforce; the other two are there to be read. + */ +@Composable +private fun WasteFan( + waste: List, + fanned: Int, + highlighted: Boolean, + onTap: () -> Unit, +) { + val shown = waste.takeLast(fanned) + if (shown.isEmpty()) { + Slot(highlighted = false, onClick = onTap) { Empty() } + return + } + Box(modifier = Modifier.width(CARD_WIDTH + FAN_STEP * (shown.size - 1))) { + shown.forEachIndexed { position, card -> + val isTop = position == shown.lastIndex + Slot( + modifier = Modifier.offset(x = FAN_STEP * position), + highlighted = isTop && highlighted, + onClick = { if (isTop) onTap() }, + ) { PlayingCardView(card = card, width = CARD_WIDTH) } + } + } +} + +@Composable +private fun Slot( + highlighted: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Box( + modifier = modifier .width(CARD_WIDTH) .border( border = BorderStroke(if (highlighted) 3.dp else 0.dp, WallaceGold), @@ -224,7 +326,7 @@ private fun Empty(label: String = "") { Box( modifier = Modifier .width(CARD_WIDTH) - .aspectRatio(0.7f) + .height(CARD_HEIGHT) .background(FeltGreenDark, RoundedCornerShape(4.dp)), contentAlignment = Alignment.Center, ) { From 4e7aa98b61cf067af6b66b8ca9093a9f9f81b2ed Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 16:14:06 +0000 Subject: [PATCH 3/4] A cell can strand a card, and the test said otherwise The test asserted that a card parked in a free cell can always be played again. It cannot. A cell holds a card nothing on the tableau will take and that is not yet ready for a foundation, and it stays there -- that is the whole danger of the cells rather than a fault in them, and the reason four of them is a game worth playing. What is worth pinning is what parking actually does: the card leaves the column and is in the cell afterwards. The one card a cell can never strand is an ace, since a foundation will always have it, so that gets a test of its own. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC --- .../game/solitaire/SolitaireRulesTest.kt | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) 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 index 135b479..c4d2d48 100644 --- a/app/src/test/java/org/prolibertate/games/game/solitaire/SolitaireRulesTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/solitaire/SolitaireRulesTest.kt @@ -128,20 +128,37 @@ class SolitaireRulesTest { } @Test - fun `a card in a cell can come back out`() { + fun `parking a card takes it off the column and into the cell`() { 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 column = toCell.from.index + val card = state.tableau[column].last() + val parked = FreeCellRules.applyMove(state, 0, toCell) assertEquals(3, parked.freeCellCount) + assertEquals(card, parked.cells[toCell.to.index]) + assertEquals(state.tableau[column].size - 1, parked.tableau[column].size) + // Deliberately not asserting the card can come back out. A parked card + // that nothing will take is stranded, and that is the whole danger of + // the cells rather than a fault in them. + } + + @Test + fun `an ace in a cell can always go home`() { + val base = FreeCellRules.initialState( + soloConfig("freecell", json.encodeToString(FreeCellOptions())), + ) + val ace = c(Rank.ACE, Suit.SPADES) + val held = base.copy(cells = listOf(ace, null, null, null)) assertTrue( - "the parked card must be playable again", - FreeCellRules.legalMoves(parked, 0) - .filterIsInstance() - .any { it.from == Place.cell(toCell.to.index) }, + "an ace is the one card a cell can never strand", + FreeCellRules.legalMoves(held, 0).contains( + MoveTo(Place.cell(0), Place.foundation(Suit.SPADES.ordinal), 1), + ), ) } From 459670012e1a9e93a7f5468dae6aacdf72888fb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 16:23:17 +0000 Subject: [PATCH 4/4] Screens for FreeCell, Spider and Pyramid, and all three into the catalogue Seven games now, and the three new ones are tappable. The furniture they share moves into CardTable: a place a card sits, an empty place that can still be tapped into, and the two dialogs for leaving. Klondike keeps its own copies rather than being rewritten to use them -- it was written first, its constants are tuned to seven columns, and its private helpers cannot collide with these. FreeCell puts the cells on the left and the homes on the right, which is what makes the cells read as somewhere to put things rather than somewhere cards come from. Spider shows the two numbers the game actually turns on above the columns: runs gone away, and rows still in the stock waiting to bury everything. Pyramid is drawn as it is dealt, each row offset half a card so every card sits in the notch between the two below it. The shape is the rules -- a card is only in play once both the cards resting on it have gone -- so seeing it at a glance is most of playing it. Tapping a king takes it alone; tapping anything else holds it and lights up only what would make thirteen with it. All three restart by rebuilding the controller, as Klondike and peg solitaire do, because a blocked deal is genuinely finished and advanceIdle never runs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC --- .../org/prolibertate/games/game/Catalog.kt | 33 +++ .../prolibertate/games/ui/GameSetupScreen.kt | 63 +++++ .../org/prolibertate/games/ui/PlayScreen.kt | 64 +++++ .../prolibertate/games/ui/game/CardTable.kt | 96 +++++++ .../games/ui/game/FreeCellScreen.kt | 188 +++++++++++++ .../games/ui/game/PyramidScreen.kt | 260 ++++++++++++++++++ .../games/ui/game/SpiderScreen.kt | 191 +++++++++++++ .../prolibertate/games/game/CatalogTest.kt | 3 + 8 files changed, 898 insertions(+) create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/CardTable.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/FreeCellScreen.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/PyramidScreen.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/SpiderScreen.kt 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 14a48a8..306e0b1 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -147,6 +147,39 @@ object GameCatalog { "and a pack that decides most of it before you start.", available = true, ), + GameDescriptor( + id = FREECELL, + title = "FreeCell", + category = GameCategory.CARD, + minPlayers = 1, + maxPlayers = 1, + teamBased = false, + blurb = "Every card face up from the start, and four cells to park what is " + + "in the way. Nearly every deal can be won by someone good enough.", + available = true, + ), + GameDescriptor( + id = SPIDER, + title = "Spider", + category = GameCategory.CARD, + minPlayers = 1, + maxPlayers = 1, + teamBased = false, + blurb = "Two packs, ten columns, eight runs to build from king to ace. " + + "One suit is a pastime; four is a fight.", + available = true, + ), + GameDescriptor( + id = PYRAMID, + title = "Pyramid", + category = GameCategory.CARD, + minPlayers = 1, + maxPlayers = 1, + teamBased = false, + blurb = "Take cards away in pairs that make thirteen, kings alone. " + + "Nothing is built and nothing goes home — the pyramid just has to go.", + available = true, + ), GameDescriptor( id = SEQUENCE, title = "Sequence", diff --git a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt index 58ee816..80632ea 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -42,6 +42,9 @@ import org.prolibertate.games.game.euchre.EuchreOptions import org.prolibertate.games.game.golf.GolfOptions import org.prolibertate.games.game.hearts.HeartsOptions import org.prolibertate.games.game.klondike.KlondikeOptions +import org.prolibertate.games.game.freecell.FreeCellOptions +import org.prolibertate.games.game.pyramid.PyramidOptions +import org.prolibertate.games.game.spider.SpiderOptions import org.prolibertate.games.game.yahtzee.YahtzeeOptions import org.prolibertate.games.game.pegsolitaire.PegBoard import org.prolibertate.games.game.pegsolitaire.PegGoal @@ -98,6 +101,9 @@ fun GameSetupScreen( var pegs by remember { mutableStateOf(PegSolitaireOptions()) } var yahtzee by remember { mutableStateOf(YahtzeeOptions()) } var klondike by remember { mutableStateOf(KlondikeOptions()) } + var freecell by remember { mutableStateOf(FreeCellOptions()) } + var spider by remember { mutableStateOf(SpiderOptions()) } + var pyramid by remember { mutableStateOf(PyramidOptions()) } // Not an engine option: which seat the local player takes. Seat 0 is // always White, so choosing Black means sitting in seat 1. var playWhite by remember { mutableStateOf(true) } @@ -125,6 +131,9 @@ fun GameSetupScreen( GameCatalog.PEG_SOLITAIRE -> setupJson.encodeToString(pegs) GameCatalog.YAHTZEE -> setupJson.encodeToString(yahtzee) GameCatalog.KLONDIKE -> setupJson.encodeToString(klondike) + GameCatalog.FREECELL -> setupJson.encodeToString(freecell) + GameCatalog.SPIDER -> setupJson.encodeToString(spider) + GameCatalog.PYRAMID -> setupJson.encodeToString(pyramid) else -> "{}" } val seatCount = seatCountFor(descriptor.id, optionsJson) @@ -184,6 +193,12 @@ fun GameSetupScreen( GameCatalog.KLONDIKE -> KlondikeOptionsEditor(klondike) { klondike = it } + GameCatalog.FREECELL -> FreeCellOptionsEditor(freecell) { freecell = it } + + GameCatalog.SPIDER -> SpiderOptionsEditor(spider) { spider = it } + + GameCatalog.PYRAMID -> PyramidOptionsEditor(pyramid) { pyramid = it } + else -> Text("No options yet for this game.") } @@ -1078,3 +1093,51 @@ private fun KlondikeOptionsEditor(options: KlondikeOptions, onChange: (KlondikeO ) } } + +@Composable +private fun FreeCellOptionsEditor(options: FreeCellOptions, onChange: (FreeCellOptions) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ChipRow( + label = "Free cells", + values = (1..6).toList(), + selected = options.freeCells, + display = { "$it" }, + onSelect = { onChange(options.copy(freeCells = it)) }, + ) + ToggleRow( + title = "Move a run in one go", + subtitle = "The rules move a run one card at a time through the cells. This " + + "does the shuffling for you, which is bookkeeping rather than a decision.", + checked = options.allowSupermoves, + onChange = { onChange(options.copy(allowSupermoves = it)) }, + ) + } +} + +@Composable +private fun SpiderOptionsEditor(options: SpiderOptions, onChange: (SpiderOptions) -> Unit) { + ChipRow( + label = "Suits", + values = listOf(1, 2, 4), + selected = options.suits, + display = { + when (it) { + 1 -> "One — a pastime" + 2 -> "Two" + else -> "Four — a fight" + } + }, + onSelect = { onChange(options.copy(suits = it)) }, + ) +} + +@Composable +private fun PyramidOptionsEditor(options: PyramidOptions, onChange: (PyramidOptions) -> Unit) { + ChipRow( + label = "Turns through the pack", + values = listOf(0, 1, 2, 3), + selected = options.redeals, + display = { if (it == 0) "One pass only" else "$it turns back" }, + onSelect = { onChange(options.copy(redeals = it)) }, + ) +} diff --git a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt index 4b93b30..3359b69 100644 --- a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt @@ -29,6 +29,16 @@ import org.prolibertate.games.game.euchre.EuchreMove import org.prolibertate.games.game.euchre.EuchrePhase import org.prolibertate.games.game.euchre.EuchreRules import org.prolibertate.games.game.euchre.EuchreState +import org.prolibertate.games.game.freecell.FreeCellMove +import org.prolibertate.games.game.freecell.FreeCellRules +import org.prolibertate.games.game.freecell.FreeCellState +import org.prolibertate.games.game.pyramid.PyramidMove +import org.prolibertate.games.game.pyramid.PyramidRules +import org.prolibertate.games.game.pyramid.PyramidState +import org.prolibertate.games.game.solitaire.FirstLegalAi +import org.prolibertate.games.game.spider.SpiderMove +import org.prolibertate.games.game.spider.SpiderRules +import org.prolibertate.games.game.spider.SpiderState import org.prolibertate.games.game.klondike.KlondikeAi import org.prolibertate.games.game.klondike.KlondikeMove import org.prolibertate.games.game.klondike.KlondikeRules @@ -102,13 +112,16 @@ import org.prolibertate.games.ui.game.ChessScreen import org.prolibertate.games.ui.game.CrazyEightsScreen import org.prolibertate.games.ui.game.CribbageScreen import org.prolibertate.games.ui.game.HeartsScreen +import org.prolibertate.games.ui.game.FreeCellScreen import org.prolibertate.games.ui.game.KlondikeScreen +import org.prolibertate.games.ui.game.PyramidScreen import org.prolibertate.games.ui.game.EuchreScreen import org.prolibertate.games.ui.game.GolfScreen import org.prolibertate.games.ui.game.KaiserScreen import org.prolibertate.games.ui.game.MastermindScreen import org.prolibertate.games.ui.game.MorrisScreen import org.prolibertate.games.ui.game.PegSolitaireScreen +import org.prolibertate.games.ui.game.SpiderScreen import org.prolibertate.games.ui.game.YahtzeeScreen import org.prolibertate.games.ui.game.PiratesScreen import org.prolibertate.games.ui.game.PresidentScreen @@ -559,6 +572,57 @@ fun PlayScreen( ) } + GameCatalog.FREECELL -> { + var generation by remember(route) { mutableIntStateOf(0) } + val controller = remember(route, generation) { + MatchController( + rules = FreeCellRules, + ai = FirstLegalAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + ) + } + StartMatch(env, controller, route) + FreeCellScreen(controller = controller, onRestart = { generation++ }, onExit = onExit) + } + + GameCatalog.SPIDER -> { + var generation by remember(route) { mutableIntStateOf(0) } + val controller = remember(route, generation) { + MatchController( + rules = SpiderRules, + ai = FirstLegalAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + ) + } + StartMatch(env, controller, route) + SpiderScreen(controller = controller, onRestart = { generation++ }, onExit = onExit) + } + + GameCatalog.PYRAMID -> { + var generation by remember(route) { mutableIntStateOf(0) } + val controller = remember(route, generation) { + MatchController( + rules = PyramidRules, + ai = FirstLegalAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + ) + } + StartMatch(env, controller, route) + PyramidScreen(controller = controller, onRestart = { generation++ }, onExit = onExit) + } + GameCatalog.MORRIS -> { val controller = remember(route) { MatchController( diff --git a/app/src/main/java/org/prolibertate/games/ui/game/CardTable.kt b/app/src/main/java/org/prolibertate/games/ui/game/CardTable.kt new file mode 100644 index 0000000..b296437 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/CardTable.kt @@ -0,0 +1,96 @@ +package org.prolibertate.games.ui.game + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.prolibertate.games.ui.common.CARD_ASPECT +import org.prolibertate.games.ui.theme.FeltGreenDark +import org.prolibertate.games.ui.theme.Parchment +import org.prolibertate.games.ui.theme.WallaceGold + +/** + * The furniture every patience table needs. + * + * FreeCell, Spider and Pyramid lay their cards out quite differently but they + * are all made of the same three things: a place a card sits, an empty place + * that can still be tapped into, and the two dialogs for leaving. Klondike keeps + * its own copies — it was written first and its layout constants are tuned to + * seven columns — but nothing new needs to repeat them. + */ +val TABLE_CARD_WIDTH: Dp = 44.dp +val TABLE_CARD_HEIGHT: Dp = TABLE_CARD_WIDTH / CARD_ASPECT + +/** Overlapped by a third, which hides as little as a stack can afford to. */ +val TABLE_STACK_STEP: Dp = TABLE_CARD_HEIGHT * 2f / 3f + +@Composable +fun TableSlot( + highlighted: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + width: Dp = TABLE_CARD_WIDTH, + content: @Composable () -> Unit, +) { + Box( + modifier = modifier + .width(width) + .border( + border = BorderStroke(if (highlighted) 3.dp else 0.dp, WallaceGold), + shape = RoundedCornerShape(4.dp), + ) + .clickable { onClick() }, + ) { + content() + } +} + +/** A place with no card on it, drawn so it is still somewhere to aim at. */ +@Composable +fun EmptySpace(label: String = "", width: Dp = TABLE_CARD_WIDTH) { + Box( + modifier = Modifier + .width(width) + .height(width / CARD_ASPECT) + .background(FeltGreenDark, RoundedCornerShape(4.dp)), + contentAlignment = Alignment.Center, + ) { + if (label.isNotEmpty()) { + Text(label, color = Parchment.copy(alpha = 0.5f)) + } + } +} + +@Composable +fun PatienceEndDialog(what: String, onEnd: () -> Unit, onKeepPlaying: () -> Unit) { + AlertDialog( + onDismissRequest = onKeepPlaying, + title = { Text("End this game?") }, + text = { Text(what) }, + confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } }, + dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } }, + ) +} + +@Composable +fun PatienceLeftDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) { + AlertDialog( + onDismissRequest = onStay, + title = { Text("The game has ended") }, + text = { Text(notice) }, + confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } }, + dismissButton = { TextButton(onClick = onStay) { Text("Look at the table") } }, + ) +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/FreeCellScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/FreeCellScreen.kt new file mode 100644 index 0000000..b2d58ab --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/FreeCellScreen.kt @@ -0,0 +1,188 @@ +package org.prolibertate.games.ui.game + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.prolibertate.games.game.cards.Suit +import org.prolibertate.games.game.freecell.FreeCellMove +import org.prolibertate.games.game.freecell.FreeCellState +import org.prolibertate.games.game.freecell.MoveTo +import org.prolibertate.games.game.freecell.Place +import org.prolibertate.games.net.MatchController +import org.prolibertate.games.ui.ScreenScaffold +import org.prolibertate.games.ui.common.PlayingCardView + +private const val TITLE = "FreeCell" + +/** + * The FreeCell table. + * + * Cells on the left, homes on the right, eight columns below — the standard + * arrangement, and the one that makes the cells read as somewhere to put things + * rather than somewhere cards come from. Everything is face up from the first + * move, so the whole game is on the screen and the only thing hidden is what to + * do about it. + */ +@Composable +fun FreeCellScreen( + controller: MatchController, + onRestart: () -> Unit, + onExit: () -> Unit, +) { + val state by controller.state.collectAsState() + val legal by controller.legalMoves.collectAsState() + val abandoned by controller.abandoned.collectAsState() + + var held by remember { mutableStateOf?>(null) } + var confirmingEnd by remember { mutableStateOf(false) } + + val endGameAction: @Composable () -> Unit = { + TextButton(onClick = { confirmingEnd = true }) { Text("End game") } + } + + val current = state + if (current == null) { + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") } + abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) { + PatienceEndDialog("The deal is left as it stands.", onExit) { confirmingEnd = false } + } + } + return + } + + val moves = legal.filterIsInstance() + val finished = legal.isEmpty() + val targets = held?.let { (from, count) -> + moves.filter { it.from == from && it.count == count }.map { it.to }.toSet() + }.orEmpty() + val sources = moves.map { it.from to it.count }.toSet() + + fun take(place: Place, count: Int) { + val inHand = held + held = when { + inHand != null && targets.contains(place) -> { + controller.submit(MoveTo(inHand.first, place, inHand.second)) + null + } + + inHand?.first == place -> null + sources.contains(place to count) -> place to count + else -> null + } + } + + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = when { + current.isWon -> "Out, in ${current.moves} moves." + finished -> "Blocked — nothing left to try." + held != null -> "Tap where it goes." + else -> "${current.foundations.sumOf { it.size }} of 52 home, " + + "${current.freeCellCount} cells free." + }, + fontWeight = FontWeight.Bold, + ) + + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + current.cells.forEachIndexed { index, card -> + TableSlot( + highlighted = held?.first == Place.cell(index) || + targets.contains(Place.cell(index)), + onClick = { take(Place.cell(index), 1) }, + ) { + card?.let { PlayingCardView(card = it, width = TABLE_CARD_WIDTH) } + ?: EmptySpace(label = "·") + } + } + current.foundations.forEachIndexed { index, pile -> + TableSlot( + highlighted = targets.contains(Place.foundation(index)), + onClick = { take(Place.foundation(index), 1) }, + ) { + pile.lastOrNull()?.let { PlayingCardView(card = it, width = TABLE_CARD_WIDTH) } + ?: EmptySpace(label = Suit.entries[index].symbol) + } + } + } + + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.Top, + ) { + current.tableau.forEachIndexed { index, column -> + if (column.isEmpty()) { + TableSlot( + highlighted = targets.contains(Place.column(index)), + onClick = { take(Place.column(index), 1) }, + ) { EmptySpace() } + } else { + Box( + modifier = Modifier + .width(TABLE_CARD_WIDTH) + .height(TABLE_CARD_HEIGHT + TABLE_STACK_STEP * (column.size - 1)), + ) { + column.forEachIndexed { depth, card -> + // Taking a card takes everything resting on it. + val count = column.size - depth + TableSlot( + modifier = Modifier.offset(y = TABLE_STACK_STEP * depth), + highlighted = held == (Place.column(index) to count) || + (depth == column.lastIndex && targets.contains(Place.column(index))), + onClick = { take(Place.column(index), count) }, + ) { PlayingCardView(card = card, width = TABLE_CARD_WIDTH) } + } + } + } + } + } + + if (finished) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onRestart) { Text("Deal again") } + TextButton(onClick = onExit) { Text("Leave") } + } + } + + current.log.takeLast(2).forEach { + Text(it, style = MaterialTheme.typography.bodySmall) + } + } + + abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) { + PatienceEndDialog("The deal is left as it stands.", onExit) { confirmingEnd = false } + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/PyramidScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/PyramidScreen.kt new file mode 100644 index 0000000..4701a5f --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/PyramidScreen.kt @@ -0,0 +1,260 @@ +package org.prolibertate.games.ui.game + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.prolibertate.games.game.pyramid.DrawCard +import org.prolibertate.games.game.pyramid.PYRAMID_SIZE +import org.prolibertate.games.game.pyramid.PyramidMove +import org.prolibertate.games.game.pyramid.PyramidSpot +import org.prolibertate.games.game.pyramid.PyramidState +import org.prolibertate.games.game.pyramid.PyramidZone +import org.prolibertate.games.game.pyramid.RecycleWaste +import org.prolibertate.games.game.pyramid.ROWS +import org.prolibertate.games.game.pyramid.TakeKing +import org.prolibertate.games.game.pyramid.TakePair +import org.prolibertate.games.game.pyramid.rowStart +import org.prolibertate.games.game.pyramid.valueOf +import org.prolibertate.games.net.MatchController +import org.prolibertate.games.ui.ScreenScaffold +import org.prolibertate.games.ui.common.CardBackView +import org.prolibertate.games.ui.common.PlayingCardView + +private const val TITLE = "Pyramid" + +/** Cards are small here: the widest row is seven of them side by side. */ +private val PYRAMID_CARD: Dp = 40.dp +private val PYRAMID_CARD_HEIGHT: Dp = PYRAMID_CARD * 7f / 5f + +/** Rows overlap vertically so the whole pyramid fits above the pack. */ +private val ROW_STEP: Dp = PYRAMID_CARD_HEIGHT * 2f / 3f + +/** + * The pyramid. + * + * Drawn as it is dealt: seven rows, each one offset half a card so every card + * sits on the two below it, and rows overlapping by a third so all seven fit. + * The shape is the rules — a card is only in play once both the cards resting on + * it have gone, and being able to see that at a glance is most of playing it. + * + * Tapping a king takes it alone. Tapping any other card holds it, and tapping + * its partner takes the pair; the ones that would pair with what is held are the + * only cards that light up. + */ +@Composable +fun PyramidScreen( + controller: MatchController, + onRestart: () -> Unit, + onExit: () -> Unit, +) { + val state by controller.state.collectAsState() + val legal by controller.legalMoves.collectAsState() + val abandoned by controller.abandoned.collectAsState() + + var held by remember { mutableStateOf(null) } + var confirmingEnd by remember { mutableStateOf(false) } + + val endGameAction: @Composable () -> Unit = { + TextButton(onClick = { confirmingEnd = true }) { Text("End game") } + } + + val current = state + if (current == null) { + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") } + abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) { + PatienceEndDialog("The pyramid is left as it stands.", onExit) { confirmingEnd = false } + } + } + return + } + + val kings = legal.filterIsInstance().map { it.spot }.toSet() + val pairs = legal.filterIsInstance() + val canDraw = legal.contains(DrawCard) + val canRedeal = legal.contains(RecycleWaste) + val finished = legal.isEmpty() + + // Everything that could pair with what is in hand. + val partners = held?.let { spot -> + pairs.mapNotNull { + when (spot) { + it.first -> it.second + it.second -> it.first + else -> null + } + }.toSet() + }.orEmpty() + val pairable = pairs.flatMap { listOf(it.first, it.second) }.toSet() + + fun tap(spot: PyramidSpot) { + held = when { + kings.contains(spot) && held == null -> { + controller.submit(TakeKing(spot)) + null + } + + held != null && partners.contains(spot) -> { + controller.submit(TakePair(held!!, spot)) + null + } + + held == spot -> null + pairable.contains(spot) -> spot + else -> null + } + } + + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = when { + current.isWon -> "Pyramid cleared, in ${current.moves} moves." + finished -> "Blocked — nothing pairs to thirteen." + held != null -> "Tap the card that makes it thirteen." + else -> "${current.pyramid.count { it != null }} of $PYRAMID_SIZE left." + }, + fontWeight = FontWeight.Bold, + ) + + PyramidView( + state = current, + held = held, + kings = kings, + partners = partners, + pairable = pairable, + onTap = ::tap, + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TableSlot( + highlighted = false, + width = PYRAMID_CARD, + onClick = { + if (canDraw) { + controller.submit(DrawCard) + } else if (canRedeal) { + controller.submit(RecycleWaste) + } + }, + ) { + if (current.stock.isNotEmpty()) { + CardBackView(width = PYRAMID_CARD) + } else { + EmptySpace(label = if (canRedeal) "↻" else "", width = PYRAMID_CARD) + } + } + val wasteSpot = PyramidSpot(PyramidZone.WASTE) + TableSlot( + highlighted = held == wasteSpot || + partners.contains(wasteSpot) || + kings.contains(wasteSpot), + width = PYRAMID_CARD, + onClick = { tap(wasteSpot) }, + ) { + current.waste.lastOrNull() + ?.let { PlayingCardView(card = it, width = PYRAMID_CARD) } + ?: EmptySpace(width = PYRAMID_CARD) + } + Text( + text = "${current.stock.size} in hand, " + + "${current.options.redeals - current.redealsUsed} turns left", + style = MaterialTheme.typography.bodySmall, + ) + } + + if (finished) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onRestart) { Text("Deal again") } + TextButton(onClick = onExit) { Text("Leave") } + } + } + + current.log.takeLast(2).forEach { + Text(it, style = MaterialTheme.typography.bodySmall) + } + } + + abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) { + PatienceEndDialog("The pyramid is left as it stands.", onExit) { confirmingEnd = false } + } + } +} + +@Composable +private fun PyramidView( + state: PyramidState, + held: PyramidSpot?, + kings: Set, + partners: Set, + pairable: Set, + onTap: (PyramidSpot) -> Unit, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(PYRAMID_CARD_HEIGHT + ROW_STEP * (ROWS - 1)), + ) { + // Each row is half a card narrower on each side than the one below, so + // every card sits in the notch between the two under it. + for (row in 0 until ROWS) { + val cardsInRow = row + 1 + for (column in 0 until cardsInRow) { + val index = rowStart(row) + column + val card = state.pyramid[index] ?: continue + val spot = PyramidSpot(PyramidZone.PYRAMID, index) + val playable = pairable.contains(spot) || kings.contains(spot) + Box( + modifier = Modifier.offset( + // Centred on the widest row, which is seven wide. + x = PYRAMID_CARD * (ROWS - cardsInRow) / 2f + PYRAMID_CARD * column, + y = ROW_STEP * row, + ), + ) { + TableSlot( + highlighted = held == spot || partners.contains(spot), + width = PYRAMID_CARD, + onClick = { if (playable) onTap(spot) }, + ) { + PlayingCardView( + card = card, + width = PYRAMID_CARD, + enabled = playable, + caption = valueOf(card).toString(), + ) + } + } + } + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/SpiderScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/SpiderScreen.kt new file mode 100644 index 0000000..2abab04 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/SpiderScreen.kt @@ -0,0 +1,191 @@ +package org.prolibertate.games.ui.game + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.prolibertate.games.game.spider.DealRow +import org.prolibertate.games.game.spider.MoveRun +import org.prolibertate.games.game.spider.RUNS_TO_WIN +import org.prolibertate.games.game.spider.SpiderMove +import org.prolibertate.games.game.spider.SpiderState +import org.prolibertate.games.net.MatchController +import org.prolibertate.games.ui.ScreenScaffold +import org.prolibertate.games.ui.common.CardBackView +import org.prolibertate.games.ui.common.PlayingCardView + +private const val TITLE = "Spider" + +/** + * The Spider table. + * + * Ten columns of a hundred and four cards, so it scrolls sideways and the cards + * are the same third-overlapped stacks as everywhere else. What is worth showing + * above them is the two numbers that decide the game: how many runs have gone + * away, and how many rows are still in the stock waiting to bury everything. + */ +@Composable +fun SpiderScreen( + controller: MatchController, + onRestart: () -> Unit, + onExit: () -> Unit, +) { + val state by controller.state.collectAsState() + val legal by controller.legalMoves.collectAsState() + val abandoned by controller.abandoned.collectAsState() + + var held by remember { mutableStateOf?>(null) } + var confirmingEnd by remember { mutableStateOf(false) } + + val endGameAction: @Composable () -> Unit = { + TextButton(onClick = { confirmingEnd = true }) { Text("End game") } + } + + val current = state + if (current == null) { + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") } + abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) { + PatienceEndDialog("The deal is left as it stands.", onExit) { confirmingEnd = false } + } + } + return + } + + val runs = legal.filterIsInstance() + val canDeal = legal.contains(DealRow) + val finished = legal.isEmpty() + val targets = held?.let { (from, count) -> + runs.filter { it.from == from && it.count == count }.map { it.to }.toSet() + }.orEmpty() + val sources = runs.map { it.from to it.count }.toSet() + + fun take(column: Int, count: Int) { + val inHand = held + held = when { + inHand != null && targets.contains(column) -> { + controller.submit(MoveRun(inHand.first, column, inHand.second)) + null + } + + inHand?.first == column && inHand.second == count -> null + sources.contains(column to count) -> column to count + else -> null + } + } + + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = when { + current.isWon -> "All eight runs, in ${current.moves} moves." + finished -> "Blocked — nothing left to try." + held != null -> "Tap the column it goes to." + else -> "${current.completed} of $RUNS_TO_WIN runs away." + }, + fontWeight = FontWeight.Bold, + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TableSlot( + highlighted = false, + onClick = { if (canDeal) controller.submit(DealRow) }, + ) { + if (current.stock.isNotEmpty()) { + CardBackView(width = TABLE_CARD_WIDTH) + } else { + EmptySpace() + } + } + Text( + text = "${current.stock.size / 10} rows left" + + if (!canDeal && current.stock.isNotEmpty()) " — fill every column first" else "", + style = MaterialTheme.typography.bodySmall, + ) + } + + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.Top, + ) { + current.tableau.forEachIndexed { index, pile -> + val total = pile.faceDown.size + pile.faceUp.size + if (total == 0) { + TableSlot( + highlighted = targets.contains(index), + onClick = { take(index, 1) }, + ) { EmptySpace() } + } else { + Box( + modifier = Modifier + .width(TABLE_CARD_WIDTH) + .height(TABLE_CARD_HEIGHT + TABLE_STACK_STEP * (total - 1)), + ) { + pile.faceDown.forEachIndexed { depth, _ -> + CardBackView( + width = TABLE_CARD_WIDTH, + modifier = Modifier.offset(y = TABLE_STACK_STEP * depth), + ) + } + pile.faceUp.forEachIndexed { depth, card -> + val count = pile.faceUp.size - depth + val row = pile.faceDown.size + depth + TableSlot( + modifier = Modifier.offset(y = TABLE_STACK_STEP * row), + highlighted = held == (index to count) || + (depth == pile.faceUp.lastIndex && targets.contains(index)), + onClick = { take(index, count) }, + ) { PlayingCardView(card = card, width = TABLE_CARD_WIDTH) } + } + } + } + } + } + + if (finished) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onRestart) { Text("Deal again") } + TextButton(onClick = onExit) { Text("Leave") } + } + } + + current.log.takeLast(2).forEach { + Text(it, style = MaterialTheme.typography.bodySmall) + } + } + + abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) { + PatienceEndDialog("The deal is left as it stands.", onExit) { confirmingEnd = false } + } + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt index 45ed393..2c23ca4 100644 --- a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt @@ -75,6 +75,9 @@ class CatalogTest { GameCatalog.PEG_SOLITAIRE, GameCatalog.YAHTZEE, GameCatalog.KLONDIKE, + GameCatalog.FREECELL, + GameCatalog.SPIDER, + GameCatalog.PYRAMID, GameCatalog.WIZARD, GameCatalog.CHESS, GameCatalog.TAYU,