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..306e0b1 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" @@ -144,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/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/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/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, ) { 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, 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..c4d2d48 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/solitaire/SolitaireRulesTest.kt @@ -0,0 +1,368 @@ +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 `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( + "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), + ), + ) + } + + // ---- 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. + } + } +}