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