Merge pull request #23: promote to main
Seven new games, single-player support and a menu tree. Verified on dev by ci.yml through run 109 and published as v1.2.112-dev from this tree.
This commit is contained in:
@@ -1,22 +1,39 @@
|
||||
package org.prolibertate.games.game
|
||||
|
||||
/**
|
||||
* The catalogue the main menu is built from.
|
||||
* The menus the main screen is built from.
|
||||
*
|
||||
* Every game the app intends to ship appears here, whether or not its rules
|
||||
* engine exists yet. [GameDescriptor.available] is what the menu keys off to
|
||||
* decide between "play" and "coming soon", so adding a game is a matter of
|
||||
* flipping that flag once its engine and screen land.
|
||||
* A tree rather than a list, because "trick-taking" is a kind of card game
|
||||
* rather than a rival to it. [parent] is read from a `when` rather than passed
|
||||
* to the constructor: an enum entry cannot refer to another entry while the
|
||||
* class is still initialising, and a lookup afterwards is free.
|
||||
*/
|
||||
enum class GameCategory(val label: String) {
|
||||
CARD("Playing Card Games"),
|
||||
BOARD("Board Games"),
|
||||
enum class GameMenu(val label: String, val blurb: String) {
|
||||
SOLITAIRE("Solitaire", "Played alone, against the deal or the board."),
|
||||
CARDS("Card Games", "Anything played with a pack."),
|
||||
TRICK_TAKING("Trick-taking", "Lead, follow, and count what you took."),
|
||||
BOARD("Board Games", "Boards, pieces and dice."),
|
||||
;
|
||||
|
||||
val parent: GameMenu? get() = when (this) {
|
||||
TRICK_TAKING -> CARDS
|
||||
else -> null
|
||||
}
|
||||
|
||||
val children: List<GameMenu> get() = entries.filter { it.parent == this }
|
||||
|
||||
val isTopLevel: Boolean get() = parent == null
|
||||
}
|
||||
|
||||
data class GameDescriptor(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val category: GameCategory,
|
||||
/**
|
||||
* Every menu this game appears under. A patience is both solitaire and a
|
||||
* card game, and Euchre is both a card game and a trick-taking one — so
|
||||
* this is a set, and the same game is listed in each place it belongs.
|
||||
*/
|
||||
val menus: Set<GameMenu>,
|
||||
val minPlayers: Int,
|
||||
val maxPlayers: Int,
|
||||
/** True when players are grouped into partnerships rather than playing solo. */
|
||||
@@ -25,6 +42,14 @@ data class GameDescriptor(
|
||||
val available: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* The catalogue the menus are built from.
|
||||
*
|
||||
* Every game the app intends to ship appears here, whether or not its rules
|
||||
* engine exists yet. [GameDescriptor.available] is what the menu keys off to
|
||||
* decide between "play" and "coming soon", so adding a game is a matter of
|
||||
* flipping that flag once its engine and screen land.
|
||||
*/
|
||||
object GameCatalog {
|
||||
|
||||
const val EUCHRE = "euchre"
|
||||
@@ -34,6 +59,15 @@ object GameCatalog {
|
||||
const val WIZARD = "wizard"
|
||||
const val CRAZY_EIGHTS = "crazy8s"
|
||||
const val CRIBBAGE = "cribbage"
|
||||
|
||||
const val HEARTS = "hearts"
|
||||
const val PEG_SOLITAIRE = "pegsolitaire"
|
||||
|
||||
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"
|
||||
@@ -47,7 +81,7 @@ object GameCatalog {
|
||||
GameDescriptor(
|
||||
id = EUCHRE,
|
||||
title = "Euchre",
|
||||
category = GameCategory.CARD,
|
||||
menus = setOf(GameMenu.CARDS, GameMenu.TRICK_TAKING),
|
||||
minPlayers = 4,
|
||||
maxPlayers = 4,
|
||||
teamBased = true,
|
||||
@@ -57,7 +91,7 @@ object GameCatalog {
|
||||
GameDescriptor(
|
||||
id = KAISER,
|
||||
title = "Kaiser",
|
||||
category = GameCategory.CARD,
|
||||
menus = setOf(GameMenu.CARDS, GameMenu.TRICK_TAKING),
|
||||
minPlayers = 4,
|
||||
maxPlayers = 4,
|
||||
teamBased = true,
|
||||
@@ -67,7 +101,7 @@ object GameCatalog {
|
||||
GameDescriptor(
|
||||
id = PRESIDENT,
|
||||
title = "President",
|
||||
category = GameCategory.CARD,
|
||||
menus = setOf(GameMenu.CARDS),
|
||||
minPlayers = 3,
|
||||
maxPlayers = 7,
|
||||
teamBased = false,
|
||||
@@ -77,7 +111,7 @@ object GameCatalog {
|
||||
GameDescriptor(
|
||||
id = GOLF,
|
||||
title = "Golf",
|
||||
category = GameCategory.CARD,
|
||||
menus = setOf(GameMenu.CARDS),
|
||||
minPlayers = 2,
|
||||
maxPlayers = 6,
|
||||
teamBased = false,
|
||||
@@ -87,7 +121,7 @@ object GameCatalog {
|
||||
GameDescriptor(
|
||||
id = WIZARD,
|
||||
title = "Wizard",
|
||||
category = GameCategory.CARD,
|
||||
menus = setOf(GameMenu.CARDS, GameMenu.TRICK_TAKING),
|
||||
minPlayers = 3,
|
||||
maxPlayers = 6,
|
||||
teamBased = false,
|
||||
@@ -97,7 +131,7 @@ object GameCatalog {
|
||||
GameDescriptor(
|
||||
id = CRAZY_EIGHTS,
|
||||
title = "Crazy 8s",
|
||||
category = GameCategory.CARD,
|
||||
menus = setOf(GameMenu.CARDS),
|
||||
minPlayers = 2,
|
||||
maxPlayers = 6,
|
||||
teamBased = false,
|
||||
@@ -107,7 +141,7 @@ object GameCatalog {
|
||||
GameDescriptor(
|
||||
id = CRIBBAGE,
|
||||
title = "Cribbage",
|
||||
category = GameCategory.CARD,
|
||||
menus = setOf(GameMenu.CARDS),
|
||||
minPlayers = 2,
|
||||
maxPlayers = 4,
|
||||
// Only at four, where the two pairs share a board between them.
|
||||
@@ -116,10 +150,65 @@ object GameCatalog {
|
||||
"twice round the board to 121.",
|
||||
available = true,
|
||||
),
|
||||
GameDescriptor(
|
||||
id = HEARTS,
|
||||
title = "Hearts",
|
||||
menus = setOf(GameMenu.CARDS, GameMenu.TRICK_TAKING),
|
||||
minPlayers = 4,
|
||||
maxPlayers = 4,
|
||||
teamBased = false,
|
||||
blurb = "Take no trick worth taking. Every heart costs a point, the queen " +
|
||||
"of spades costs thirteen, and taking all of them costs everybody else.",
|
||||
available = true,
|
||||
),
|
||||
GameDescriptor(
|
||||
id = KLONDIKE,
|
||||
title = "Klondike",
|
||||
menus = setOf(GameMenu.SOLITAIRE, GameMenu.CARDS),
|
||||
minPlayers = 1,
|
||||
maxPlayers = 1,
|
||||
teamBased = false,
|
||||
blurb = "Patience, as everyone means it. Seven columns down to the aces, " +
|
||||
"and a pack that decides most of it before you start.",
|
||||
available = true,
|
||||
),
|
||||
GameDescriptor(
|
||||
id = FREECELL,
|
||||
title = "FreeCell",
|
||||
menus = setOf(GameMenu.SOLITAIRE, GameMenu.CARDS),
|
||||
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",
|
||||
menus = setOf(GameMenu.SOLITAIRE, GameMenu.CARDS),
|
||||
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",
|
||||
menus = setOf(GameMenu.SOLITAIRE, GameMenu.CARDS),
|
||||
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",
|
||||
category = GameCategory.BOARD,
|
||||
menus = setOf(GameMenu.BOARD, GameMenu.CARDS),
|
||||
minPlayers = 2,
|
||||
maxPlayers = 12,
|
||||
teamBased = true,
|
||||
@@ -129,7 +218,7 @@ object GameCatalog {
|
||||
GameDescriptor(
|
||||
id = MASTERMIND,
|
||||
title = "Mastermind",
|
||||
category = GameCategory.BOARD,
|
||||
menus = setOf(GameMenu.BOARD),
|
||||
minPlayers = 2,
|
||||
maxPlayers = 2,
|
||||
teamBased = false,
|
||||
@@ -140,7 +229,7 @@ object GameCatalog {
|
||||
GameDescriptor(
|
||||
id = BACKGAMMON,
|
||||
title = "Backgammon",
|
||||
category = GameCategory.BOARD,
|
||||
menus = setOf(GameMenu.BOARD),
|
||||
minPlayers = 2,
|
||||
maxPlayers = 2,
|
||||
teamBased = false,
|
||||
@@ -150,7 +239,7 @@ object GameCatalog {
|
||||
GameDescriptor(
|
||||
id = CHESS,
|
||||
title = "Chess",
|
||||
category = GameCategory.BOARD,
|
||||
menus = setOf(GameMenu.BOARD),
|
||||
minPlayers = 2,
|
||||
maxPlayers = 2,
|
||||
teamBased = false,
|
||||
@@ -160,7 +249,7 @@ object GameCatalog {
|
||||
GameDescriptor(
|
||||
id = CHECKERS,
|
||||
title = "Checkers",
|
||||
category = GameCategory.BOARD,
|
||||
menus = setOf(GameMenu.BOARD),
|
||||
minPlayers = 2,
|
||||
maxPlayers = 2,
|
||||
teamBased = false,
|
||||
@@ -170,17 +259,41 @@ object GameCatalog {
|
||||
GameDescriptor(
|
||||
id = MORRIS,
|
||||
title = "Nine Men's Morris",
|
||||
category = GameCategory.BOARD,
|
||||
menus = setOf(GameMenu.BOARD),
|
||||
minPlayers = 2,
|
||||
maxPlayers = 2,
|
||||
teamBased = false,
|
||||
blurb = "Three in a row takes a piece. Reduce them to two and the game is yours.",
|
||||
available = true,
|
||||
),
|
||||
GameDescriptor(
|
||||
id = PEG_SOLITAIRE,
|
||||
title = "Peg Solitaire",
|
||||
menus = setOf(GameMenu.SOLITAIRE, GameMenu.BOARD),
|
||||
// The first game here played alone. Everything downstream reads the
|
||||
// seat count from these two numbers, so one seat is all it takes.
|
||||
minPlayers = 1,
|
||||
maxPlayers = 1,
|
||||
teamBased = false,
|
||||
blurb = "The tee game from the table, and four larger boards. Jump a peg, " +
|
||||
"take the one you passed, and try to finish with one standing.",
|
||||
available = true,
|
||||
),
|
||||
GameDescriptor(
|
||||
id = YAHTZEE,
|
||||
title = "Yahtzee",
|
||||
menus = setOf(GameMenu.BOARD),
|
||||
minPlayers = 1,
|
||||
maxPlayers = 6,
|
||||
teamBased = false,
|
||||
blurb = "Five dice, three throws, thirteen boxes to fill. Every box can be " +
|
||||
"written in once, so the hard part is what to give up on.",
|
||||
available = true,
|
||||
),
|
||||
GameDescriptor(
|
||||
id = PIRATES,
|
||||
title = "Pirates and Bulgars",
|
||||
category = GameCategory.BOARD,
|
||||
menus = setOf(GameMenu.BOARD),
|
||||
minPlayers = 2,
|
||||
maxPlayers = 2,
|
||||
teamBased = false,
|
||||
@@ -191,7 +304,7 @@ object GameCatalog {
|
||||
GameDescriptor(
|
||||
id = TAYU,
|
||||
title = "Ta Yü",
|
||||
category = GameCategory.BOARD,
|
||||
menus = setOf(GameMenu.BOARD),
|
||||
minPlayers = 2,
|
||||
maxPlayers = 4,
|
||||
// Two play solo, four play in partnerships across the two axes.
|
||||
@@ -213,13 +326,24 @@ object GameCatalog {
|
||||
* production build lists only what can actually be played, so nobody
|
||||
* downloads a release and taps into a dead end.
|
||||
*/
|
||||
fun byCategory(
|
||||
category: GameCategory,
|
||||
fun inMenu(
|
||||
menu: GameMenu,
|
||||
includeComingSoon: Boolean = true,
|
||||
): List<GameDescriptor> = all.filter {
|
||||
it.category == category && (includeComingSoon || it.available)
|
||||
it.menus.contains(menu) && (includeComingSoon || it.available)
|
||||
}
|
||||
|
||||
/** Everything currently playable, regardless of category. */
|
||||
/**
|
||||
* Whether a menu is worth showing at all.
|
||||
*
|
||||
* A section with nothing in it and no populated child under it is a heading
|
||||
* over an empty room, which is what a production build would get if every
|
||||
* game in a menu were still unfinished.
|
||||
*/
|
||||
fun hasAnything(menu: GameMenu, includeComingSoon: Boolean = true): Boolean =
|
||||
inMenu(menu, includeComingSoon).isNotEmpty() ||
|
||||
menu.children.any { inMenu(it, includeComingSoon).isNotEmpty() }
|
||||
|
||||
/** Everything currently playable, whatever menu it is under. */
|
||||
val playable: List<GameDescriptor> get() = all.filter { it.available }
|
||||
}
|
||||
|
||||
@@ -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,108 @@
|
||||
package org.prolibertate.games.game.hearts
|
||||
|
||||
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.GameAi
|
||||
|
||||
/**
|
||||
* A cautious Hearts player.
|
||||
*
|
||||
* It plays to avoid points rather than to shoot the moon: it sheds the queen of
|
||||
* spades and the guards above her at the first opportunity, ducks under whoever
|
||||
* is winning a trick when it can, and takes a trick as cheaply as possible when
|
||||
* it cannot get out of the way. That is enough to make it an opponent worth
|
||||
* beating without pretending to read the table.
|
||||
*/
|
||||
object HeartsAi : GameAi<HeartsState, HeartsMove> {
|
||||
|
||||
override fun chooseMove(state: HeartsState, seat: Int, legal: List<HeartsMove>): HeartsMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
return when (state.phase) {
|
||||
HeartsPhase.PASSING -> choosePass(state, seat, legal)
|
||||
else -> choosePlay(state, seat, legal)
|
||||
}
|
||||
}
|
||||
|
||||
private fun choosePass(state: HeartsState, seat: Int, legal: List<HeartsMove>): HeartsMove {
|
||||
val hand = state.hands[seat]
|
||||
val wanted = hand.sortedByDescending { passDanger(it, hand) }.take(PASS_SIZE).toSet()
|
||||
// Prefer the exact bundle the heuristic asked for, but fall back rather
|
||||
// than fail: the caller's list is what the host will validate against.
|
||||
return legal.firstOrNull { it is PassCards && it.cards.toSet() == wanted } ?: legal.first()
|
||||
}
|
||||
|
||||
/**
|
||||
* How much a card wants to be somebody else's problem.
|
||||
*
|
||||
* The queen is the whole game, and the ace and king of spades are only
|
||||
* dangerous because they can be forced to capture her — so a long spade
|
||||
* holding, which gives room to duck, makes all three far less urgent.
|
||||
*/
|
||||
private fun passDanger(card: Card, hand: List<Card>): Int {
|
||||
val spades = hand.count { it.suit == Suit.SPADES }
|
||||
val guarded = spades >= 5
|
||||
return when {
|
||||
card == QUEEN_OF_SPADES -> if (guarded) 45 else 100
|
||||
card.suit == Suit.SPADES && card.rank.order > Rank.QUEEN.order ->
|
||||
if (guarded) 30 else 90
|
||||
|
||||
card.suit == Suit.HEARTS -> 40 + card.rank.order
|
||||
else -> card.rank.order
|
||||
}
|
||||
}
|
||||
|
||||
private fun choosePlay(state: HeartsState, seat: Int, legal: List<HeartsMove>): HeartsMove {
|
||||
val cards = legal.filterIsInstance<PlayCard>().map { it.card }
|
||||
if (cards.isEmpty()) return legal.first()
|
||||
val chosen = when {
|
||||
state.trick.isEmpty() -> lead(cards)
|
||||
else -> follow(state, cards)
|
||||
}
|
||||
return PlayCard(chosen)
|
||||
}
|
||||
|
||||
/**
|
||||
* Leads low, and keeps off spades while the queen is still unaccounted for —
|
||||
* leading a spade under her is how a seat ends up holding her.
|
||||
*/
|
||||
private fun lead(cards: List<Card>): Card {
|
||||
val offSpades = cards.filter { it.suit != Suit.SPADES }
|
||||
val pool = if (offSpades.isNotEmpty()) offSpades else cards
|
||||
return pool.minBy { it.rank.order }
|
||||
}
|
||||
|
||||
private fun follow(state: HeartsState, cards: List<Card>): Card {
|
||||
val led = state.trick.first().card.suit
|
||||
val best = state.trick.maxOf { trickStrength(it.card, led) }
|
||||
val pot = pointsIn(state.trick.map { it.card })
|
||||
val last = state.trick.size == state.options.playerCount - 1
|
||||
|
||||
val ducking = cards.filter { trickStrength(it, led) < best }
|
||||
if (ducking.isNotEmpty()) {
|
||||
// Safe under the current winner, so shed the most expensive card
|
||||
// that still loses.
|
||||
return ducking.maxBy { it.rank.order + pointsOf(it) * 2 }
|
||||
}
|
||||
|
||||
val void = cards.none { it.suit == led }
|
||||
if (void) return discard(cards)
|
||||
|
||||
// Every card left would take the trick. Playing last with nothing in the
|
||||
// pot means it costs nothing, so there is no reason to waste a high card
|
||||
// on it; otherwise take it as cheaply as possible.
|
||||
return if (last && pot == 0) cards.maxBy { it.rank.order } else cards.minBy { it.rank.order }
|
||||
}
|
||||
|
||||
/** Void in the led suit and free to throw anything, so throw the worst of it. */
|
||||
private fun discard(cards: List<Card>): Card {
|
||||
cards.firstOrNull { it == QUEEN_OF_SPADES }?.let { return it }
|
||||
cards.filter { it.suit == Suit.SPADES && it.rank.order > Rank.QUEEN.order }
|
||||
.maxByOrNull { it.rank.order }
|
||||
?.let { return it }
|
||||
cards.filter { it.suit == Suit.HEARTS }
|
||||
.maxByOrNull { it.rank.order }
|
||||
?.let { return it }
|
||||
return cards.maxBy { it.rank.order }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package org.prolibertate.games.game.hearts
|
||||
|
||||
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
|
||||
|
||||
@Serializable
|
||||
data class HeartsOptions(
|
||||
/**
|
||||
* Four hands of thirteen, which is the game as it is normally played. Three
|
||||
* and five handed Hearts exist, but they need cards struck from the deck to
|
||||
* divide evenly, and a seat that holds a different number of cards from its
|
||||
* neighbours changes which discards are safe — a different game to bid and
|
||||
* play, not a seat count.
|
||||
*/
|
||||
val playerCount: Int = 4,
|
||||
/** The round in which somebody crosses this ends the game. */
|
||||
val targetScore: Int = 100,
|
||||
/**
|
||||
* Taking all twenty-six points scores nothing and gives everyone else the
|
||||
* lot. Switching this off scores a moon like any other hand, which is to say
|
||||
* it becomes the worst result on the table rather than the best.
|
||||
*/
|
||||
val allowShootTheMoon: Boolean = true,
|
||||
) {
|
||||
init {
|
||||
require(playerCount == 4) { "Hearts is dealt four handed" }
|
||||
require(targetScore > 0) { "targetScore must be positive" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the three passed cards go. The cycle repeats every fourth round, and
|
||||
* the hold round is what stops a seat from planning around a known direction
|
||||
* forever.
|
||||
*/
|
||||
@Serializable
|
||||
enum class PassDirection(val label: String) {
|
||||
LEFT("left"),
|
||||
RIGHT("right"),
|
||||
ACROSS("across"),
|
||||
HOLD("nobody"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
/** Rounds run left, right, across, hold, and back to left. */
|
||||
fun forRound(round: Int): PassDirection = entries[round % entries.size]
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class HeartsPhase { PASSING, PLAYING, ROUND_OVER, GAME_OVER }
|
||||
|
||||
@Serializable
|
||||
data class PlayedCard(val seat: Int, val card: Card)
|
||||
|
||||
@Serializable
|
||||
data class HeartsState(
|
||||
val options: HeartsOptions,
|
||||
val seed: Long,
|
||||
/** Zero-based. Only used to pick the pass direction and to number the log. */
|
||||
val round: Int,
|
||||
val phase: HeartsPhase,
|
||||
val hands: List<List<Card>>,
|
||||
/** Survives redaction so a client can draw the backs of other hands. */
|
||||
val handCounts: List<Int>,
|
||||
/** What each seat has chosen to pass, before the swap happens. */
|
||||
val passSelections: List<List<Card>>,
|
||||
val turn: Int,
|
||||
val leader: Int,
|
||||
/**
|
||||
* Which trick of the round is being played, counting from zero. Carried
|
||||
* rather than derived because the first trick is the one that bans point
|
||||
* cards, and a rule that important should not depend on reading it back out
|
||||
* of how many cards happen to have been taken.
|
||||
*/
|
||||
val trickNumber: Int,
|
||||
val trick: List<PlayedCard>,
|
||||
/** Held on the table so a finished trick can be read before it is swept. */
|
||||
val completedTrick: List<PlayedCard> = emptyList(),
|
||||
/**
|
||||
* Every card each seat has taken in tricks this round. Kept whole rather
|
||||
* than as a running count because shooting the moon is decided on the
|
||||
* twenty-six penalty cards together, not on a total that cannot tell an
|
||||
* unbroken moon from thirteen hearts and a lucky queen elsewhere.
|
||||
*/
|
||||
val taken: List<List<Card>>,
|
||||
val heartsBroken: Boolean,
|
||||
val scores: List<Int>,
|
||||
val roundScores: List<Int>,
|
||||
val log: List<String>,
|
||||
) {
|
||||
val passDirection: PassDirection get() = PassDirection.forRound(round)
|
||||
|
||||
/** Thirteen, four handed: every card dealt is a card played. */
|
||||
val tricksPerRound: Int get() = DECK_SIZE / options.playerCount
|
||||
}
|
||||
|
||||
/** An ordinary pack, which Hearts uses whole. */
|
||||
const val DECK_SIZE: Int = 52
|
||||
|
||||
@Serializable
|
||||
sealed interface HeartsMove
|
||||
|
||||
/** The three cards this seat is giving away. Order does not matter. */
|
||||
@Serializable
|
||||
@SerialName("pass")
|
||||
data class PassCards(val cards: List<Card>) : HeartsMove
|
||||
|
||||
@Serializable
|
||||
@SerialName("play")
|
||||
data class PlayCard(val card: Card) : HeartsMove
|
||||
|
||||
/** How many cards change hands in a pass. */
|
||||
const val PASS_SIZE: Int = 3
|
||||
|
||||
val QUEEN_OF_SPADES: Card = Card(Rank.QUEEN, Suit.SPADES)
|
||||
|
||||
/** The two of clubs always leads the first trick of a round. */
|
||||
val TWO_OF_CLUBS: Card = Card(Rank.TWO, Suit.CLUBS)
|
||||
|
||||
/** Every heart costs a point; the queen of spades costs thirteen. */
|
||||
fun pointsOf(card: Card): Int = when {
|
||||
card == QUEEN_OF_SPADES -> 13
|
||||
card.suit == Suit.HEARTS -> 1
|
||||
else -> 0
|
||||
}
|
||||
|
||||
/** The whole twenty-six, which is what a moon has to consist of. */
|
||||
const val ALL_POINTS: Int = 26
|
||||
|
||||
fun pointsIn(cards: List<Card>): Int = cards.sumOf { pointsOf(it) }
|
||||
|
||||
/** Which seat receives what [from] passes, given the round's direction. */
|
||||
fun passTarget(from: Int, playerCount: Int, direction: PassDirection): Int = when (direction) {
|
||||
PassDirection.LEFT -> (from + 1) % playerCount
|
||||
PassDirection.RIGHT -> (from + playerCount - 1) % playerCount
|
||||
PassDirection.ACROSS -> (from + playerCount / 2) % playerCount
|
||||
PassDirection.HOLD -> from
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranks a card within a trick. Nothing trumps in Hearts, so a card that did not
|
||||
* follow the led suit cannot win however high it is.
|
||||
*/
|
||||
fun trickStrength(card: Card, ledSuit: Suit): Int =
|
||||
if (card.suit == ledSuit) card.rank.order else -1
|
||||
|
||||
/**
|
||||
* Turns a round's takings into the points each seat adds to its score.
|
||||
*
|
||||
* A seat that took all twenty-six has shot the moon: it scores nothing and
|
||||
* every other seat takes the lot. That is the one case where a round's scores
|
||||
* do not sum to twenty-six.
|
||||
*/
|
||||
fun scoreRound(taken: List<List<Card>>, allowShootTheMoon: Boolean): List<Int> {
|
||||
val raw = taken.map { pointsIn(it) }
|
||||
if (!allowShootTheMoon) return raw
|
||||
val shooter = raw.indexOfFirst { it == ALL_POINTS }
|
||||
if (shooter < 0) return raw
|
||||
return raw.indices.map { if (it == shooter) 0 else ALL_POINTS }
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package org.prolibertate.games.game.hearts
|
||||
|
||||
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.Suit
|
||||
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
|
||||
|
||||
/**
|
||||
* Hearts as a pure state machine. See RULES-hearts.md.
|
||||
*/
|
||||
object HeartsRules : GameRules<HeartsState, HeartsMove> {
|
||||
|
||||
override val gameId: String = GameCatalog.HEARTS
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
override fun initialState(config: TableConfig): HeartsState {
|
||||
val options = json.decodeFromString<HeartsOptions>(config.optionsJson)
|
||||
require(config.seats.size == options.playerCount) {
|
||||
"Seat count ${config.seats.size} does not match ${options.playerCount} players"
|
||||
}
|
||||
return deal(options, config.seed, 0, List(options.playerCount) { 0 }, listOf("Round 1."))
|
||||
}
|
||||
|
||||
private fun deal(
|
||||
options: HeartsOptions,
|
||||
seed: Long,
|
||||
round: Int,
|
||||
scores: List<Int>,
|
||||
log: List<String>,
|
||||
): HeartsState {
|
||||
// A fresh generator per round, keyed on the round, so the whole game is
|
||||
// reproducible from the one seed the host shipped with the table.
|
||||
val random = Random(seed + round)
|
||||
val deck = Decks.standard52().shuffledWith(random)
|
||||
val perHand = deck.size / options.playerCount
|
||||
val hands = (0 until options.playerCount).map { seat ->
|
||||
deck.subList(seat * perHand, (seat + 1) * perHand).sortedWith(handOrder)
|
||||
}
|
||||
val direction = PassDirection.forRound(round)
|
||||
val passing = direction != PassDirection.HOLD
|
||||
// A hold round has nothing to swap, so it opens straight onto the lead.
|
||||
val opener = if (passing) 0 else holderOfTwoOfClubs(hands)
|
||||
return HeartsState(
|
||||
options = options,
|
||||
seed = seed,
|
||||
round = round,
|
||||
phase = if (passing) HeartsPhase.PASSING else HeartsPhase.PLAYING,
|
||||
hands = hands,
|
||||
handCounts = hands.map { it.size },
|
||||
passSelections = List(options.playerCount) { emptyList() },
|
||||
turn = opener,
|
||||
leader = opener,
|
||||
trickNumber = 0,
|
||||
trick = emptyList(),
|
||||
completedTrick = emptyList(),
|
||||
taken = List(options.playerCount) { emptyList() },
|
||||
heartsBroken = false,
|
||||
scores = scores,
|
||||
roundScores = List(options.playerCount) { 0 },
|
||||
log = log + if (passing) "Passing ${direction.label}." else "No passing this round.",
|
||||
)
|
||||
}
|
||||
|
||||
/** Sorted by suit then rank, which is how a hand is held rather than dealt. */
|
||||
private val handOrder = compareBy<Card>({ it.suit.ordinal }, { it.rank.order })
|
||||
|
||||
private fun holderOfTwoOfClubs(hands: List<List<Card>>): Int =
|
||||
hands.indexOfFirst { it.contains(TWO_OF_CLUBS) }
|
||||
|
||||
override fun currentSeat(state: HeartsState): Int? = when (state.phase) {
|
||||
// Passing is simultaneous at the table, but the engine hands out one
|
||||
// seat at a time. The lowest seat that has not chosen goes next, and
|
||||
// the swap happens once the last one has.
|
||||
HeartsPhase.PASSING -> state.passSelections.indexOfFirst { it.isEmpty() }.takeIf { it >= 0 }
|
||||
HeartsPhase.PLAYING -> state.turn
|
||||
HeartsPhase.ROUND_OVER, HeartsPhase.GAME_OVER -> null
|
||||
}
|
||||
|
||||
override fun legalMoves(state: HeartsState, seat: Int): List<HeartsMove> =
|
||||
when (state.phase) {
|
||||
HeartsPhase.PASSING ->
|
||||
if (state.passSelections[seat].isNotEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
combinations(state.hands[seat], PASS_SIZE).map { PassCards(it) }
|
||||
}
|
||||
|
||||
HeartsPhase.PLAYING ->
|
||||
if (state.turn != seat) emptyList() else playableCards(state, seat).map { PlayCard(it) }
|
||||
|
||||
HeartsPhase.ROUND_OVER, HeartsPhase.GAME_OVER -> emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* The cards [seat] may legally play into the trick as it stands.
|
||||
*
|
||||
* Four restrictions, in the order they bite: the two of clubs opens the
|
||||
* round, a seat that can follow the led suit must, no point card may be
|
||||
* discarded on the first trick, and hearts cannot be led until one has been
|
||||
* played. Each of the last three lifts when a seat holds nothing else.
|
||||
*/
|
||||
fun playableCards(state: HeartsState, seat: Int): List<Card> {
|
||||
val hand = state.hands[seat]
|
||||
if (hand.isEmpty()) return emptyList()
|
||||
val firstTrick = state.trickNumber == 0
|
||||
|
||||
if (state.trick.isEmpty()) {
|
||||
if (firstTrick) return listOf(TWO_OF_CLUBS).filter { hand.contains(it) }
|
||||
val offHearts = hand.filter { it.suit != Suit.HEARTS }
|
||||
// Hearts may only be led once broken — unless that is all there is
|
||||
// left to lead, in which case the restriction cannot be honoured.
|
||||
return if (state.heartsBroken || offHearts.isEmpty()) hand else offHearts
|
||||
}
|
||||
|
||||
val led = state.trick.first().card.suit
|
||||
val following = hand.filter { it.suit == led }
|
||||
if (following.isNotEmpty()) return following
|
||||
|
||||
// Void in the led suit, so anything goes — except that the first trick
|
||||
// takes no points while the seat has a card that costs none.
|
||||
if (firstTrick) {
|
||||
val harmless = hand.filter { pointsOf(it) == 0 }
|
||||
if (harmless.isNotEmpty()) return harmless
|
||||
}
|
||||
return hand
|
||||
}
|
||||
|
||||
override fun applyMove(state: HeartsState, seat: Int, move: HeartsMove): HeartsState =
|
||||
when (move) {
|
||||
is PassCards -> applyPass(state, seat, move)
|
||||
is PlayCard -> applyPlay(state, seat, move)
|
||||
}
|
||||
|
||||
private fun applyPass(state: HeartsState, seat: Int, move: PassCards): HeartsState {
|
||||
require(state.phase == HeartsPhase.PASSING) { "Not passing" }
|
||||
require(state.passSelections[seat].isEmpty()) { "Seat $seat has already passed" }
|
||||
require(move.cards.size == PASS_SIZE) { "A pass is $PASS_SIZE cards" }
|
||||
require(move.cards.distinct().size == PASS_SIZE) { "Cannot pass the same card twice" }
|
||||
val hand = state.hands[seat]
|
||||
require(move.cards.all { hand.contains(it) }) { "Cannot pass a card not held" }
|
||||
|
||||
val selections = state.passSelections.toMutableList()
|
||||
selections[seat] = move.cards
|
||||
if (selections.any { it.isEmpty() }) {
|
||||
return state.copy(passSelections = selections)
|
||||
}
|
||||
return completePass(state.copy(passSelections = selections))
|
||||
}
|
||||
|
||||
/** Everyone has chosen, so the three-card bundles change hands at once. */
|
||||
private fun completePass(state: HeartsState): HeartsState {
|
||||
val direction = state.passDirection
|
||||
val count = state.options.playerCount
|
||||
val incoming = MutableList(count) { emptyList<Card>() }
|
||||
for (from in 0 until count) {
|
||||
incoming[passTarget(from, count, direction)] = state.passSelections[from]
|
||||
}
|
||||
val hands = state.hands.mapIndexed { seat, hand ->
|
||||
(hand - state.passSelections[seat].toSet() + incoming[seat]).sortedWith(handOrder)
|
||||
}
|
||||
val opener = holderOfTwoOfClubs(hands)
|
||||
return state.copy(
|
||||
phase = HeartsPhase.PLAYING,
|
||||
hands = hands,
|
||||
handCounts = hands.map { it.size },
|
||||
turn = opener,
|
||||
leader = opener,
|
||||
log = state.log + "Cards passed ${direction.label}.",
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyPlay(state: HeartsState, seat: Int, move: PlayCard): HeartsState {
|
||||
require(state.phase == HeartsPhase.PLAYING) { "Not playing" }
|
||||
require(state.turn == seat) { "Seat $seat is not on the clock" }
|
||||
require(playableCards(state, seat).contains(move.card)) { "${move.card} is not playable" }
|
||||
|
||||
val hands = state.hands.toMutableList()
|
||||
hands[seat] = hands[seat] - move.card
|
||||
val trick = state.trick + PlayedCard(seat, move.card)
|
||||
val broken = state.heartsBroken || move.card.suit == Suit.HEARTS
|
||||
|
||||
if (trick.size < state.options.playerCount) {
|
||||
return state.copy(
|
||||
hands = hands,
|
||||
handCounts = hands.map { it.size },
|
||||
trick = trick,
|
||||
completedTrick = emptyList(),
|
||||
turn = (seat + 1) % state.options.playerCount,
|
||||
heartsBroken = broken,
|
||||
)
|
||||
}
|
||||
|
||||
val led = trick.first().card.suit
|
||||
val winner = trick.maxBy { trickStrength(it.card, led) }.seat
|
||||
val taken = state.taken.toMutableList()
|
||||
taken[winner] = taken[winner] + trick.map { it.card }
|
||||
val swept = state.copy(
|
||||
hands = hands,
|
||||
handCounts = hands.map { it.size },
|
||||
trick = emptyList(),
|
||||
completedTrick = trick,
|
||||
taken = taken,
|
||||
heartsBroken = broken,
|
||||
turn = winner,
|
||||
leader = winner,
|
||||
trickNumber = state.trickNumber + 1,
|
||||
)
|
||||
return if (hands.all { it.isEmpty() }) finishRound(swept) else swept
|
||||
}
|
||||
|
||||
private fun finishRound(state: HeartsState): HeartsState {
|
||||
val gained = scoreRound(state.taken, state.options.allowShootTheMoon)
|
||||
val scores = state.scores.mapIndexed { seat, score -> score + gained[seat] }
|
||||
val moon = state.taken.indexOfFirst { pointsIn(it) == ALL_POINTS }
|
||||
val over = scores.any { it >= state.options.targetScore }
|
||||
val notes = buildList {
|
||||
if (moon >= 0 && state.options.allowShootTheMoon) add("Seat $moon shot the moon.")
|
||||
add("Round ${state.round + 1} scored.")
|
||||
if (over) add("Game over — lowest score wins.")
|
||||
}
|
||||
return state.copy(
|
||||
phase = if (over) HeartsPhase.GAME_OVER else HeartsPhase.ROUND_OVER,
|
||||
scores = scores,
|
||||
roundScores = gained,
|
||||
log = state.log + notes,
|
||||
)
|
||||
}
|
||||
|
||||
/** Advances from [HeartsPhase.ROUND_OVER] into the next deal. */
|
||||
fun startNextRound(state: HeartsState): HeartsState {
|
||||
check(state.phase == HeartsPhase.ROUND_OVER) { "Round is not over" }
|
||||
val next = state.round + 1
|
||||
return deal(state.options, state.seed, next, state.scores, state.log + "Round ${next + 1}.")
|
||||
}
|
||||
|
||||
override fun isFinished(state: HeartsState): Boolean = state.phase == HeartsPhase.GAME_OVER
|
||||
|
||||
override fun summary(state: HeartsState): String = when (state.phase) {
|
||||
HeartsPhase.PASSING -> "Passing ${state.passDirection.label}"
|
||||
HeartsPhase.PLAYING -> "Trick ${state.trickNumber + 1} of ${state.tricksPerRound}"
|
||||
HeartsPhase.ROUND_OVER -> "Round ${state.round + 1}: " + state.roundScores.joinToString(", ")
|
||||
HeartsPhase.GAME_OVER -> {
|
||||
val best = state.scores.min()
|
||||
"Winner: seat ${state.scores.indexOf(best)} on $best"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Other hands go, and so do the cards they have chosen to pass — a pass is
|
||||
* secret until it lands, and a client that could read the selections would
|
||||
* know exactly what is coming.
|
||||
*/
|
||||
override fun viewFor(state: HeartsState, seat: Int): HeartsState = state.copy(
|
||||
hands = state.hands.mapIndexed { index, hand -> if (index == seat) hand else emptyList() },
|
||||
handCounts = state.handCounts,
|
||||
passSelections = state.passSelections.mapIndexed { index, cards ->
|
||||
if (index == seat) cards else emptyList()
|
||||
},
|
||||
)
|
||||
|
||||
override fun encodeState(state: HeartsState): String = json.encodeToString(state)
|
||||
override fun decodeState(json: String): HeartsState = this.json.decodeFromString(json)
|
||||
override fun encodeMove(move: HeartsMove): String = json.encodeToString(move)
|
||||
override fun decodeMove(json: String): HeartsMove = this.json.decodeFromString(json)
|
||||
|
||||
/** Every distinct [size]-card selection from [cards], order ignored. */
|
||||
private fun combinations(cards: List<Card>, size: Int): List<List<Card>> {
|
||||
if (size == 0) return listOf(emptyList())
|
||||
if (cards.size < size) return emptyList()
|
||||
val result = mutableListOf<List<Card>>()
|
||||
fun walk(start: Int, chosen: List<Card>) {
|
||||
if (chosen.size == size) {
|
||||
result.add(chosen)
|
||||
return
|
||||
}
|
||||
// Stop early once too few cards remain to finish the selection.
|
||||
for (i in start..cards.size - (size - chosen.size)) {
|
||||
walk(i + 1, chosen + cards[i])
|
||||
}
|
||||
}
|
||||
walk(0, emptyList())
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.prolibertate.games.game.klondike
|
||||
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
* Plays the deal the way a person does when they are not thinking hard.
|
||||
*
|
||||
* Nothing asks it to during an ordinary game — the only seat belongs to the
|
||||
* player — but a seat has to be playable by something, and this is what a
|
||||
* "suggest a move" button would offer.
|
||||
*
|
||||
* The order matters more than the numbers. Sending a card home always wins,
|
||||
* turning one up comes next, and aimless shuffling between columns scores below
|
||||
* drawing so it is only reached when there is nothing else at all. That last
|
||||
* ordering is what stops it walking a run back and forth between two columns for
|
||||
* ever.
|
||||
*/
|
||||
object KlondikeAi : GameAi<KlondikeState, KlondikeMove> {
|
||||
|
||||
override fun chooseMove(state: KlondikeState, seat: Int, legal: List<KlondikeMove>): KlondikeMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
return legal.maxBy { worth(state, it) }
|
||||
}
|
||||
|
||||
private fun worth(state: KlondikeState, move: KlondikeMove): Int = when (move) {
|
||||
Draw -> 30
|
||||
Redeal -> 20
|
||||
is MoveCards -> when {
|
||||
move.to.kind == SpotKind.FOUNDATION -> 100
|
||||
// Taking a card back off a foundation is occasionally right and
|
||||
// usually not, so it sits below doing nothing much at all.
|
||||
move.from.kind == SpotKind.FOUNDATION -> -50
|
||||
move.from.kind == SpotKind.TABLEAU && turnsACardUp(state, move) -> 80
|
||||
move.from.kind == SpotKind.TABLEAU && emptiesAColumn(state, move) -> 60
|
||||
move.from.kind == SpotKind.WASTE -> 40
|
||||
else -> 15
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the move strips a column back to its buried cards. */
|
||||
private fun turnsACardUp(state: KlondikeState, move: MoveCards): Boolean {
|
||||
val pile = state.tableau[move.from.index]
|
||||
return move.count == pile.faceUp.size && pile.faceDown.isNotEmpty()
|
||||
}
|
||||
|
||||
private fun emptiesAColumn(state: KlondikeState, move: MoveCards): Boolean {
|
||||
val pile = state.tableau[move.from.index]
|
||||
return move.count == pile.faceUp.size && pile.faceDown.isEmpty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package org.prolibertate.games.game.klondike
|
||||
|
||||
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.buildsDownAlternating
|
||||
|
||||
const val TABLEAU_PILES: Int = 7
|
||||
const val FOUNDATIONS: Int = 4
|
||||
|
||||
/**
|
||||
* Where a card can be, and which one of them.
|
||||
*
|
||||
* A kind and an index rather than a sealed hierarchy: every spot in this game is
|
||||
* one of four things and at most one number identifies it, so a pair says
|
||||
* everything a subclass per kind would, and serialises as two plain fields.
|
||||
*/
|
||||
@Serializable
|
||||
enum class SpotKind { STOCK, WASTE, TABLEAU, FOUNDATION }
|
||||
|
||||
@Serializable
|
||||
data class Spot(val kind: SpotKind, val index: Int = 0) {
|
||||
companion object {
|
||||
val stock = Spot(SpotKind.STOCK)
|
||||
val waste = Spot(SpotKind.WASTE)
|
||||
fun tableau(index: Int) = Spot(SpotKind.TABLEAU, index)
|
||||
fun foundation(index: Int) = Spot(SpotKind.FOUNDATION, index)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A tableau pile: what is still turned down, and the run turned up on top of it.
|
||||
*
|
||||
* Kept as two lists because the split is the whole tension of the game. A pile
|
||||
* is only worth what has been turned up, and the buried part is the thing being
|
||||
* played for.
|
||||
*/
|
||||
@Serializable
|
||||
data class TableauPile(
|
||||
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 KlondikeOptions(
|
||||
/**
|
||||
* One card off the stock at a time, or three. Three is the harder game and
|
||||
* the older one; one is what most people mean by patience now.
|
||||
*/
|
||||
val drawCount: Int = 1,
|
||||
/**
|
||||
* How many times the waste may be turned back into the stock. Null is as
|
||||
* often as you like; zero is a single pass through the pack, which is the
|
||||
* gambling version.
|
||||
*/
|
||||
val redealLimit: Int? = null,
|
||||
/**
|
||||
* Only a king may be moved into an empty column. Turning this off is a
|
||||
* common kindness that makes far more deals winnable.
|
||||
*/
|
||||
val kingsOnlyInSpaces: Boolean = true,
|
||||
) {
|
||||
init {
|
||||
require(drawCount in 1..3) { "Draw one to three" }
|
||||
require(redealLimit == null || redealLimit >= 0) { "redealLimit cannot be negative" }
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class KlondikeState(
|
||||
val options: KlondikeOptions,
|
||||
val seed: Long,
|
||||
/** Face down, dealt from the end. */
|
||||
val stock: List<Card>,
|
||||
/** Face up; the last card is the one in play. */
|
||||
val waste: List<Card>,
|
||||
/** One per suit, in [Suit] order, each running ace upwards. */
|
||||
val foundations: List<List<Card>>,
|
||||
val tableau: List<TableauPile>,
|
||||
val redealsUsed: Int,
|
||||
val moves: Int,
|
||||
val log: List<String>,
|
||||
) {
|
||||
/** Every foundation full is the game won. */
|
||||
val isWon: Boolean get() = foundations.all { it.size == Rank.standard.size }
|
||||
|
||||
val canRedeal: Boolean
|
||||
get() = stock.isEmpty() &&
|
||||
waste.isNotEmpty() &&
|
||||
(options.redealLimit == null || redealsUsed < options.redealLimit)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed interface KlondikeMove
|
||||
|
||||
/** Turns the next card or three off the stock and onto the waste. */
|
||||
@Serializable
|
||||
@SerialName("draw")
|
||||
data object Draw : KlondikeMove
|
||||
|
||||
/** Turns the waste back over to make a new stock, once the stock is out. */
|
||||
@Serializable
|
||||
@SerialName("redeal")
|
||||
data object Redeal : KlondikeMove
|
||||
|
||||
/**
|
||||
* Moves [count] cards from one spot to another.
|
||||
*
|
||||
* [count] is only ever more than one when moving between tableau columns, where
|
||||
* a whole descending run travels together.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("move")
|
||||
data class MoveCards(val from: Spot, val to: Spot, val count: Int = 1) : KlondikeMove
|
||||
|
||||
/** Foundations are indexed by suit, so a card always knows which one it wants. */
|
||||
fun foundationFor(card: Card): Int = card.suit.ordinal
|
||||
|
||||
/**
|
||||
* Down by one and in the other colour.
|
||||
*
|
||||
* Named here because it is what this game builds with, but the rule itself is
|
||||
* shared: FreeCell builds the same way, Spider builds in suit, and all of them
|
||||
* need the same ace-low ordering underneath.
|
||||
*/
|
||||
fun buildsDown(lower: Card, onto: Card): Boolean = buildsDownAlternating(lower, onto)
|
||||
|
||||
/**
|
||||
* Whether [cards] is a run that can travel as one: each card a step below the
|
||||
* one before it and in the opposite colour.
|
||||
*/
|
||||
fun isMovableRun(cards: List<Card>): Boolean =
|
||||
cards.isNotEmpty() && cards.zipWithNext().all { (upper, lower) -> buildsDown(lower, upper) }
|
||||
|
||||
/** What an empty column will take. */
|
||||
fun acceptsInSpace(card: Card, kingsOnly: Boolean): Boolean =
|
||||
!kingsOnly || card.rank == Rank.KING
|
||||
|
||||
/** Suit order is the foundation order, so this names them for the screen. */
|
||||
fun foundationSuit(index: Int): Suit = Suit.entries[index]
|
||||
@@ -0,0 +1,250 @@
|
||||
package org.prolibertate.games.game.klondike
|
||||
|
||||
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 kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Klondike as a pure state machine. See RULES-klondike.md.
|
||||
*
|
||||
* One seat, like peg solitaire: the player is not playing against anybody, only
|
||||
* against the deal.
|
||||
*/
|
||||
object KlondikeRules : GameRules<KlondikeState, KlondikeMove> {
|
||||
|
||||
override val gameId: String = GameCatalog.KLONDIKE
|
||||
|
||||
const val SOLO_SEAT: Int = 0
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
override fun initialState(config: TableConfig): KlondikeState {
|
||||
val options = json.decodeFromString<KlondikeOptions>(config.optionsJson)
|
||||
require(config.seats.size == 1) { "Klondike seats one, not ${config.seats.size}" }
|
||||
return deal(options, config.seed)
|
||||
}
|
||||
|
||||
private fun deal(options: KlondikeOptions, seed: Long): KlondikeState {
|
||||
val deck = Decks.standard52().shuffledWith(Random(seed)).toMutableList()
|
||||
val piles = (0 until TABLEAU_PILES).map { index ->
|
||||
// Column n gets n+1 cards, and only the last of them is turned up.
|
||||
val taken = List(index + 1) { deck.removeAt(deck.size - 1) }
|
||||
TableauPile(faceDown = taken.dropLast(1), faceUp = listOf(taken.last()))
|
||||
}
|
||||
return KlondikeState(
|
||||
options = options,
|
||||
seed = seed,
|
||||
stock = deck.toList(),
|
||||
waste = emptyList(),
|
||||
foundations = List(FOUNDATIONS) { emptyList() },
|
||||
tableau = piles,
|
||||
redealsUsed = 0,
|
||||
moves = 0,
|
||||
log = listOf("Dealt. Drawing ${options.drawCount} at a time."),
|
||||
)
|
||||
}
|
||||
|
||||
override fun currentSeat(state: KlondikeState): Int? =
|
||||
if (isFinished(state)) null else SOLO_SEAT
|
||||
|
||||
override fun legalMoves(state: KlondikeState, seat: Int): List<KlondikeMove> {
|
||||
if (seat != SOLO_SEAT || state.isWon) return emptyList()
|
||||
val moves = mutableListOf<KlondikeMove>()
|
||||
if (state.stock.isNotEmpty()) moves += Draw
|
||||
if (state.canRedeal) moves += Redeal
|
||||
|
||||
state.waste.lastOrNull()?.let { card ->
|
||||
val foundation = foundationFor(card)
|
||||
if (acceptsOnFoundation(state.foundations[foundation], card)) {
|
||||
moves += MoveCards(Spot.waste, Spot.foundation(foundation))
|
||||
}
|
||||
state.tableau.indices.forEach { pile ->
|
||||
if (tableauAccepts(state, pile, card)) {
|
||||
moves += MoveCards(Spot.waste, Spot.tableau(pile))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.tableau.forEachIndexed { index, pile ->
|
||||
pile.top?.let { card ->
|
||||
val foundation = foundationFor(card)
|
||||
if (acceptsOnFoundation(state.foundations[foundation], card)) {
|
||||
moves += MoveCards(Spot.tableau(index), Spot.foundation(foundation))
|
||||
}
|
||||
}
|
||||
for (count in 1..pile.faceUp.size) {
|
||||
val run = pile.faceUp.takeLast(count)
|
||||
if (!isMovableRun(run)) continue
|
||||
val head = run.first()
|
||||
state.tableau.indices.forEach { target ->
|
||||
if (target == index || !tableauAccepts(state, target, head)) return@forEach
|
||||
// Shifting a whole column into an empty one gains nothing and
|
||||
// would let a game shuffle back and forth for ever, so it is
|
||||
// not offered as a move at all.
|
||||
val wholeColumn = pile.faceDown.isEmpty() && count == pile.faceUp.size
|
||||
if (wholeColumn && state.tableau[target].isEmpty) return@forEach
|
||||
moves += MoveCards(Spot.tableau(index), Spot.tableau(target), count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.foundations.forEachIndexed { index, foundation ->
|
||||
foundation.lastOrNull()?.let { card ->
|
||||
state.tableau.indices.forEach { pile ->
|
||||
if (tableauAccepts(state, pile, card)) {
|
||||
moves += MoveCards(Spot.foundation(index), Spot.tableau(pile))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return moves
|
||||
}
|
||||
|
||||
/** Whether column [index] will take [card] on top of it. */
|
||||
fun tableauAccepts(state: KlondikeState, index: Int, card: Card): Boolean {
|
||||
val pile = state.tableau[index]
|
||||
val top = pile.top ?: return acceptsInSpace(card, state.options.kingsOnlyInSpaces)
|
||||
return buildsDown(card, top)
|
||||
}
|
||||
|
||||
override fun applyMove(state: KlondikeState, seat: Int, move: KlondikeMove): KlondikeState {
|
||||
require(seat == SOLO_SEAT) { "Klondike has one seat" }
|
||||
require(legalMoves(state, seat).contains(move)) { "$move is not legal here" }
|
||||
val next = when (move) {
|
||||
Draw -> draw(state)
|
||||
Redeal -> redeal(state)
|
||||
is MoveCards -> moveCards(state, move)
|
||||
}
|
||||
val won = next.isWon
|
||||
return next.copy(
|
||||
moves = state.moves + 1,
|
||||
log = if (won) next.log + "Out, in ${next.moves + 1} moves." else next.log,
|
||||
)
|
||||
}
|
||||
|
||||
private fun draw(state: KlondikeState): KlondikeState {
|
||||
val count = minOf(state.options.drawCount, state.stock.size)
|
||||
val taken = state.stock.takeLast(count)
|
||||
return state.copy(
|
||||
stock = state.stock.dropLast(count),
|
||||
// Turning a packet over reverses it, so the card that was deepest of
|
||||
// the three is the one left showing.
|
||||
waste = state.waste + taken.reversed(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun redeal(state: KlondikeState): KlondikeState = state.copy(
|
||||
// The waste is turned face down as one, so its top card ends up at the
|
||||
// bottom of the new stock.
|
||||
stock = state.waste.reversed(),
|
||||
waste = emptyList(),
|
||||
redealsUsed = state.redealsUsed + 1,
|
||||
log = state.log + "Waste turned back over.",
|
||||
)
|
||||
|
||||
private fun moveCards(state: KlondikeState, move: MoveCards): KlondikeState {
|
||||
val cards = cardsAt(state, move.from, move.count)
|
||||
val without = removeFrom(state, move.from, move.count)
|
||||
return addTo(without, move.to, cards)
|
||||
}
|
||||
|
||||
private fun cardsAt(state: KlondikeState, from: Spot, count: Int): List<Card> = when (from.kind) {
|
||||
SpotKind.WASTE -> state.waste.takeLast(count)
|
||||
SpotKind.TABLEAU -> state.tableau[from.index].faceUp.takeLast(count)
|
||||
SpotKind.FOUNDATION -> state.foundations[from.index].takeLast(count)
|
||||
SpotKind.STOCK -> throw IllegalArgumentException("Cards are not taken from the stock by hand")
|
||||
}
|
||||
|
||||
private fun removeFrom(state: KlondikeState, from: Spot, count: Int): KlondikeState =
|
||||
when (from.kind) {
|
||||
SpotKind.WASTE -> state.copy(waste = state.waste.dropLast(count))
|
||||
|
||||
SpotKind.TABLEAU -> {
|
||||
val pile = state.tableau[from.index]
|
||||
val left = pile.faceUp.dropLast(count)
|
||||
// Emptying the turned-up run exposes whatever was under it, and
|
||||
// turning that card is the point of most of the game.
|
||||
val flipped = if (left.isEmpty() && pile.faceDown.isNotEmpty()) {
|
||||
TableauPile(
|
||||
faceDown = pile.faceDown.dropLast(1),
|
||||
faceUp = listOf(pile.faceDown.last()),
|
||||
)
|
||||
} else {
|
||||
pile.copy(faceUp = left)
|
||||
}
|
||||
state.copy(tableau = state.tableau.replacing(from.index, flipped))
|
||||
}
|
||||
|
||||
SpotKind.FOUNDATION -> state.copy(
|
||||
foundations = state.foundations.replacing(
|
||||
from.index,
|
||||
state.foundations[from.index].dropLast(count),
|
||||
),
|
||||
)
|
||||
|
||||
SpotKind.STOCK -> throw IllegalArgumentException("Nothing is taken off the stock by hand")
|
||||
}
|
||||
|
||||
private fun addTo(state: KlondikeState, to: Spot, cards: List<Card>): KlondikeState =
|
||||
when (to.kind) {
|
||||
SpotKind.TABLEAU -> {
|
||||
val pile = state.tableau[to.index]
|
||||
state.copy(
|
||||
tableau = state.tableau.replacing(
|
||||
to.index,
|
||||
pile.copy(faceUp = pile.faceUp + cards),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
SpotKind.FOUNDATION -> state.copy(
|
||||
foundations = state.foundations.replacing(
|
||||
to.index,
|
||||
state.foundations[to.index] + cards,
|
||||
),
|
||||
)
|
||||
|
||||
SpotKind.WASTE, SpotKind.STOCK ->
|
||||
throw IllegalArgumentException("Nothing is put back on the ${to.kind}")
|
||||
}
|
||||
|
||||
private fun <T> List<T>.replacing(index: Int, value: T): List<T> =
|
||||
toMutableList().also { it[index] = value }
|
||||
|
||||
/**
|
||||
* Won, or with nothing left to try.
|
||||
*
|
||||
* A game can dry up entirely: no stock, no redeal left, and not a card that
|
||||
* will go anywhere. That is a finish as much as winning is, and the screen
|
||||
* needs to be able to say so rather than leaving the player hunting.
|
||||
*/
|
||||
override fun isFinished(state: KlondikeState): Boolean =
|
||||
state.isWon || legalMoves(state, SOLO_SEAT).isEmpty()
|
||||
|
||||
override fun summary(state: KlondikeState): 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"
|
||||
}
|
||||
|
||||
/** One seat, and it is the player's, so there is nothing to strip. */
|
||||
override fun viewFor(state: KlondikeState, seat: Int): KlondikeState = state
|
||||
|
||||
override fun encodeState(state: KlondikeState): String = json.encodeToString(state)
|
||||
override fun decodeState(json: String): KlondikeState = this.json.decodeFromString(json)
|
||||
override fun encodeMove(move: KlondikeMove): String = json.encodeToString(move)
|
||||
override fun decodeMove(json: String): KlondikeMove = this.json.decodeFromString(json)
|
||||
|
||||
/** Deals the same pack again, so a lost game can be replayed from the start. */
|
||||
fun restart(state: KlondikeState): KlondikeState = deal(state.options, state.seed)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package org.prolibertate.games.game.pegsolitaire
|
||||
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
* Plays the board for the one seat.
|
||||
*
|
||||
* Nothing normally asks it to: the single seat belongs to the person holding the
|
||||
* phone, so this never runs during an ordinary game. It exists because a seat
|
||||
* that could be left empty has to be playable by something, and because a
|
||||
* one-move-deep chooser is exactly what a "suggest a jump" button would want.
|
||||
*
|
||||
* The heuristic is the one every good peg player uses by hand: keep the pegs
|
||||
* together. A jump that leaves more jumps behind it has not painted the board
|
||||
* into a corner, and pulling toward the middle stops a stranded peg being left
|
||||
* out on an arm where nothing can ever reach it.
|
||||
*/
|
||||
object PegSolitaireAi : GameAi<PegSolitaireState, PegSolitaireMove> {
|
||||
|
||||
override fun chooseMove(
|
||||
state: PegSolitaireState,
|
||||
seat: Int,
|
||||
legal: List<PegSolitaireMove>,
|
||||
): PegSolitaireMove {
|
||||
require(legal.isNotEmpty()) { "No jump available" }
|
||||
val jumps = legal.filterIsInstance<PegJump>()
|
||||
if (jumps.isEmpty()) return legal.first()
|
||||
return jumps.maxBy { score(state, it) }
|
||||
}
|
||||
|
||||
private fun score(state: PegSolitaireState, jump: PegJump): Int {
|
||||
val after = state.copy(pegs = state.pegs - jump.from - jump.over + jump.to)
|
||||
// Solving outright beats every other consideration.
|
||||
if (after.solved) return Int.MAX_VALUE
|
||||
val followUps = PegSolitaireRules.legalJumps(after).size
|
||||
return followUps * 10 - spread(after)
|
||||
}
|
||||
|
||||
/**
|
||||
* How far the pegs sit from their own centre, in whole holes.
|
||||
*
|
||||
* A smaller number is a tighter cluster. Manhattan distance is the right
|
||||
* measure on both board shapes here, because a jump moves two steps along one
|
||||
* of the grid's own directions either way.
|
||||
*/
|
||||
private fun spread(state: PegSolitaireState): Int {
|
||||
if (state.pegs.isEmpty()) return 0
|
||||
val meanRow = state.pegs.sumOf { it.row } / state.pegs.size
|
||||
val meanCol = state.pegs.sumOf { it.col } / state.pegs.size
|
||||
return state.pegs.sumOf {
|
||||
kotlin.math.abs(it.row - meanRow) + kotlin.math.abs(it.col - meanCol)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package org.prolibertate.games.game.pegsolitaire
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A hole in the board, by row and column.
|
||||
*
|
||||
* Both geometries the boards come in are indexed this way, which is what lets
|
||||
* one jump rule serve them all: a triangular row is simply shorter than the one
|
||||
* below it, and a cross is a square with pieces bitten out of the corners.
|
||||
*/
|
||||
@Serializable
|
||||
data class Hole(val row: Int, val col: Int)
|
||||
|
||||
/**
|
||||
* The boards this plays on.
|
||||
*
|
||||
* Two shapes of grid, and the difference between them is only how many
|
||||
* directions a peg can jump in. On a triangle a hole has six neighbours, because
|
||||
* the rows are staggered; on a square board it has four, because they are not.
|
||||
* Everything else — the jump, the goal, the dead end — is the same game.
|
||||
*/
|
||||
@Serializable
|
||||
enum class PegBoard(val label: String, val holeCount: Int) {
|
||||
/** The tee game found on a restaurant table: five rows, fifteen holes. */
|
||||
TRIANGLE_15("Triangle of 15", 15),
|
||||
TRIANGLE_21("Triangle of 21", 21),
|
||||
|
||||
/** The classic cross: a seven by seven square with two-by-two corners cut. */
|
||||
ENGLISH_CROSS("English cross", 33),
|
||||
|
||||
/** The continental board, with a shallower cut that leaves four more holes. */
|
||||
FRENCH_CROSS("French cross", 37),
|
||||
DIAMOND_41("Diamond of 41", 41),
|
||||
;
|
||||
|
||||
val isTriangular: Boolean get() = this == TRIANGLE_15 || this == TRIANGLE_21
|
||||
|
||||
/** Every hole on this board. */
|
||||
fun holes(): Set<Hole> = when (this) {
|
||||
TRIANGLE_15 -> triangle(5)
|
||||
TRIANGLE_21 -> triangle(6)
|
||||
// 49 less the four two-by-two corners.
|
||||
ENGLISH_CROSS -> square(7) { r, c -> r in 2..4 || c in 2..4 }
|
||||
// 49 less a three-hole triangle at each corner.
|
||||
FRENCH_CROSS -> square(7) { r, c ->
|
||||
val fromEdgeRow = minOf(r, 6 - r)
|
||||
val fromEdgeCol = minOf(c, 6 - c)
|
||||
fromEdgeRow + fromEdgeCol >= 2
|
||||
}
|
||||
|
||||
DIAMOND_41 -> square(9) { r, c -> kotlin.math.abs(r - 4) + kotlin.math.abs(c - 4) <= 4 }
|
||||
}
|
||||
|
||||
/**
|
||||
* The directions a jump may run in, as row and column steps.
|
||||
*
|
||||
* Diagonals are not among the four on a square board: a peg moves along a
|
||||
* rank or a file and nowhere else, which is what makes the cross boards hard.
|
||||
*/
|
||||
fun directions(): List<Pair<Int, Int>> = if (isTriangular) {
|
||||
listOf(0 to -1, 0 to 1, -1 to -1, -1 to 0, 1 to 0, 1 to 1)
|
||||
} else {
|
||||
listOf(0 to -1, 0 to 1, -1 to 0, 1 to 0)
|
||||
}
|
||||
|
||||
/** Where the one empty hole sits when nothing else is chosen. */
|
||||
fun defaultStart(): Hole = when {
|
||||
// The apex, which is how the tee game is set out on the table.
|
||||
isTriangular -> Hole(0, 0)
|
||||
this == DIAMOND_41 -> Hole(4, 4)
|
||||
else -> Hole(3, 3)
|
||||
}
|
||||
|
||||
private fun triangle(rows: Int): Set<Hole> =
|
||||
(0 until rows).flatMap { r -> (0..r).map { c -> Hole(r, c) } }.toSet()
|
||||
|
||||
private fun square(size: Int, keep: (Int, Int) -> Boolean): Set<Hole> =
|
||||
(0 until size).flatMap { r ->
|
||||
(0 until size).mapNotNull { c -> Hole(r, c).takeIf { keep(r, c) } }
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
/**
|
||||
* What counts as having won.
|
||||
*
|
||||
* Leaving one peg anywhere is the game as it is usually played. Leaving it in
|
||||
* the hole the board started empty is the harder problem, and on the English
|
||||
* cross it is the one the puzzle is famous for.
|
||||
*/
|
||||
@Serializable
|
||||
enum class PegGoal(val label: String) {
|
||||
ONE_PEG("Leave one peg"),
|
||||
ONE_PEG_IN_START("Leave one peg, in the starting hole"),
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class PegSolitaireOptions(
|
||||
val board: PegBoard = PegBoard.TRIANGLE_15,
|
||||
val goal: PegGoal = PegGoal.ONE_PEG,
|
||||
/**
|
||||
* The hole left empty at the start. Null takes the board's own default,
|
||||
* which keeps a saved table valid if the default ever moves.
|
||||
*/
|
||||
val startEmpty: Hole? = null,
|
||||
) {
|
||||
/** The empty hole this table actually opens with. */
|
||||
fun start(): Hole = startEmpty ?: board.defaultStart()
|
||||
|
||||
init {
|
||||
require(board.holes().contains(start())) {
|
||||
"${start()} is not a hole on the ${board.label}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class PegSolitaireState(
|
||||
val options: PegSolitaireOptions,
|
||||
/** Which holes still hold a peg. Everything else on the board is empty. */
|
||||
val pegs: Set<Hole>,
|
||||
val jumps: Int,
|
||||
/** Held so the screen can show what just happened. */
|
||||
val lastJump: PegJump? = null,
|
||||
val log: List<String>,
|
||||
) {
|
||||
val board: PegBoard get() = options.board
|
||||
|
||||
val remaining: Int get() = pegs.size
|
||||
|
||||
/** True once the board matches what the chosen goal asked for. */
|
||||
val solved: Boolean
|
||||
get() = remaining == 1 && when (options.goal) {
|
||||
PegGoal.ONE_PEG -> true
|
||||
PegGoal.ONE_PEG_IN_START -> pegs.first() == options.start()
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed interface PegSolitaireMove
|
||||
|
||||
/**
|
||||
* A peg jumps a neighbour and lands two holes away, and the peg it jumped is
|
||||
* taken off. [over] is not carried because a jump is always exactly two steps in
|
||||
* one direction, so the hole between the ends is the only one it can be —
|
||||
* storing it would be a second copy of the same fact, free to disagree.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("jump")
|
||||
data class PegJump(val from: Hole, val to: Hole) : PegSolitaireMove {
|
||||
val over: Hole get() = Hole((from.row + to.row) / 2, (from.col + to.col) / 2)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package org.prolibertate.games.game.pegsolitaire
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.engine.GameRules
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
|
||||
/**
|
||||
* Peg solitaire as a pure state machine. See RULES-pegsolitaire.md.
|
||||
*
|
||||
* The first game here played by one person, which the engine takes in its
|
||||
* stride: there is one seat, it is always on the clock until the board runs out
|
||||
* of jumps, and [viewFor] has nothing to hide because there is nobody to hide it
|
||||
* from.
|
||||
*/
|
||||
object PegSolitaireRules : GameRules<PegSolitaireState, PegSolitaireMove> {
|
||||
|
||||
override val gameId: String = GameCatalog.PEG_SOLITAIRE
|
||||
|
||||
/** The one seat. Named rather than written as a bare 0 at each use. */
|
||||
const val SOLO_SEAT: Int = 0
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
override fun initialState(config: TableConfig): PegSolitaireState {
|
||||
val options = json.decodeFromString<PegSolitaireOptions>(config.optionsJson)
|
||||
require(config.seats.size == 1) {
|
||||
"Peg solitaire seats one, not ${config.seats.size}"
|
||||
}
|
||||
val start = options.start()
|
||||
return PegSolitaireState(
|
||||
options = options,
|
||||
pegs = options.board.holes() - start,
|
||||
jumps = 0,
|
||||
lastJump = null,
|
||||
log = listOf("${options.board.label}, opening at row ${start.row + 1}."),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every jump available on the board as it stands.
|
||||
*
|
||||
* A jump needs three holes in a line: a peg to move, a peg beside it to take,
|
||||
* and an empty hole beyond. Checking that the landing hole is on the board is
|
||||
* what keeps a jump from running off a triangle's short row or out through a
|
||||
* cross's missing corner.
|
||||
*/
|
||||
fun legalJumps(state: PegSolitaireState): List<PegJump> {
|
||||
val holes = state.board.holes()
|
||||
val directions = state.board.directions()
|
||||
return state.pegs.flatMap { from ->
|
||||
directions.mapNotNull { (dr, dc) ->
|
||||
val over = Hole(from.row + dr, from.col + dc)
|
||||
val to = Hole(from.row + 2 * dr, from.col + 2 * dc)
|
||||
// `over` holding a peg already implies it is a hole, since a peg
|
||||
// can only ever sit in one.
|
||||
val ok = state.pegs.contains(over) &&
|
||||
holes.contains(to) &&
|
||||
!state.pegs.contains(to)
|
||||
if (ok) PegJump(from, to) else null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun currentSeat(state: PegSolitaireState): Int? =
|
||||
if (isFinished(state)) null else SOLO_SEAT
|
||||
|
||||
override fun legalMoves(state: PegSolitaireState, seat: Int): List<PegSolitaireMove> =
|
||||
if (seat != SOLO_SEAT) emptyList() else legalJumps(state)
|
||||
|
||||
override fun applyMove(
|
||||
state: PegSolitaireState,
|
||||
seat: Int,
|
||||
move: PegSolitaireMove,
|
||||
): PegSolitaireState {
|
||||
require(seat == SOLO_SEAT) { "Peg solitaire has one seat" }
|
||||
val jump = move as PegJump
|
||||
require(legalJumps(state).contains(jump)) { "$jump is not a legal jump" }
|
||||
val pegs = state.pegs - jump.from - jump.over + jump.to
|
||||
val next = state.copy(
|
||||
pegs = pegs,
|
||||
jumps = state.jumps + 1,
|
||||
lastJump = jump,
|
||||
)
|
||||
val note = when {
|
||||
next.solved -> "Solved in ${next.jumps} jumps."
|
||||
legalJumps(next).isEmpty() -> "No jumps left, ${next.remaining} pegs standing."
|
||||
else -> null
|
||||
}
|
||||
return if (note == null) next else next.copy(log = next.log + note)
|
||||
}
|
||||
|
||||
/** The board is finished when nothing can jump, whether or not it was solved. */
|
||||
override fun isFinished(state: PegSolitaireState): Boolean = legalJumps(state).isEmpty()
|
||||
|
||||
override fun summary(state: PegSolitaireState): String = when {
|
||||
state.solved -> "Solved in ${state.jumps} jumps"
|
||||
isFinished(state) -> "Stuck with ${state.remaining} pegs"
|
||||
else -> "${state.remaining} pegs, ${legalJumps(state).size} jumps available"
|
||||
}
|
||||
|
||||
/**
|
||||
* Nothing to strip. One person plays, the whole board is face up in front of
|
||||
* them, and there is no second seat that could be told less than the truth.
|
||||
*/
|
||||
override fun viewFor(state: PegSolitaireState, seat: Int): PegSolitaireState = state
|
||||
|
||||
override fun encodeState(state: PegSolitaireState): String = json.encodeToString(state)
|
||||
override fun decodeState(json: String): PegSolitaireState = this.json.decodeFromString(json)
|
||||
override fun encodeMove(move: PegSolitaireMove): String = json.encodeToString(move)
|
||||
override fun decodeMove(json: String): PegSolitaireMove = this.json.decodeFromString(json)
|
||||
|
||||
/** Deals the same board again, so a lost game can be retried from the start. */
|
||||
fun restart(state: PegSolitaireState): PegSolitaireState {
|
||||
val start = state.options.start()
|
||||
return state.copy(
|
||||
pegs = state.options.board.holes() - start,
|
||||
jumps = 0,
|
||||
lastJump = null,
|
||||
log = state.log + "Board reset.",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,41 @@
|
||||
package org.prolibertate.games.game.solitaire
|
||||
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Rank
|
||||
|
||||
/**
|
||||
* Where a rank sits when the ace is low.
|
||||
*
|
||||
* [Rank.order] puts the ace at fourteen, which is right for the games this deck
|
||||
* was built for: in Euchre, Kaiser, Wizard and the rest it is the highest card
|
||||
* there is. Every patience in this app counts it the other way — foundations run
|
||||
* ace upwards, columns run down to an ace, and Pyramid needs it to be worth one
|
||||
* when a pair has to make thirteen.
|
||||
*
|
||||
* It lives here rather than on [Rank] because ace high is correct everywhere
|
||||
* outside these games, and it is shared rather than copied because four
|
||||
* separate definitions of "the ace is low" would be four chances to disagree.
|
||||
*/
|
||||
val Rank.patienceOrder: Int get() = if (this == Rank.ACE) 1 else order
|
||||
|
||||
/** Down by one, and in the other colour: the ordinary tableau build. */
|
||||
fun buildsDownAlternating(lower: Card, onto: Card): Boolean =
|
||||
lower.rank.patienceOrder == onto.rank.patienceOrder - 1 &&
|
||||
lower.suit.isRed != onto.suit.isRed
|
||||
|
||||
/** Down by one in the same suit, which is what Spider pays for. */
|
||||
fun buildsDownInSuit(lower: Card, onto: Card): Boolean =
|
||||
lower.rank.patienceOrder == onto.rank.patienceOrder - 1 && lower.suit == onto.suit
|
||||
|
||||
/** Ace first, then strictly upwards in the same suit. */
|
||||
fun acceptsOnFoundation(foundation: List<Card>, card: Card): Boolean {
|
||||
val top = foundation.lastOrNull() ?: return card.rank == Rank.ACE
|
||||
return card.suit == top.suit && card.rank.patienceOrder == top.rank.patienceOrder + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [cards] is a run that travels as one, by whatever rule the game
|
||||
* builds with.
|
||||
*/
|
||||
fun isRun(cards: List<Card>, builds: (Card, Card) -> Boolean): Boolean =
|
||||
cards.isNotEmpty() && cards.zipWithNext().all { (upper, lower) -> builds(lower, upper) }
|
||||
@@ -0,0 +1,107 @@
|
||||
package org.prolibertate.games.game.spider
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Rank
|
||||
import org.prolibertate.games.game.cards.Suit
|
||||
import org.prolibertate.games.game.solitaire.patienceOrder
|
||||
|
||||
const val COLUMNS: Int = 10
|
||||
|
||||
/** Eight complete runs, king down to ace, is the whole pack cleared. */
|
||||
const val RUNS_TO_WIN: Int = 8
|
||||
const val RUN_LENGTH: Int = 13
|
||||
|
||||
@Serializable
|
||||
data class SpiderOptions(
|
||||
/**
|
||||
* How many suits the two packs are made of. One suit is the game people
|
||||
* actually finish; four is the real thing and is brutal. Two sits between
|
||||
* them. The pack is always a hundred and four cards either way — only how
|
||||
* many different suits those cards are drawn from changes.
|
||||
*/
|
||||
val suits: Int = 1,
|
||||
) {
|
||||
init {
|
||||
require(suits == 1 || suits == 2 || suits == 4) { "Spider is played with one, two or four suits" }
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SpiderPile(
|
||||
val faceDown: List<Card> = emptyList(),
|
||||
val faceUp: List<Card> = emptyList(),
|
||||
) {
|
||||
val isEmpty: Boolean get() = faceDown.isEmpty() && faceUp.isEmpty()
|
||||
val top: Card? get() = faceUp.lastOrNull()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SpiderState(
|
||||
val options: SpiderOptions,
|
||||
val seed: Long,
|
||||
val stock: List<Card>,
|
||||
val tableau: List<SpiderPile>,
|
||||
/** Runs sent away complete. Eight of them wins. */
|
||||
val completed: Int,
|
||||
val moves: Int,
|
||||
val log: List<String>,
|
||||
) {
|
||||
val isWon: Boolean get() = completed == RUNS_TO_WIN
|
||||
|
||||
/**
|
||||
* A row cannot be dealt onto an empty column — the rule that stops a deal
|
||||
* from being used to dodge a position with nowhere to put anything.
|
||||
*/
|
||||
val canDeal: Boolean get() = stock.isNotEmpty() && tableau.none { it.isEmpty }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed interface SpiderMove
|
||||
|
||||
/** One card face up onto every column at once. */
|
||||
@Serializable
|
||||
@SerialName("deal")
|
||||
data object DealRow : SpiderMove
|
||||
|
||||
@Serializable
|
||||
@SerialName("move")
|
||||
data class MoveRun(val from: Int, val to: Int, val count: Int) : SpiderMove
|
||||
|
||||
/**
|
||||
* Down by one in the same suit.
|
||||
*
|
||||
* Spider lets a card *land* on any suit a rank higher, but only a same-suit run
|
||||
* may travel as a unit — which is the whole difficulty of the four-suit game.
|
||||
*/
|
||||
fun sameSuitRun(cards: List<Card>): Boolean =
|
||||
cards.isNotEmpty() && cards.zipWithNext().all { (upper, lower) ->
|
||||
lower.suit == upper.suit && lower.rank.patienceOrder == upper.rank.patienceOrder - 1
|
||||
}
|
||||
|
||||
/** What a column will take on top: anything one rank higher, of any suit. */
|
||||
fun landsOn(card: Card, onto: Card): Boolean =
|
||||
card.rank.patienceOrder == onto.rank.patienceOrder - 1
|
||||
|
||||
/** King down to ace in one suit, which is a run ready to go away. */
|
||||
fun isCompleteRun(cards: List<Card>): Boolean =
|
||||
cards.size == RUN_LENGTH &&
|
||||
cards.first().rank == Rank.KING &&
|
||||
cards.last().rank == Rank.ACE &&
|
||||
sameSuitRun(cards)
|
||||
|
||||
/**
|
||||
* A hundred and four cards drawn from [suits] suits.
|
||||
*
|
||||
* One suit means eight identical spade packs; four means two of each ordinary
|
||||
* suit. The count never changes, so the deal is the same shape whatever the
|
||||
* table chose.
|
||||
*/
|
||||
fun spiderDeck(suits: Int): List<Card> {
|
||||
val used = Suit.entries.take(suits)
|
||||
val copies = 8 / suits
|
||||
return used.flatMap { suit ->
|
||||
List(copies) { Rank.standard.map { Card(it, suit) } }.flatten()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package org.prolibertate.games.game.spider
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.shuffledWith
|
||||
import org.prolibertate.games.game.engine.GameRules
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Spider as a pure state machine. See RULES-spider.md.
|
||||
*/
|
||||
object SpiderRules : GameRules<SpiderState, SpiderMove> {
|
||||
|
||||
override val gameId: String = GameCatalog.SPIDER
|
||||
const val SOLO_SEAT: Int = 0
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
override fun initialState(config: TableConfig): SpiderState {
|
||||
val options = json.decodeFromString<SpiderOptions>(config.optionsJson)
|
||||
require(config.seats.size == 1) { "Spider seats one, not ${config.seats.size}" }
|
||||
return deal(options, config.seed)
|
||||
}
|
||||
|
||||
private fun deal(options: SpiderOptions, seed: Long): SpiderState {
|
||||
val deck = spiderDeck(options.suits).shuffledWith(Random(seed)).toMutableList()
|
||||
// Fifty-four cards go out: six to the first four columns, five to the
|
||||
// rest. The remaining fifty are the five rows still to come.
|
||||
val piles = (0 until COLUMNS).map { index ->
|
||||
val size = if (index < 4) 6 else 5
|
||||
val taken = List(size) { deck.removeAt(deck.size - 1) }
|
||||
SpiderPile(faceDown = taken.dropLast(1), faceUp = listOf(taken.last()))
|
||||
}
|
||||
return SpiderState(
|
||||
options = options,
|
||||
seed = seed,
|
||||
stock = deck.toList(),
|
||||
tableau = piles,
|
||||
completed = 0,
|
||||
moves = 0,
|
||||
log = listOf("Dealt, ${options.suits} suit${if (options.suits == 1) "" else "s"}."),
|
||||
)
|
||||
}
|
||||
|
||||
override fun currentSeat(state: SpiderState): Int? =
|
||||
if (isFinished(state)) null else SOLO_SEAT
|
||||
|
||||
/** The longest same-suit descending run on the end of a column. */
|
||||
fun movableTail(pile: SpiderPile): List<Card> {
|
||||
if (pile.faceUp.isEmpty()) return emptyList()
|
||||
var take = 1
|
||||
while (take < pile.faceUp.size) {
|
||||
if (!sameSuitRun(pile.faceUp.takeLast(take + 1))) break
|
||||
take++
|
||||
}
|
||||
return pile.faceUp.takeLast(take)
|
||||
}
|
||||
|
||||
override fun legalMoves(state: SpiderState, seat: Int): List<SpiderMove> {
|
||||
if (seat != SOLO_SEAT || state.isWon) return emptyList()
|
||||
val moves = mutableListOf<SpiderMove>()
|
||||
if (state.canDeal) moves += DealRow
|
||||
|
||||
state.tableau.forEachIndexed { from, pile ->
|
||||
val tail = movableTail(pile)
|
||||
for (count in 1..tail.size) {
|
||||
val run = tail.takeLast(count)
|
||||
val head = run.first()
|
||||
state.tableau.indices.forEach { to ->
|
||||
if (to == from) return@forEach
|
||||
val target = state.tableau[to]
|
||||
val fits = target.top?.let { landsOn(head, it) } ?: true
|
||||
if (!fits) return@forEach
|
||||
// Emptying one column only to fill another gains nothing.
|
||||
if (target.isEmpty && pile.faceDown.isEmpty() && count == pile.faceUp.size) {
|
||||
return@forEach
|
||||
}
|
||||
moves += MoveRun(from, to, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
return moves
|
||||
}
|
||||
|
||||
override fun applyMove(state: SpiderState, seat: Int, move: SpiderMove): SpiderState {
|
||||
require(seat == SOLO_SEAT) { "Spider has one seat" }
|
||||
require(legalMoves(state, seat).contains(move)) { "$move is not legal here" }
|
||||
val next = when (move) {
|
||||
DealRow -> dealRow(state)
|
||||
is MoveRun -> moveRun(state, move)
|
||||
}
|
||||
return next.copy(
|
||||
moves = state.moves + 1,
|
||||
log = if (next.isWon) next.log + "Out, in ${state.moves + 1} moves." else next.log,
|
||||
)
|
||||
}
|
||||
|
||||
private fun dealRow(state: SpiderState): SpiderState {
|
||||
var stock = state.stock
|
||||
val piles = state.tableau.map { pile ->
|
||||
val card = stock.lastOrNull() ?: return@map pile
|
||||
stock = stock.dropLast(1)
|
||||
pile.copy(faceUp = pile.faceUp + card)
|
||||
}
|
||||
// A dealt row can complete a run in any column it landed on.
|
||||
return sweep(state.copy(stock = stock, tableau = piles), piles.indices.toList())
|
||||
}
|
||||
|
||||
private fun moveRun(state: SpiderState, move: MoveRun): SpiderState {
|
||||
val source = state.tableau[move.from]
|
||||
val run = source.faceUp.takeLast(move.count)
|
||||
val left = source.faceUp.dropLast(move.count)
|
||||
val stripped = if (left.isEmpty() && source.faceDown.isNotEmpty()) {
|
||||
SpiderPile(source.faceDown.dropLast(1), listOf(source.faceDown.last()))
|
||||
} else {
|
||||
source.copy(faceUp = left)
|
||||
}
|
||||
val target = state.tableau[move.to]
|
||||
val piles = state.tableau.toMutableList().also {
|
||||
it[move.from] = stripped
|
||||
it[move.to] = target.copy(faceUp = target.faceUp + run)
|
||||
}
|
||||
return sweep(state.copy(tableau = piles), listOf(move.to))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends away any king-to-ace run finished by the last move.
|
||||
*
|
||||
* Only the columns that changed are looked at, and only their ends: a
|
||||
* complete run can only ever be sitting on the bottom of a pile, because
|
||||
* anything laid on an ace would have to be lower than one.
|
||||
*/
|
||||
private fun sweep(state: SpiderState, touched: List<Int>): SpiderState {
|
||||
var piles = state.tableau
|
||||
var completed = state.completed
|
||||
var notes = state.log
|
||||
touched.forEach { index ->
|
||||
val pile = piles[index]
|
||||
if (pile.faceUp.size < RUN_LENGTH) return@forEach
|
||||
if (!isCompleteRun(pile.faceUp.takeLast(RUN_LENGTH))) return@forEach
|
||||
val left = pile.faceUp.dropLast(RUN_LENGTH)
|
||||
val stripped = if (left.isEmpty() && pile.faceDown.isNotEmpty()) {
|
||||
SpiderPile(pile.faceDown.dropLast(1), listOf(pile.faceDown.last()))
|
||||
} else {
|
||||
pile.copy(faceUp = left)
|
||||
}
|
||||
piles = piles.toMutableList().also { it[index] = stripped }
|
||||
completed++
|
||||
notes = notes + "A run went away — $completed of $RUNS_TO_WIN."
|
||||
}
|
||||
return state.copy(tableau = piles, completed = completed, log = notes)
|
||||
}
|
||||
|
||||
override fun isFinished(state: SpiderState): Boolean =
|
||||
state.isWon || legalMoves(state, SOLO_SEAT).isEmpty()
|
||||
|
||||
override fun summary(state: SpiderState): String = when {
|
||||
state.isWon -> "All eight runs, in ${state.moves} moves"
|
||||
isFinished(state) -> "Blocked with ${state.completed} runs away"
|
||||
else -> "${state.completed} of $RUNS_TO_WIN runs away"
|
||||
}
|
||||
|
||||
override fun viewFor(state: SpiderState, seat: Int): SpiderState = state
|
||||
|
||||
override fun encodeState(state: SpiderState): String = json.encodeToString(state)
|
||||
override fun decodeState(json: String): SpiderState = this.json.decodeFromString(json)
|
||||
override fun encodeMove(move: SpiderMove): String = json.encodeToString(move)
|
||||
override fun decodeMove(json: String): SpiderMove = this.json.decodeFromString(json)
|
||||
|
||||
fun restart(state: SpiderState): SpiderState = deal(state.options, state.seed)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package org.prolibertate.games.game.yahtzee
|
||||
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
* Plays the dice by eye rather than by arithmetic.
|
||||
*
|
||||
* It deliberately does not try each way of keeping and look at what came back.
|
||||
* The throw is a pure function of the state and what is held, so an AI that
|
||||
* enumerated the thirty-two subsets and applied them would be reading the roll
|
||||
* before making it — beating the player by seeing the future rather than by
|
||||
* playing well. Everything below decides from the dice already on the table.
|
||||
*/
|
||||
object YahtzeeAi : GameAi<YahtzeeState, YahtzeeMove> {
|
||||
|
||||
override fun chooseMove(state: YahtzeeState, seat: Int, legal: List<YahtzeeMove>): YahtzeeMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
val rolls = legal.filterIsInstance<RollDice>()
|
||||
val writes = legal.filterIsInstance<ScoreIn>()
|
||||
|
||||
// Nothing on the table yet, so there is only one thing to do.
|
||||
if (!state.hasRolled) return rolls.firstOrNull() ?: writes.first()
|
||||
|
||||
val best = writes.maxByOrNull { scoreOf(it.category, state.dice) }
|
||||
val bestScore = best?.let { scoreOf(it.category, state.dice) } ?: 0
|
||||
|
||||
// A hand already worth taking, or no rolls left, ends the turn.
|
||||
if (rolls.isEmpty()) return best ?: writes.first()
|
||||
if (bestScore >= WORTH_KEEPING) return best!!
|
||||
|
||||
val keep = whatToKeep(state.dice)
|
||||
return rolls.firstOrNull { it.keep.sorted() == keep.sorted() }
|
||||
?: rolls.first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Good enough to stop throwing. Set at a full house, which is the point
|
||||
* where a hand is worth more than an average reroll of it.
|
||||
*/
|
||||
private const val WORTH_KEEPING = 25
|
||||
|
||||
/**
|
||||
* What to hold on to: a run if one is nearly there, otherwise the face there
|
||||
* is most of. Holding the biggest group is what turns three of a kind into
|
||||
* four, and it is the whole of ordinary dice play.
|
||||
*/
|
||||
private fun whatToKeep(dice: List<Int>): List<Int> {
|
||||
val run = runIndices(dice)
|
||||
if (run.size >= 4) return run
|
||||
|
||||
val counts = countsOf(dice)
|
||||
val most = counts.entries
|
||||
.sortedWith(compareByDescending<Map.Entry<Int, Int>> { it.value }.thenByDescending { it.key })
|
||||
.first()
|
||||
if (most.value >= 2) {
|
||||
return dice.indices.filter { dice[it] == most.key }
|
||||
}
|
||||
// Five different faces and no run worth keeping: hold the big ones and
|
||||
// throw the rest.
|
||||
return dice.indices.filter { dice[it] >= 5 }
|
||||
}
|
||||
|
||||
/** Positions making up the longest run of consecutive faces, one die per face. */
|
||||
private fun runIndices(dice: List<Int>): List<Int> {
|
||||
val faces = dice.toSortedSet().toList()
|
||||
var bestStart = 0
|
||||
var bestLength = 0
|
||||
var start = 0
|
||||
for (i in faces.indices) {
|
||||
if (i > 0 && faces[i] != faces[i - 1] + 1) start = i
|
||||
val length = i - start + 1
|
||||
if (length > bestLength) {
|
||||
bestLength = length
|
||||
bestStart = start
|
||||
}
|
||||
}
|
||||
if (bestLength < 4) return emptyList()
|
||||
val wanted = faces.subList(bestStart, bestStart + bestLength).toSet()
|
||||
// One die per face: a second five adds nothing to a straight.
|
||||
val taken = mutableSetOf<Int>()
|
||||
return dice.indices.filter { index ->
|
||||
val face = dice[index]
|
||||
face in wanted && taken.add(face)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package org.prolibertate.games.game.yahtzee
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** Five dice, thirteen boxes, and thirteen turns to fill them. */
|
||||
const val DICE_COUNT: Int = 5
|
||||
const val ROLLS_PER_TURN: Int = 3
|
||||
|
||||
@Serializable
|
||||
enum class YahtzeeSection { UPPER, LOWER }
|
||||
|
||||
/**
|
||||
* The thirteen boxes on the card, in the order they are printed.
|
||||
*
|
||||
* The upper six score only the die they name, which is what the bonus is for:
|
||||
* three of each is exactly sixty-three, so the bonus rewards a card that did not
|
||||
* give up on any of them.
|
||||
*/
|
||||
@Serializable
|
||||
enum class YahtzeeCategory(val label: String, val section: YahtzeeSection) {
|
||||
ONES("Ones", YahtzeeSection.UPPER),
|
||||
TWOS("Twos", YahtzeeSection.UPPER),
|
||||
THREES("Threes", YahtzeeSection.UPPER),
|
||||
FOURS("Fours", YahtzeeSection.UPPER),
|
||||
FIVES("Fives", YahtzeeSection.UPPER),
|
||||
SIXES("Sixes", YahtzeeSection.UPPER),
|
||||
THREE_OF_A_KIND("Three of a kind", YahtzeeSection.LOWER),
|
||||
FOUR_OF_A_KIND("Four of a kind", YahtzeeSection.LOWER),
|
||||
FULL_HOUSE("Full house", YahtzeeSection.LOWER),
|
||||
SMALL_STRAIGHT("Small straight", YahtzeeSection.LOWER),
|
||||
LARGE_STRAIGHT("Large straight", YahtzeeSection.LOWER),
|
||||
YAHTZEE("Yahtzee", YahtzeeSection.LOWER),
|
||||
CHANCE("Chance", YahtzeeSection.LOWER),
|
||||
;
|
||||
|
||||
/** The face this box counts, for the upper six. */
|
||||
val face: Int? get() = if (section == YahtzeeSection.UPPER) ordinal + 1 else null
|
||||
}
|
||||
|
||||
/** Three of each of the upper six, which is what the bonus asks for. */
|
||||
const val UPPER_BONUS_AT: Int = 63
|
||||
const val UPPER_BONUS: Int = 35
|
||||
const val YAHTZEE_SCORE: Int = 50
|
||||
const val YAHTZEE_BONUS: Int = 100
|
||||
|
||||
@Serializable
|
||||
data class YahtzeeOptions(
|
||||
/** One plays it as a puzzle against the card; up to six round a table. */
|
||||
val playerCount: Int = 2,
|
||||
/**
|
||||
* A second Yahtzee is worth a hundred on top, once the Yahtzee box itself
|
||||
* holds fifty. Switched off, extra Yahtzees are only worth whatever box they
|
||||
* are written into.
|
||||
*/
|
||||
val yahtzeeBonus: Boolean = true,
|
||||
) {
|
||||
init {
|
||||
require(playerCount in 1..6) { "Yahtzee seats one to six" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One player's card.
|
||||
*
|
||||
* Boxes are held as a list indexed by [YahtzeeCategory.ordinal] rather than a
|
||||
* map, so what crosses the wire is thirteen slots in a fixed order and a box
|
||||
* that has never been filled is plainly null rather than absent.
|
||||
*/
|
||||
@Serializable
|
||||
data class YahtzeeCard(
|
||||
val boxes: List<Int?> = List(YahtzeeCategory.entries.size) { null },
|
||||
) {
|
||||
operator fun get(category: YahtzeeCategory): Int? = boxes[category.ordinal]
|
||||
|
||||
fun isFilled(category: YahtzeeCategory): Boolean = this[category] != null
|
||||
|
||||
val isComplete: Boolean get() = boxes.none { it == null }
|
||||
|
||||
fun with(category: YahtzeeCategory, score: Int): YahtzeeCard {
|
||||
require(!isFilled(category)) { "${category.label} is already written in" }
|
||||
return copy(boxes = boxes.toMutableList().also { it[category.ordinal] = score })
|
||||
}
|
||||
|
||||
val upperSubtotal: Int
|
||||
get() = YahtzeeCategory.entries
|
||||
.filter { it.section == YahtzeeSection.UPPER }
|
||||
.sumOf { this[it] ?: 0 }
|
||||
|
||||
val upperBonus: Int get() = if (upperSubtotal >= UPPER_BONUS_AT) UPPER_BONUS else 0
|
||||
|
||||
val lowerSubtotal: Int
|
||||
get() = YahtzeeCategory.entries
|
||||
.filter { it.section == YahtzeeSection.LOWER }
|
||||
.sumOf { this[it] ?: 0 }
|
||||
|
||||
/** Does not include Yahtzee bonuses, which are held on the state. */
|
||||
val total: Int get() = upperSubtotal + upperBonus + lowerSubtotal
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class YahtzeeState(
|
||||
val options: YahtzeeOptions,
|
||||
val seed: Long,
|
||||
/** Zero-based. Thirteen rounds fill thirteen boxes. */
|
||||
val round: Int,
|
||||
val turn: Int,
|
||||
/** Empty until the seat has rolled; five dice after that. */
|
||||
val dice: List<Int>,
|
||||
val rollsUsed: Int,
|
||||
val cards: List<YahtzeeCard>,
|
||||
/** Hundreds earned for extra Yahtzees, counted per seat. */
|
||||
val yahtzeeBonuses: List<Int>,
|
||||
val log: List<String>,
|
||||
) {
|
||||
val rollsLeft: Int get() = ROLLS_PER_TURN - rollsUsed
|
||||
|
||||
/** Nothing can be written in until the dice have been thrown at least once. */
|
||||
val hasRolled: Boolean get() = rollsUsed > 0
|
||||
|
||||
fun totalFor(seat: Int): Int = cards[seat].total + yahtzeeBonuses[seat]
|
||||
|
||||
val isOver: Boolean get() = cards.all { it.isComplete }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed interface YahtzeeMove
|
||||
|
||||
/**
|
||||
* Throws the dice, keeping the ones at [keep].
|
||||
*
|
||||
* Positions rather than faces: two fives are two different dice, and a player
|
||||
* who keeps one of them and rerolls the other is doing something a set of faces
|
||||
* could not express.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("roll")
|
||||
data class RollDice(val keep: List<Int> = emptyList()) : YahtzeeMove
|
||||
|
||||
@Serializable
|
||||
@SerialName("score")
|
||||
data class ScoreIn(val category: YahtzeeCategory) : YahtzeeMove
|
||||
|
||||
/** How many of each face, indexed one to six. */
|
||||
fun countsOf(dice: List<Int>): Map<Int, Int> = dice.groupingBy { it }.eachCount()
|
||||
|
||||
/** The longest run of consecutive faces present, however many of each. */
|
||||
fun longestRun(dice: List<Int>): Int {
|
||||
val present = dice.toSortedSet()
|
||||
var best = 0
|
||||
var run = 0
|
||||
var previous: Int? = null
|
||||
for (face in present) {
|
||||
run = if (previous != null && face == previous + 1) run + 1 else 1
|
||||
best = maxOf(best, run)
|
||||
previous = face
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* What [category] is worth for [dice].
|
||||
*
|
||||
* A full house is strictly three of one and two of another, so five of a kind
|
||||
* does not pay for it — that is the printed rule, and the joker variations that
|
||||
* change it are not played here.
|
||||
*/
|
||||
fun scoreOf(category: YahtzeeCategory, dice: List<Int>): Int {
|
||||
val counts = countsOf(dice)
|
||||
val sum = dice.sum()
|
||||
return when (category) {
|
||||
YahtzeeCategory.ONES, YahtzeeCategory.TWOS, YahtzeeCategory.THREES,
|
||||
YahtzeeCategory.FOURS, YahtzeeCategory.FIVES, YahtzeeCategory.SIXES,
|
||||
-> {
|
||||
val face = category.face!!
|
||||
face * (counts[face] ?: 0)
|
||||
}
|
||||
|
||||
YahtzeeCategory.THREE_OF_A_KIND -> if (counts.values.any { it >= 3 }) sum else 0
|
||||
YahtzeeCategory.FOUR_OF_A_KIND -> if (counts.values.any { it >= 4 }) sum else 0
|
||||
YahtzeeCategory.FULL_HOUSE ->
|
||||
if (counts.values.sorted() == listOf(2, 3)) 25 else 0
|
||||
|
||||
YahtzeeCategory.SMALL_STRAIGHT -> if (longestRun(dice) >= 4) 30 else 0
|
||||
YahtzeeCategory.LARGE_STRAIGHT -> if (longestRun(dice) >= 5) 40 else 0
|
||||
YahtzeeCategory.YAHTZEE -> if (counts.values.any { it == DICE_COUNT }) YAHTZEE_SCORE else 0
|
||||
YahtzeeCategory.CHANCE -> sum
|
||||
}
|
||||
}
|
||||
|
||||
fun isYahtzee(dice: List<Int>): Boolean =
|
||||
dice.size == DICE_COUNT && dice.toSet().size == 1
|
||||
@@ -0,0 +1,175 @@
|
||||
package org.prolibertate.games.game.yahtzee
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.engine.GameRules
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Yahtzee as a pure state machine. See RULES-yahtzee.md.
|
||||
*
|
||||
* The dice are thrown from the state rather than from a generator held on the
|
||||
* side: [applyMove] has to return the same thing every time it is handed the
|
||||
* same state and move, or the host and its clients would part company on the
|
||||
* first reroll. Seeding on the round, the seat, the rolls used and what was kept
|
||||
* gives a throw that is unpredictable to play against and identical to replay.
|
||||
*/
|
||||
object YahtzeeRules : GameRules<YahtzeeState, YahtzeeMove> {
|
||||
|
||||
override val gameId: String = GameCatalog.YAHTZEE
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
override fun initialState(config: TableConfig): YahtzeeState {
|
||||
val options = json.decodeFromString<YahtzeeOptions>(config.optionsJson)
|
||||
require(config.seats.size == options.playerCount) {
|
||||
"Seat count ${config.seats.size} does not match ${options.playerCount} players"
|
||||
}
|
||||
return YahtzeeState(
|
||||
options = options,
|
||||
seed = config.seed,
|
||||
round = 0,
|
||||
turn = 0,
|
||||
dice = emptyList(),
|
||||
rollsUsed = 0,
|
||||
cards = List(options.playerCount) { YahtzeeCard() },
|
||||
yahtzeeBonuses = List(options.playerCount) { 0 },
|
||||
log = listOf("Round 1."),
|
||||
)
|
||||
}
|
||||
|
||||
override fun currentSeat(state: YahtzeeState): Int? =
|
||||
if (state.isOver) null else state.turn
|
||||
|
||||
override fun legalMoves(state: YahtzeeState, seat: Int): List<YahtzeeMove> {
|
||||
if (state.isOver || state.turn != seat) return emptyList()
|
||||
val moves = mutableListOf<YahtzeeMove>()
|
||||
if (state.rollsUsed < ROLLS_PER_TURN) {
|
||||
// The first throw of a turn is all five dice: there is nothing on
|
||||
// the table yet to keep.
|
||||
moves += if (!state.hasRolled) {
|
||||
listOf(RollDice(emptyList()))
|
||||
} else {
|
||||
keepSubsets().map { RollDice(it) }
|
||||
}
|
||||
}
|
||||
if (state.hasRolled) {
|
||||
moves += YahtzeeCategory.entries
|
||||
.filterNot { state.cards[seat].isFilled(it) }
|
||||
.map { ScoreIn(it) }
|
||||
}
|
||||
return moves
|
||||
}
|
||||
|
||||
/** Every set of dice positions a player might hold back — thirty-two of them. */
|
||||
private fun keepSubsets(): List<List<Int>> =
|
||||
(0 until (1 shl DICE_COUNT)).map { mask ->
|
||||
(0 until DICE_COUNT).filter { mask and (1 shl it) != 0 }
|
||||
}
|
||||
|
||||
override fun applyMove(state: YahtzeeState, seat: Int, move: YahtzeeMove): YahtzeeState {
|
||||
require(!state.isOver) { "The game is over" }
|
||||
require(state.turn == seat) { "Seat $seat is not on the clock" }
|
||||
return when (move) {
|
||||
is RollDice -> applyRoll(state, move)
|
||||
is ScoreIn -> applyScore(state, seat, move)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyRoll(state: YahtzeeState, move: RollDice): YahtzeeState {
|
||||
require(state.rollsUsed < ROLLS_PER_TURN) { "No rolls left this turn" }
|
||||
require(move.keep.distinct().size == move.keep.size) { "Cannot keep a die twice" }
|
||||
require(move.keep.all { it in 0 until DICE_COUNT }) { "No such die" }
|
||||
require(state.hasRolled || move.keep.isEmpty()) { "Nothing to keep before the first roll" }
|
||||
return state.copy(
|
||||
dice = throwDice(state, move.keep),
|
||||
rollsUsed = state.rollsUsed + 1,
|
||||
)
|
||||
}
|
||||
|
||||
private fun throwDice(state: YahtzeeState, keep: List<Int>): List<Int> {
|
||||
val random = Random(
|
||||
state.seed +
|
||||
state.round * 10_000L +
|
||||
state.turn * 1_000L +
|
||||
state.rollsUsed * 100L +
|
||||
keep.sorted().hashCode(),
|
||||
)
|
||||
return (0 until DICE_COUNT).map { index ->
|
||||
if (keep.contains(index) && index < state.dice.size) {
|
||||
state.dice[index]
|
||||
} else {
|
||||
random.nextInt(1, 7)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyScore(state: YahtzeeState, seat: Int, move: ScoreIn): YahtzeeState {
|
||||
require(state.hasRolled) { "Roll before writing anything in" }
|
||||
val card = state.cards[seat]
|
||||
require(!card.isFilled(move.category)) { "${move.category.label} is already written in" }
|
||||
|
||||
val written = scoreOf(move.category, state.dice)
|
||||
// A second Yahtzee pays a hundred on top, but only once the box itself
|
||||
// holds the fifty — a zero written there earlier forfeits the bonus, as
|
||||
// the printed rule has it.
|
||||
val earnsBonus = state.options.yahtzeeBonus &&
|
||||
isYahtzee(state.dice) &&
|
||||
card[YahtzeeCategory.YAHTZEE] == YAHTZEE_SCORE
|
||||
val bonuses = if (!earnsBonus) {
|
||||
state.yahtzeeBonuses
|
||||
} else {
|
||||
state.yahtzeeBonuses.toMutableList().also { it[seat] = it[seat] + YAHTZEE_BONUS }
|
||||
}
|
||||
|
||||
val cards = state.cards.toMutableList().also { it[seat] = card.with(move.category, written) }
|
||||
val nextTurn = (seat + 1) % state.options.playerCount
|
||||
val wrapped = nextTurn == 0
|
||||
val round = if (wrapped) state.round + 1 else state.round
|
||||
val notes = buildList {
|
||||
add("Seat $seat wrote $written in ${move.category.label}.")
|
||||
if (earnsBonus) add("Seat $seat rolled another Yahtzee: $YAHTZEE_BONUS more.")
|
||||
if (cards.all { it.isComplete }) add("Cards full.") else if (wrapped) add("Round ${round + 1}.")
|
||||
}
|
||||
return state.copy(
|
||||
round = round,
|
||||
turn = nextTurn,
|
||||
// The dice go back in the cup for whoever is next.
|
||||
dice = emptyList(),
|
||||
rollsUsed = 0,
|
||||
cards = cards,
|
||||
yahtzeeBonuses = bonuses,
|
||||
log = state.log + notes,
|
||||
)
|
||||
}
|
||||
|
||||
override fun isFinished(state: YahtzeeState): Boolean = state.isOver
|
||||
|
||||
override fun summary(state: YahtzeeState): String = when {
|
||||
state.isOver -> {
|
||||
val totals = state.cards.indices.map { state.totalFor(it) }
|
||||
val best = totals.max()
|
||||
"Winner: seat ${totals.indexOf(best)} on $best"
|
||||
}
|
||||
|
||||
!state.hasRolled -> "Seat ${state.turn} to throw"
|
||||
else -> "Seat ${state.turn}, ${state.rollsLeft} rolls left"
|
||||
}
|
||||
|
||||
/**
|
||||
* Nothing is hidden. The dice are on the table and every card is face up —
|
||||
* Yahtzee is a game of what you do with a throw everyone watched, so there
|
||||
* is no private state to strip.
|
||||
*/
|
||||
override fun viewFor(state: YahtzeeState, seat: Int): YahtzeeState = state
|
||||
|
||||
override fun encodeState(state: YahtzeeState): String = json.encodeToString(state)
|
||||
override fun decodeState(json: String): YahtzeeState = this.json.decodeFromString(json)
|
||||
override fun encodeMove(move: YahtzeeMove): String = json.encodeToString(move)
|
||||
override fun decodeMove(json: String): YahtzeeMove = this.json.decodeFromString(json)
|
||||
}
|
||||
@@ -2,15 +2,17 @@ package org.prolibertate.games.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material3.Button
|
||||
@@ -36,7 +38,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.prolibertate.games.R
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.GameCategory
|
||||
import org.prolibertate.games.game.GameMenu
|
||||
import org.prolibertate.games.game.GameDescriptor
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import org.prolibertate.games.net.LobbyController
|
||||
@@ -64,6 +66,9 @@ class AppEnv(
|
||||
*/
|
||||
sealed interface Route {
|
||||
data object Menu : Route
|
||||
|
||||
/** One section of the menu: its games, and any sub-section under it. */
|
||||
data class Section(val menu: GameMenu) : Route
|
||||
data object Settings : Route
|
||||
data object Scorekeeper : Route
|
||||
data class Setup(val gameId: String) : Route
|
||||
@@ -152,12 +157,20 @@ private fun AppContent(
|
||||
// Dev builds show the games that are not finished yet; production
|
||||
// releases list only what can actually be played.
|
||||
showComingSoon = showComingSoon,
|
||||
onPickGame = { push(Route.Setup(it.id)) },
|
||||
onPickSection = { push(Route.Section(it)) },
|
||||
onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) },
|
||||
onScorekeeper = { push(Route.Scorekeeper) },
|
||||
onSettings = { push(Route.Settings) },
|
||||
)
|
||||
|
||||
is Route.Section -> SectionScreen(
|
||||
menu = route.menu,
|
||||
showComingSoon = showComingSoon,
|
||||
onPickGame = { push(Route.Setup(it.id)) },
|
||||
onPickSection = { push(Route.Section(it)) },
|
||||
onBack = pop,
|
||||
)
|
||||
|
||||
is Route.Settings -> SettingsScreen(
|
||||
env = env,
|
||||
settings = settings,
|
||||
@@ -202,11 +215,11 @@ private fun AppContent(
|
||||
// Main menu
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MainMenuScreen(
|
||||
showComingSoon: Boolean,
|
||||
onPickGame: (GameDescriptor) -> Unit,
|
||||
onPickSection: (GameMenu) -> Unit,
|
||||
onJoinGame: () -> Unit,
|
||||
onScorekeeper: () -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
@@ -221,51 +234,157 @@ fun MainMenuScreen(
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
// Only the top of the tree. Trick-taking is reached through Card Games,
|
||||
// which is where somebody looking for it would go.
|
||||
val sections = GameMenu.entries.filter {
|
||||
it.isTopLevel && GameCatalog.hasAnything(it, includeComingSoon = showComingSoon)
|
||||
}
|
||||
|
||||
// One column, held in the middle. There are only ever a handful of
|
||||
// entries here now that the games themselves live a level down, and a
|
||||
// short list spread across a grid reads as a page half empty rather
|
||||
// than as a menu. Capped in width so it does not become a row of very
|
||||
// wide buttons on a tablet.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 12.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// Wraps rather than running off the edge: two buttons and a narrow
|
||||
// phone would otherwise push the second one past the screen.
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 340.dp)
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
OutlinedButton(onClick = onJoinGame) { Text("Join a game nearby") }
|
||||
// Not a game, and deliberately not in the catalogue: it keeps
|
||||
// score for whatever is being played on the actual table.
|
||||
OutlinedButton(onClick = onScorekeeper) { Text("Scorekeeper") }
|
||||
sections.forEach { section ->
|
||||
SectionTile(
|
||||
menu = section,
|
||||
count = countIn(section, showComingSoon),
|
||||
onClick = { onPickSection(section) },
|
||||
)
|
||||
}
|
||||
|
||||
// The column count follows the available width, so a phone in
|
||||
// portrait gets two and a tablet in landscape gets four or more.
|
||||
// Below the games, and full width like them, so the column
|
||||
// stays one column all the way down.
|
||||
OutlinedButton(
|
||||
onClick = onJoinGame,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
) { Text("Join a game nearby") }
|
||||
// Not a game, and deliberately not in the catalogue: it keeps
|
||||
// score for whatever is being played on the actual table.
|
||||
OutlinedButton(
|
||||
onClick = onScorekeeper,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Scorekeeper") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* How many games a section offers, counting whatever is under it as well.
|
||||
*
|
||||
* A game in both a section and its sub-section is one game, not two, so this
|
||||
* counts a set rather than adding the lists together.
|
||||
*/
|
||||
private fun countIn(menu: GameMenu, showComingSoon: Boolean): Int =
|
||||
(GameCatalog.inMenu(menu, showComingSoon) +
|
||||
menu.children.flatMap { GameCatalog.inMenu(it, showComingSoon) })
|
||||
.toSet().size
|
||||
|
||||
/**
|
||||
* One section of the menu.
|
||||
*
|
||||
* Sub-sections come first and its own games after, so Card Games opens on
|
||||
* "Trick-taking" and then everything played with a pack — including the
|
||||
* trick-taking games themselves, which belong in both places and are listed in
|
||||
* both. A game reachable one way and not the other would just be a game
|
||||
* somebody could not find.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SectionScreen(
|
||||
menu: GameMenu,
|
||||
showComingSoon: Boolean,
|
||||
onPickGame: (GameDescriptor) -> Unit,
|
||||
onPickSection: (GameMenu) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val children = menu.children.filter {
|
||||
GameCatalog.hasAnything(it, includeComingSoon = showComingSoon)
|
||||
}
|
||||
val games = GameCatalog.inMenu(menu, includeComingSoon = showComingSoon)
|
||||
|
||||
ScreenScaffold(title = menu.label, onBack = onBack) { modifier ->
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 172.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = modifier.fillMaxSize(),
|
||||
) {
|
||||
GameCategory.entries.forEach { category ->
|
||||
val games = GameCatalog.byCategory(category, includeComingSoon = showComingSoon)
|
||||
// Don't leave a heading stranded over nothing.
|
||||
if (games.isEmpty()) return@forEach
|
||||
|
||||
item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Text(
|
||||
text = category.label,
|
||||
text = menu.blurb,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
items(children) { child ->
|
||||
SectionTile(
|
||||
menu = child,
|
||||
count = countIn(child, showComingSoon),
|
||||
onClick = { onPickSection(child) },
|
||||
)
|
||||
}
|
||||
if (games.isNotEmpty() && children.isNotEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Text(
|
||||
text = "All ${menu.label.lowercase()}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
items(games) { game ->
|
||||
GameTile(game = game, onClick = { onPickGame(game) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A way into a section, rather than into a game. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SectionTile(menu: GameMenu, count: Int, onClick: () -> Unit) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Text(
|
||||
text = menu.label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = menu.blurb,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
Text(
|
||||
text = if (count == 1) "1 game" else "$count games",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -297,14 +416,12 @@ private fun GameTile(game: GameDescriptor, onClick: () -> Unit) {
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
Text(
|
||||
text = if (game.available) {
|
||||
if (game.minPlayers == game.maxPlayers) {
|
||||
"${game.minPlayers} players"
|
||||
} else {
|
||||
"${game.minPlayers}–${game.maxPlayers} players"
|
||||
}
|
||||
} else {
|
||||
"Coming soon"
|
||||
text = when {
|
||||
!game.available -> "Coming soon"
|
||||
// A puzzle for one, rather than "1 players".
|
||||
game.maxPlayers == 1 -> "On your own"
|
||||
game.minPlayers == game.maxPlayers -> "${game.minPlayers} players"
|
||||
else -> "${game.minPlayers}–${game.maxPlayers} players"
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
|
||||
@@ -40,6 +40,15 @@ import org.prolibertate.games.game.crazyeights.CrazyEightsOptions
|
||||
import org.prolibertate.games.game.cribbage.CribbageOptions
|
||||
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
|
||||
import org.prolibertate.games.game.pegsolitaire.PegSolitaireOptions
|
||||
import org.prolibertate.games.game.kaiser.KaiserOptions
|
||||
import org.prolibertate.games.game.mastermind.MastermindLevel
|
||||
import org.prolibertate.games.game.mastermind.MastermindOptions
|
||||
@@ -88,6 +97,13 @@ fun GameSetupScreen(
|
||||
var backgammon by remember { mutableStateOf(BackgammonOptions()) }
|
||||
var mastermind by remember { mutableStateOf(MastermindOptions()) }
|
||||
var pirates by remember { mutableStateOf(PiratesOptions()) }
|
||||
var hearts by remember { mutableStateOf(HeartsOptions()) }
|
||||
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) }
|
||||
@@ -111,6 +127,13 @@ fun GameSetupScreen(
|
||||
GameCatalog.BACKGAMMON -> setupJson.encodeToString(backgammon)
|
||||
GameCatalog.MASTERMIND -> setupJson.encodeToString(mastermind)
|
||||
GameCatalog.PIRATES -> setupJson.encodeToString(pirates)
|
||||
GameCatalog.HEARTS -> setupJson.encodeToString(hearts)
|
||||
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)
|
||||
@@ -162,15 +185,37 @@ fun GameSetupScreen(
|
||||
onSide = { playPirates = it },
|
||||
)
|
||||
|
||||
GameCatalog.HEARTS -> HeartsOptionsEditor(hearts) { hearts = it }
|
||||
|
||||
GameCatalog.PEG_SOLITAIRE -> PegSolitaireOptionsEditor(pegs) { pegs = it }
|
||||
|
||||
GameCatalog.YAHTZEE -> YahtzeeOptionsEditor(yahtzee) { yahtzee = it }
|
||||
|
||||
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.")
|
||||
}
|
||||
|
||||
Divider()
|
||||
Text("Opponents", fontWeight = FontWeight.Bold)
|
||||
// A one-seat game has nobody to play against and no seat anyone
|
||||
// could join, so it is offered as itself rather than as a table
|
||||
// with every other chair filled by the computer.
|
||||
val solo = seatCount == 1
|
||||
Text(if (solo) "On your own" else "Opponents", fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
text = "Play now and every other seat is taken by the computer. Host a game " +
|
||||
text = if (solo) {
|
||||
"A puzzle for one. There is nobody to wait for and nothing to host."
|
||||
} else {
|
||||
"Play now and every other seat is taken by the computer. Host a game " +
|
||||
"instead and people can claim those seats as they join — whatever is " +
|
||||
"still empty when you start stays computer-controlled.",
|
||||
"still empty when you start stays computer-controlled."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
@@ -182,17 +227,19 @@ fun GameSetupScreen(
|
||||
descriptor.id == GameCatalog.PIRATES && playPirates -> 1
|
||||
else -> 0
|
||||
}
|
||||
PrimaryAction(text = "Play against the computer") {
|
||||
PrimaryAction(text = if (solo) "Play" else "Play against the computer") {
|
||||
onPlayOffline(
|
||||
offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat),
|
||||
localSeat,
|
||||
)
|
||||
}
|
||||
if (!solo) {
|
||||
PrimaryAction(text = "Host a game for others to join") {
|
||||
onHostOnline(optionsJson)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** [localSeat] is the person at this device; every other seat starts as AI. */
|
||||
@@ -250,6 +297,9 @@ fun seatCountFor(gameId: String, optionsJson: String): Int {
|
||||
GameCatalog.CHESS, GameCatalog.MORRIS, GameCatalog.CHECKERS,
|
||||
GameCatalog.BACKGAMMON, GameCatalog.MASTERMIND, GameCatalog.PIRATES -> 2
|
||||
|
||||
GameCatalog.YAHTZEE ->
|
||||
setupJson.decodeFromString<YahtzeeOptions>(optionsJson).playerCount
|
||||
|
||||
GameCatalog.TAYU ->
|
||||
setupJson.decodeFromString<TayuOptions>(optionsJson).playerCount
|
||||
|
||||
@@ -944,3 +994,150 @@ private fun ToggleRow(
|
||||
Switch(checked = checked, onCheckedChange = onChange)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeartsOptionsEditor(options: HeartsOptions, onChange: (HeartsOptions) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Game ends at",
|
||||
values = listOf(50, 100),
|
||||
selected = options.targetScore,
|
||||
display = { "$it points" },
|
||||
onSelect = { onChange(options.copy(targetScore = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Shooting the moon",
|
||||
subtitle = "Take all twenty-six and score nothing, while everyone else takes " +
|
||||
"the lot. Switched off, a moon is simply the worst hand at the table.",
|
||||
checked = options.allowShootTheMoon,
|
||||
onChange = { onChange(options.copy(allowShootTheMoon = it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PegSolitaireOptionsEditor(
|
||||
options: PegSolitaireOptions,
|
||||
onChange: (PegSolitaireOptions) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Board",
|
||||
values = PegBoard.entries.toList(),
|
||||
selected = options.board,
|
||||
display = { it.label },
|
||||
// The opening hole is cleared as well: a hole that exists on the old
|
||||
// board may be a missing corner on the new one, and the options
|
||||
// refuse to be built that way.
|
||||
onSelect = { onChange(options.copy(board = it, startEmpty = null)) },
|
||||
)
|
||||
ChipRow(
|
||||
label = "Finish",
|
||||
values = PegGoal.entries.toList(),
|
||||
selected = options.goal,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(goal = it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun YahtzeeOptionsEditor(options: YahtzeeOptions, onChange: (YahtzeeOptions) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Players",
|
||||
values = (1..6).toList(),
|
||||
selected = options.playerCount,
|
||||
display = { if (it == 1) "On your own" else "$it" },
|
||||
onSelect = { onChange(options.copy(playerCount = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Second Yahtzee scores a hundred",
|
||||
subtitle = "Only once the Yahtzee box holds the fifty. A zero written there " +
|
||||
"earlier forfeits it, as the printed rule has it.",
|
||||
checked = options.yahtzeeBonus,
|
||||
onChange = { onChange(options.copy(yahtzeeBonus = it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KlondikeOptionsEditor(options: KlondikeOptions, onChange: (KlondikeOptions) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Turn from the stock",
|
||||
values = listOf(1, 3),
|
||||
selected = options.drawCount,
|
||||
display = { "$it at a time" },
|
||||
onSelect = { onChange(options.copy(drawCount = it)) },
|
||||
)
|
||||
ChipRow(
|
||||
label = "Turning the waste back over",
|
||||
values = listOf(null, 0, 1, 2),
|
||||
selected = options.redealLimit,
|
||||
display = {
|
||||
when (it) {
|
||||
null -> "As often as you like"
|
||||
0 -> "Never — one pass"
|
||||
else -> "$it times"
|
||||
}
|
||||
},
|
||||
onSelect = { onChange(options.copy(redealLimit = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Only a king fills a space",
|
||||
subtitle = "Turning this off lets any card into an empty column, which makes " +
|
||||
"far more deals winnable.",
|
||||
checked = options.kingsOnlyInSpaces,
|
||||
onChange = { onChange(options.copy(kingsOnlyInSpaces = it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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)) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.chess.ChessAi
|
||||
@@ -26,6 +29,33 @@ 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
|
||||
import org.prolibertate.games.game.klondike.KlondikeState
|
||||
import org.prolibertate.games.game.yahtzee.YahtzeeAi
|
||||
import org.prolibertate.games.game.yahtzee.YahtzeeMove
|
||||
import org.prolibertate.games.game.yahtzee.YahtzeeRules
|
||||
import org.prolibertate.games.game.yahtzee.YahtzeeState
|
||||
import org.prolibertate.games.game.hearts.HeartsAi
|
||||
import org.prolibertate.games.game.hearts.HeartsMove
|
||||
import org.prolibertate.games.game.hearts.HeartsPhase
|
||||
import org.prolibertate.games.game.hearts.HeartsRules
|
||||
import org.prolibertate.games.game.hearts.HeartsState
|
||||
import org.prolibertate.games.game.pegsolitaire.PegSolitaireAi
|
||||
import org.prolibertate.games.game.pegsolitaire.PegSolitaireMove
|
||||
import org.prolibertate.games.game.pegsolitaire.PegSolitaireRules
|
||||
import org.prolibertate.games.game.pegsolitaire.PegSolitaireState
|
||||
import org.prolibertate.games.game.golf.GolfAi
|
||||
import org.prolibertate.games.game.golf.GolfMove
|
||||
import org.prolibertate.games.game.golf.GolfPhase
|
||||
@@ -81,11 +111,18 @@ import org.prolibertate.games.ui.game.CheckersScreen
|
||||
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
|
||||
import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS
|
||||
@@ -430,6 +467,162 @@ fun PlayScreen(
|
||||
PiratesScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||
}
|
||||
|
||||
GameCatalog.HEARTS -> {
|
||||
val controller = remember(route) {
|
||||
MatchController<HeartsState, HeartsMove>(
|
||||
rules = HeartsRules,
|
||||
ai = HeartsAi,
|
||||
config = route.config,
|
||||
scope = scope,
|
||||
role = role,
|
||||
localSeats = setOf(route.localSeat),
|
||||
primarySeat = route.localSeat,
|
||||
advanceIdle = { state ->
|
||||
if (state.phase == HeartsPhase.ROUND_OVER) {
|
||||
HeartsRules.startNextRound(state)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
// What a round cost is read before the next deal.
|
||||
awaitsConfirmation = { state -> state.phase == HeartsPhase.ROUND_OVER },
|
||||
aiThinkingMillis = { settings.scaled(700L) },
|
||||
holdBeforeNextMove = { state ->
|
||||
if (state.completedTrick.isNotEmpty()) {
|
||||
settings.scaled(TRICK_HOLD_MILLIS)
|
||||
} else {
|
||||
0L
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
StartMatch(env, controller, route)
|
||||
HeartsScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||
}
|
||||
|
||||
GameCatalog.PEG_SOLITAIRE -> {
|
||||
// Playing again means a fresh board rather than a fresh round, and
|
||||
// it cannot go through advanceIdle: driveIdleSeats returns as soon
|
||||
// as rules.isFinished is true, and a peg board with no jumps left
|
||||
// genuinely is finished. Rebuilding the controller is the honest
|
||||
// way to deal again, and the board is deterministic, so the retry
|
||||
// is the same puzzle.
|
||||
var generation by remember(route) { mutableIntStateOf(0) }
|
||||
val controller = remember(route, generation) {
|
||||
MatchController<PegSolitaireState, PegSolitaireMove>(
|
||||
rules = PegSolitaireRules,
|
||||
// Never consulted while a person holds the only seat.
|
||||
ai = PegSolitaireAi,
|
||||
config = route.config,
|
||||
scope = scope,
|
||||
role = role,
|
||||
localSeats = setOf(route.localSeat),
|
||||
primarySeat = route.localSeat,
|
||||
)
|
||||
}
|
||||
StartMatch(env, controller, route)
|
||||
PegSolitaireScreen(
|
||||
controller = controller,
|
||||
onRestart = { generation++ },
|
||||
onExit = onExit,
|
||||
)
|
||||
}
|
||||
|
||||
GameCatalog.YAHTZEE -> {
|
||||
val controller = remember(route) {
|
||||
MatchController<YahtzeeState, YahtzeeMove>(
|
||||
rules = YahtzeeRules,
|
||||
ai = YahtzeeAi,
|
||||
config = route.config,
|
||||
scope = scope,
|
||||
role = role,
|
||||
localSeats = setOf(route.localSeat),
|
||||
primarySeat = route.localSeat,
|
||||
// Long enough to watch the dice land, short enough that a
|
||||
// six-handed game is not an evening of waiting.
|
||||
aiThinkingMillis = { settings.scaled(500L) },
|
||||
)
|
||||
}
|
||||
StartMatch(env, controller, route)
|
||||
YahtzeeScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||
}
|
||||
|
||||
GameCatalog.KLONDIKE -> {
|
||||
// Dealing again rebuilds the controller, for the same reason peg
|
||||
// solitaire does: a blocked deal is genuinely finished, so
|
||||
// driveIdleSeats has already stopped and advanceIdle never runs.
|
||||
var generation by remember(route) { mutableIntStateOf(0) }
|
||||
val controller = remember(route, generation) {
|
||||
MatchController<KlondikeState, KlondikeMove>(
|
||||
rules = KlondikeRules,
|
||||
// Only ever asked for a hint; the seat is the player's.
|
||||
ai = KlondikeAi,
|
||||
config = route.config,
|
||||
scope = scope,
|
||||
role = role,
|
||||
localSeats = setOf(route.localSeat),
|
||||
primarySeat = route.localSeat,
|
||||
)
|
||||
}
|
||||
StartMatch(env, controller, route)
|
||||
KlondikeScreen(
|
||||
controller = controller,
|
||||
onRestart = { generation++ },
|
||||
onExit = onExit,
|
||||
)
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
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.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Divider
|
||||
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.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
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.Card
|
||||
import org.prolibertate.games.game.hearts.HeartsMove
|
||||
import org.prolibertate.games.game.hearts.HeartsPhase
|
||||
import org.prolibertate.games.game.hearts.HeartsState
|
||||
import org.prolibertate.games.game.hearts.PASS_SIZE
|
||||
import org.prolibertate.games.game.hearts.PassCards
|
||||
import org.prolibertate.games.game.hearts.PlayCard
|
||||
import org.prolibertate.games.game.hearts.pointsOf
|
||||
import org.prolibertate.games.net.MatchController
|
||||
import org.prolibertate.games.ui.ScreenScaffold
|
||||
import org.prolibertate.games.ui.common.PlayingCardView
|
||||
|
||||
private const val TITLE = "Hearts"
|
||||
|
||||
/**
|
||||
* The Hearts table.
|
||||
*
|
||||
* Two quite different things happen at this screen, so it reads the phase and
|
||||
* shows one or the other: choosing three cards to give away, which is a
|
||||
* multiple selection nothing else in the app does, and then playing one card at
|
||||
* a time, which every trick-taking screen here does the same way.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun HeartsScreen(
|
||||
controller: MatchController<HeartsState, HeartsMove>,
|
||||
localSeat: Int,
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val legal by controller.legalMoves.collectAsState()
|
||||
val abandoned by controller.abandoned.collectAsState()
|
||||
val awaiting by controller.awaitingConfirmation.collectAsState()
|
||||
|
||||
val chosen = remember { mutableStateListOf<Card>() }
|
||||
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 { LeftHeartsDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) EndHeartsDialog(onExit) { confirmingEnd = false }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val hand = current.hands.getOrElse(localSeat) { emptyList() }
|
||||
val passing = current.phase == HeartsPhase.PASSING
|
||||
val alreadyPassed = current.passSelections.getOrElse(localSeat) { emptyList() }.isNotEmpty()
|
||||
val playable = legal.filterIsInstance<PlayCard>().map { it.card }.toSet()
|
||||
|
||||
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||
Column(
|
||||
modifier = modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = when {
|
||||
current.phase == HeartsPhase.GAME_OVER -> "Game over — lowest score wins."
|
||||
current.phase == HeartsPhase.ROUND_OVER -> "Round scored."
|
||||
passing && alreadyPassed -> "Waiting for the others to choose…"
|
||||
passing -> "Choose three to pass ${current.passDirection.label}"
|
||||
playable.isEmpty() -> "Waiting…"
|
||||
else -> "Your turn"
|
||||
},
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = "Every heart costs a point and the queen of spades costs thirteen. " +
|
||||
"Take none of them.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
TrickView(current)
|
||||
ScoreRow(current, localSeat)
|
||||
Divider()
|
||||
|
||||
Text(
|
||||
text = if (passing && !alreadyPassed) {
|
||||
"Passing ${current.passDirection.label} — ${chosen.size} of $PASS_SIZE chosen"
|
||||
} else {
|
||||
"Your hand"
|
||||
},
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
hand.forEach { card ->
|
||||
val selectable = passing && !alreadyPassed
|
||||
PlayingCardView(
|
||||
card = card,
|
||||
width = 52.dp,
|
||||
selected = chosen.contains(card),
|
||||
enabled = if (selectable) true else playable.contains(card),
|
||||
caption = pointsOf(card).takeIf { it > 0 }?.let { "−$it" },
|
||||
onClick = {
|
||||
if (selectable) {
|
||||
toggle(chosen, card)
|
||||
} else if (playable.contains(card)) {
|
||||
controller.submit(PlayCard(card))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (passing && !alreadyPassed) {
|
||||
Button(
|
||||
enabled = chosen.size == PASS_SIZE,
|
||||
onClick = {
|
||||
controller.submit(PassCards(chosen.toList()))
|
||||
chosen.clear()
|
||||
},
|
||||
) { Text("Pass these three") }
|
||||
}
|
||||
|
||||
if (awaiting) {
|
||||
Button(onClick = controller::confirmAdvance) { Text("Next round") }
|
||||
}
|
||||
|
||||
current.log.takeLast(3).forEach {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
|
||||
abandoned?.let { LeftHeartsDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) EndHeartsDialog(onExit) { confirmingEnd = false }
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds or removes [card], never letting the selection run past three. */
|
||||
private fun toggle(chosen: SnapshotStateList<Card>, card: Card) {
|
||||
if (chosen.contains(card)) {
|
||||
chosen.remove(card)
|
||||
} else if (chosen.size < PASS_SIZE) {
|
||||
chosen.add(card)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun TrickView(state: HeartsState) {
|
||||
// The trick just swept is held on the table for a moment, so show that when
|
||||
// there is nothing in front of the players yet.
|
||||
val showing = state.trick.ifEmpty { state.completedTrick }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = if (state.trick.isEmpty() && showing.isNotEmpty()) {
|
||||
"Last trick — taken by seat ${state.leader}"
|
||||
} else {
|
||||
"Trick ${state.trickNumber + 1} of ${state.tricksPerRound}"
|
||||
},
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
if (showing.isEmpty()) {
|
||||
Text("Nothing played yet.", style = MaterialTheme.typography.bodySmall)
|
||||
} else {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
showing.forEach { played ->
|
||||
PlayingCardView(
|
||||
card = played.card,
|
||||
width = 46.dp,
|
||||
caption = "seat ${played.seat}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScoreRow(state: HeartsState, localSeat: Int) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
state.scores.forEachIndexed { seat, score ->
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = if (seat == localSeat) "You" else "Seat $seat",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = if (seat == localSeat) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
Text("$score", fontWeight = FontWeight.Bold)
|
||||
// What this round has cost so far, which is the number anybody
|
||||
// actually watches while a hand is being played.
|
||||
Text(
|
||||
text = "+${state.roundScores.getOrElse(seat) { 0 }}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EndHeartsDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onKeepPlaying,
|
||||
title = { Text("End this game?") },
|
||||
text = { Text("The game ends here. Anyone else at the table is told you have left.") },
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LeftHeartsDialog(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,359 @@
|
||||
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.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.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
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.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
|
||||
import org.prolibertate.games.ui.theme.Parchment
|
||||
import org.prolibertate.games.ui.theme.WallaceGold
|
||||
|
||||
private const val TITLE = "Klondike"
|
||||
|
||||
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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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(
|
||||
controller: MatchController<KlondikeState, KlondikeMove>,
|
||||
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<Spot, 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 { LeftTableDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) EndTableDialog(onExit) { confirmingEnd = false }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val moves = legal.filterIsInstance<MoveCards>()
|
||||
val canDraw = legal.contains(Draw)
|
||||
val canRedeal = legal.contains(Redeal)
|
||||
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(spot: Spot, count: Int) {
|
||||
val inHand = held
|
||||
held = when {
|
||||
inHand != null && targets.contains(spot) -> {
|
||||
controller.submit(MoveCards(inHand.first, spot, inHand.second))
|
||||
null
|
||||
}
|
||||
|
||||
inHand?.first == spot -> null
|
||||
sources.contains(spot to count) -> spot 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."
|
||||
},
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
// The homes, along the top where they are being filled towards.
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
current.foundations.forEachIndexed { index, pile ->
|
||||
Slot(
|
||||
highlighted = targets.contains(Spot.foundation(index)),
|
||||
onClick = { take(Spot.foundation(index), 1) },
|
||||
) {
|
||||
pile.lastOrNull()
|
||||
?.let { PlayingCardView(card = it, width = CARD_WIDTH) }
|
||||
?: Empty(label = foundationSuit(index).symbol)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
current.tableau.forEachIndexed { index, pile ->
|
||||
ColumnOfCards(
|
||||
pile = pile,
|
||||
held = held,
|
||||
index = index,
|
||||
isTarget = targets.contains(Spot.tableau(index)),
|
||||
onTap = { count -> take(Spot.tableau(index), count) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The pack sits at the bottom left, under the hand that turns it.
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Slot(
|
||||
highlighted = false,
|
||||
onClick = {
|
||||
if (canDraw) {
|
||||
controller.submit(Draw)
|
||||
} else if (canRedeal) {
|
||||
controller.submit(Redeal)
|
||||
}
|
||||
},
|
||||
) {
|
||||
if (current.stock.isNotEmpty()) {
|
||||
CardBackView(width = CARD_WIDTH)
|
||||
} else {
|
||||
Empty(label = if (canRedeal) "↻" else "")
|
||||
}
|
||||
}
|
||||
WasteFan(
|
||||
waste = current.waste,
|
||||
fanned = minOf(current.options.drawCount, MAX_FANNED),
|
||||
highlighted = held?.first == Spot.waste,
|
||||
onTap = { take(Spot.waste, 1) },
|
||||
)
|
||||
}
|
||||
|
||||
if (finished) {
|
||||
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 { LeftTableDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) EndTableDialog(onExit) { confirmingEnd = false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
)
|
||||
.clickable { onClick() },
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
/** An empty place on the table, drawn so it can still be tapped into. */
|
||||
@Composable
|
||||
private fun Empty(label: String = "") {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(CARD_WIDTH)
|
||||
.height(CARD_HEIGHT)
|
||||
.background(FeltGreenDark, RoundedCornerShape(4.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (label.isNotEmpty()) {
|
||||
Text(label, color = Parchment.copy(alpha = 0.5f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EndTableDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onKeepPlaying,
|
||||
title = { Text("End this game?") },
|
||||
text = { Text("The deal is left as it stands.") },
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LeftTableDialog(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,259 @@
|
||||
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.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
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.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
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.pegsolitaire.Hole
|
||||
import org.prolibertate.games.game.pegsolitaire.PegBoard
|
||||
import org.prolibertate.games.game.pegsolitaire.PegJump
|
||||
import org.prolibertate.games.game.pegsolitaire.PegSolitaireMove
|
||||
import org.prolibertate.games.game.pegsolitaire.PegSolitaireState
|
||||
import org.prolibertate.games.net.MatchController
|
||||
import org.prolibertate.games.ui.ScreenScaffold
|
||||
import org.prolibertate.games.ui.theme.FeltGreenDark
|
||||
import org.prolibertate.games.ui.theme.Parchment
|
||||
import org.prolibertate.games.ui.theme.WallaceGold
|
||||
import org.prolibertate.games.ui.theme.WallaceRed
|
||||
|
||||
private const val TITLE = "Peg Solitaire"
|
||||
|
||||
/**
|
||||
* The peg board.
|
||||
*
|
||||
* One layout serves both board shapes. Each hole is placed at a position in
|
||||
* hole-widths rather than pixels, and a triangle simply offsets its rows by half
|
||||
* a hole so they stagger — which is the same thing that gives a triangular hole
|
||||
* six neighbours instead of four. Scaling that grid to whatever width the phone
|
||||
* has is then the only sizing decision left.
|
||||
*/
|
||||
@Composable
|
||||
fun PegSolitaireScreen(
|
||||
controller: MatchController<PegSolitaireState, PegSolitaireMove>,
|
||||
onRestart: () -> Unit,
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val legal by controller.legalMoves.collectAsState()
|
||||
val abandoned by controller.abandoned.collectAsState()
|
||||
|
||||
var selected by remember { mutableStateOf<Hole?>(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("Setting up…") }
|
||||
abandoned?.let { LeftBoardDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) EndBoardDialog(onExit) { confirmingEnd = false }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val jumps = legal.filterIsInstance<PegJump>()
|
||||
val movable = jumps.map { it.from }.toSet()
|
||||
val landings = selected?.let { from -> jumps.filter { it.from == from } }.orEmpty()
|
||||
val destinations = landings.map { it.to }.toSet()
|
||||
val finished = jumps.isEmpty()
|
||||
|
||||
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||
Column(
|
||||
modifier = modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = when {
|
||||
current.solved -> "Solved, in ${current.jumps} jumps."
|
||||
finished -> "Stuck with ${current.remaining} pegs."
|
||||
selected != null -> "Tap a ringed hole to jump into it."
|
||||
else -> "Tap a peg that can jump."
|
||||
},
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = "${current.board.label}. ${current.remaining} pegs left, " +
|
||||
"${current.jumps} jumps made.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
Board(
|
||||
state = current,
|
||||
selected = selected,
|
||||
movable = movable,
|
||||
destinations = destinations,
|
||||
onTap = { hole ->
|
||||
selected = when {
|
||||
// Completing a jump takes priority: the tapped hole is
|
||||
// one the selected peg was offered.
|
||||
selected != null && destinations.contains(hole) -> {
|
||||
controller.submit(PegJump(from = selected!!, to = hole))
|
||||
null
|
||||
}
|
||||
|
||||
movable.contains(hole) -> hole
|
||||
else -> null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (finished) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = onRestart) { Text("Play again") }
|
||||
TextButton(onClick = onExit) { Text("Leave") }
|
||||
}
|
||||
}
|
||||
|
||||
current.log.takeLast(3).forEach {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
|
||||
abandoned?.let { LeftBoardDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) EndBoardDialog(onExit) { confirmingEnd = false }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Board(
|
||||
state: PegSolitaireState,
|
||||
selected: Hole?,
|
||||
movable: Set<Hole>,
|
||||
destinations: Set<Hole>,
|
||||
onTap: (Hole) -> Unit,
|
||||
) {
|
||||
val board = state.board
|
||||
val holes = remember(board) { board.holes().toList() }
|
||||
val places = remember(board) { holes.associateWith { placeOf(board, it) } }
|
||||
val xs = places.values.map { it.first }
|
||||
val ys = places.values.map { it.second }
|
||||
val minX = xs.min()
|
||||
val minY = ys.min()
|
||||
val spanX = xs.max() - minX
|
||||
val spanY = ys.max() - minY
|
||||
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||
// One extra hole-width so the last column has somewhere to sit.
|
||||
val cell: Dp = maxWidth / (spanX + 1f)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(cell * (spanY + 1f))
|
||||
.background(FeltGreenDark),
|
||||
) {
|
||||
holes.forEach { hole ->
|
||||
val (x, y) = places.getValue(hole)
|
||||
HoleView(
|
||||
filled = state.pegs.contains(hole),
|
||||
selected = hole == selected,
|
||||
canMove = movable.contains(hole),
|
||||
isDestination = destinations.contains(hole),
|
||||
size = cell,
|
||||
modifier = Modifier.offset(x = cell * (x - minX), y = cell * (y - minY)),
|
||||
onTap = { onTap(hole) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a hole sits, in hole-widths from the board's top left.
|
||||
*
|
||||
* The half-hole shift on a triangle is the whole difference between the two
|
||||
* geometries as far as drawing is concerned.
|
||||
*/
|
||||
private fun placeOf(board: PegBoard, hole: Hole): Pair<Float, Float> =
|
||||
if (board.isTriangular) {
|
||||
(hole.col - hole.row / 2f) to hole.row.toFloat()
|
||||
} else {
|
||||
hole.col.toFloat() to hole.row.toFloat()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HoleView(
|
||||
filled: Boolean,
|
||||
selected: Boolean,
|
||||
canMove: Boolean,
|
||||
isDestination: Boolean,
|
||||
size: Dp,
|
||||
modifier: Modifier,
|
||||
onTap: () -> Unit,
|
||||
) {
|
||||
Box(modifier = modifier.size(size), contentAlignment = Alignment.Center) {
|
||||
val diameter = size * 0.72f
|
||||
val ring = when {
|
||||
selected -> WallaceGold
|
||||
isDestination -> WallaceGold
|
||||
else -> Parchment.copy(alpha = 0.35f)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(diameter)
|
||||
.background(
|
||||
color = if (filled) WallaceRed else FeltGreenDark,
|
||||
shape = CircleShape,
|
||||
)
|
||||
.border(
|
||||
// A destination is drawn as an empty ring rather than filled,
|
||||
// so it never reads as a peg already sitting there.
|
||||
border = BorderStroke(if (selected || isDestination) 3.dp else 1.dp, ring),
|
||||
shape = CircleShape,
|
||||
)
|
||||
.clickable(enabled = filled && canMove || isDestination) { onTap() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EndBoardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onKeepPlaying,
|
||||
title = { Text("End this game?") },
|
||||
text = { Text("The board is left as it stands.") },
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LeftBoardDialog(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 board") } },
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Divider
|
||||
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.mutableStateListOf
|
||||
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.yahtzee.DICE_COUNT
|
||||
import org.prolibertate.games.game.yahtzee.RollDice
|
||||
import org.prolibertate.games.game.yahtzee.ScoreIn
|
||||
import org.prolibertate.games.game.yahtzee.YahtzeeCategory
|
||||
import org.prolibertate.games.game.yahtzee.YahtzeeMove
|
||||
import org.prolibertate.games.game.yahtzee.YahtzeeSection
|
||||
import org.prolibertate.games.game.yahtzee.YahtzeeState
|
||||
import org.prolibertate.games.game.yahtzee.scoreOf
|
||||
import org.prolibertate.games.net.MatchController
|
||||
import org.prolibertate.games.ui.ScreenScaffold
|
||||
import org.prolibertate.games.ui.theme.Parchment
|
||||
import org.prolibertate.games.ui.theme.WallaceGold
|
||||
import org.prolibertate.games.ui.theme.WallaceRed
|
||||
|
||||
private const val TITLE = "Yahtzee"
|
||||
|
||||
/**
|
||||
* The Yahtzee card.
|
||||
*
|
||||
* A turn is two decisions and the screen shows them together: which dice to keep
|
||||
* before throwing again, and which box to write in when the throwing stops. Each
|
||||
* open box shows what the dice on the table would score in it, because that
|
||||
* comparison is the whole of the choice — and doing the arithmetic by eye is not
|
||||
* what anyone wants from a phone.
|
||||
*/
|
||||
@Composable
|
||||
fun YahtzeeScreen(
|
||||
controller: MatchController<YahtzeeState, YahtzeeMove>,
|
||||
localSeat: Int,
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val legal by controller.legalMoves.collectAsState()
|
||||
val abandoned by controller.abandoned.collectAsState()
|
||||
|
||||
val kept = remember { mutableStateListOf<Int>() }
|
||||
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("Setting up…") }
|
||||
abandoned?.let { LeftCardDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) EndCardDialog(onExit) { confirmingEnd = false }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val yours = current.turn == localSeat && !current.isOver
|
||||
val canRoll = legal.any { it is RollDice }
|
||||
val writable = legal.filterIsInstance<ScoreIn>().map { it.category }.toSet()
|
||||
|
||||
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||
Column(
|
||||
modifier = modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = when {
|
||||
current.isOver -> "Cards full."
|
||||
!yours -> "Seat ${current.turn} is throwing…"
|
||||
!current.hasRolled -> "Your turn — throw the dice."
|
||||
else -> "${current.rollsLeft} throws left, or write it down."
|
||||
},
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
DiceRow(
|
||||
dice = current.dice,
|
||||
kept = kept,
|
||||
enabled = yours && current.hasRolled && canRoll,
|
||||
onToggle = { index ->
|
||||
if (kept.contains(index)) kept.remove(index) else kept.add(index)
|
||||
},
|
||||
)
|
||||
|
||||
if (yours && canRoll) {
|
||||
Button(
|
||||
onClick = {
|
||||
controller.submit(RollDice(kept.toList()))
|
||||
// What was kept applied to the throw just made; the next
|
||||
// one starts from whatever is on the table now.
|
||||
kept.clear()
|
||||
},
|
||||
) {
|
||||
Text(if (current.hasRolled) "Throw the rest" else "Throw")
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
ScoreCard(
|
||||
state = current,
|
||||
localSeat = localSeat,
|
||||
writable = if (yours) writable else emptySet(),
|
||||
onWrite = {
|
||||
controller.submit(ScoreIn(it))
|
||||
kept.clear()
|
||||
},
|
||||
)
|
||||
|
||||
current.log.takeLast(3).forEach {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
|
||||
abandoned?.let { LeftCardDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) EndCardDialog(onExit) { confirmingEnd = false }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiceRow(
|
||||
dice: List<Int>,
|
||||
kept: List<Int>,
|
||||
enabled: Boolean,
|
||||
onToggle: (Int) -> Unit,
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (dice.isEmpty()) {
|
||||
repeat(DICE_COUNT) { Die(face = null, held = false, enabled = false) {} }
|
||||
} else {
|
||||
dice.forEachIndexed { index, face ->
|
||||
Die(
|
||||
face = face,
|
||||
held = kept.contains(index),
|
||||
enabled = enabled,
|
||||
onClick = { onToggle(index) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Die(face: Int?, held: Boolean, enabled: Boolean, onClick: () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.background(if (face == null) Parchment.copy(alpha = 0.3f) else Parchment, RoundedCornerShape(8.dp))
|
||||
.border(
|
||||
width = if (held) 3.dp else 1.dp,
|
||||
color = if (held) WallaceGold else WallaceRed.copy(alpha = 0.4f),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
)
|
||||
.clickable(enabled = enabled) { onClick() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = face?.toString() ?: "·",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = WallaceRed,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScoreCard(
|
||||
state: YahtzeeState,
|
||||
localSeat: Int,
|
||||
writable: Set<YahtzeeCategory>,
|
||||
onWrite: (YahtzeeCategory) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text("", modifier = Modifier.weight(2f), style = MaterialTheme.typography.labelSmall)
|
||||
state.cards.indices.forEach { seat ->
|
||||
Text(
|
||||
text = if (seat == localSeat) "You" else "S$seat",
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
YahtzeeSection.entries.forEach { section ->
|
||||
YahtzeeCategory.entries.filter { it.section == section }.forEach { category ->
|
||||
CardRow(state, localSeat, category, writable, onWrite)
|
||||
}
|
||||
if (section == YahtzeeSection.UPPER) {
|
||||
TotalRow("Bonus at 63", state.cards.map { it.upperBonus })
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
TotalRow("Total", state.cards.indices.map { state.totalFor(it) }, bold = true)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CardRow(
|
||||
state: YahtzeeState,
|
||||
localSeat: Int,
|
||||
category: YahtzeeCategory,
|
||||
writable: Set<YahtzeeCategory>,
|
||||
onWrite: (YahtzeeCategory) -> Unit,
|
||||
) {
|
||||
val open = writable.contains(category)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = open) { onWrite(category) }
|
||||
.padding(vertical = 2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = category.label,
|
||||
modifier = Modifier.weight(2f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = if (open) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
state.cards.forEachIndexed { seat, card ->
|
||||
val written = card[category]
|
||||
Text(
|
||||
text = when {
|
||||
written != null -> "$written"
|
||||
// What this throw would be worth here, which is the only
|
||||
// number the decision actually turns on.
|
||||
seat == localSeat && open -> "(${scoreOf(category, state.dice)})"
|
||||
else -> "—"
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (written == null && open) WallaceRed else MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TotalRow(label: String, values: List<Int>, bold: Boolean = false) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.weight(2f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
values.forEach {
|
||||
Text(
|
||||
text = "$it",
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EndCardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onKeepPlaying,
|
||||
title = { Text("End this game?") },
|
||||
text = { Text("The game ends here. Anyone else at the table is told you have left.") },
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LeftCardDialog(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 card") } },
|
||||
)
|
||||
}
|
||||
@@ -16,21 +16,54 @@ class CatalogTest {
|
||||
|
||||
@Test
|
||||
fun `a production build lists only games that can be played`() {
|
||||
GameCategory.entries.forEach { category ->
|
||||
val shown = GameCatalog.byCategory(category, includeComingSoon = false)
|
||||
GameMenu.entries.forEach { menu ->
|
||||
val shown = GameCatalog.inMenu(menu, includeComingSoon = false)
|
||||
assertTrue(
|
||||
"$category offers an unfinished game in a release build",
|
||||
"$menu offers an unfinished game in a release build",
|
||||
shown.all { it.available },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a dev build lists the whole catalogue`() {
|
||||
val shown = GameCategory.entries.flatMap {
|
||||
GameCatalog.byCategory(it, includeComingSoon = true)
|
||||
fun `every game is reachable from at least one menu`() {
|
||||
// Counting is no good now that a game can be in several menus: the
|
||||
// union is what matters, and a game in none of them would be in the
|
||||
// catalogue but on no screen.
|
||||
val reachable = GameMenu.entries.flatMap {
|
||||
GameCatalog.inMenu(it, includeComingSoon = true)
|
||||
}.toSet()
|
||||
assertEquals(GameCatalog.all.toSet(), reachable)
|
||||
GameCatalog.all.forEach {
|
||||
assertTrue("${it.id} is in no menu", it.menus.isNotEmpty())
|
||||
}
|
||||
assertEquals(GameCatalog.all.size, shown.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a game can be in more than one menu`() {
|
||||
// The whole point of the set. A patience is solitaire and a card game;
|
||||
// Euchre is a card game and a trick-taking one.
|
||||
val klondike = GameCatalog.byId(GameCatalog.KLONDIKE)!!
|
||||
assertTrue(klondike.menus.containsAll(setOf(GameMenu.SOLITAIRE, GameMenu.CARDS)))
|
||||
assertTrue(GameCatalog.inMenu(GameMenu.SOLITAIRE).contains(klondike))
|
||||
assertTrue(GameCatalog.inMenu(GameMenu.CARDS).contains(klondike))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `trick-taking sits under cards, and its games are card games too`() {
|
||||
assertEquals(GameMenu.CARDS, GameMenu.TRICK_TAKING.parent)
|
||||
assertEquals(listOf(GameMenu.TRICK_TAKING), GameMenu.CARDS.children)
|
||||
assertTrue(GameMenu.TRICK_TAKING.isTopLevel.not())
|
||||
assertTrue(GameMenu.SOLITAIRE.isTopLevel)
|
||||
|
||||
val tricks = GameCatalog.inMenu(GameMenu.TRICK_TAKING)
|
||||
assertTrue("there should be some", tricks.isNotEmpty())
|
||||
// A sub-menu that listed something its parent did not would be a game
|
||||
// you could reach one way and not the other.
|
||||
assertTrue(
|
||||
"every trick-taking game is also a card game",
|
||||
tricks.all { it.menus.contains(GameMenu.CARDS) },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -42,19 +75,19 @@ class CatalogTest {
|
||||
// engine is written.
|
||||
assertTrue(GameCatalog.all.all { it.available })
|
||||
assertEquals(
|
||||
GameCatalog.byCategory(GameCategory.BOARD, includeComingSoon = false),
|
||||
GameCatalog.byCategory(GameCategory.BOARD, includeComingSoon = true),
|
||||
GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = false),
|
||||
GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hiding unfinished games still leaves something to play in each category`() {
|
||||
// A category filtered down to nothing would leave a stranded heading,
|
||||
// and a menu with no playable game at all would be a broken release.
|
||||
GameCategory.entries.forEach { category ->
|
||||
assertFalse(
|
||||
"$category has nothing playable in a release build",
|
||||
GameCatalog.byCategory(category, includeComingSoon = false).isEmpty(),
|
||||
fun `hiding unfinished games still leaves something to play in every menu`() {
|
||||
// A menu filtered down to nothing would leave a stranded heading, and a
|
||||
// release where a whole section was empty would be a broken one.
|
||||
GameMenu.entries.forEach { menu ->
|
||||
assertTrue(
|
||||
"$menu has nothing playable in a release build",
|
||||
GameCatalog.hasAnything(menu, includeComingSoon = false),
|
||||
)
|
||||
}
|
||||
assertTrue(GameCatalog.playable.isNotEmpty())
|
||||
@@ -71,6 +104,13 @@ class CatalogTest {
|
||||
GameCatalog.KAISER,
|
||||
GameCatalog.CRAZY_EIGHTS,
|
||||
GameCatalog.CRIBBAGE,
|
||||
GameCatalog.HEARTS,
|
||||
GameCatalog.PEG_SOLITAIRE,
|
||||
GameCatalog.YAHTZEE,
|
||||
GameCatalog.KLONDIKE,
|
||||
GameCatalog.FREECELL,
|
||||
GameCatalog.SPIDER,
|
||||
GameCatalog.PYRAMID,
|
||||
GameCatalog.WIZARD,
|
||||
GameCatalog.CHESS,
|
||||
GameCatalog.TAYU,
|
||||
@@ -87,7 +127,11 @@ class CatalogTest {
|
||||
@Test
|
||||
fun `player counts are sane`() {
|
||||
GameCatalog.all.forEach { game ->
|
||||
assertTrue("${game.id} min players", game.minPlayers >= 2)
|
||||
// One, not two. Peg solitaire is played alone, and the engine took
|
||||
// that without changes — one seat, always on the clock, nothing to
|
||||
// redact. Everything downstream reads the seat count from here, so
|
||||
// this is the only place that had to allow it.
|
||||
assertTrue("${game.id} min players", game.minPlayers >= 1)
|
||||
assertTrue("${game.id} max >= min", game.maxPlayers >= game.minPlayers)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
package org.prolibertate.games.game.hearts
|
||||
|
||||
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
|
||||
|
||||
class HeartsRulesTest {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private fun config(options: HeartsOptions = HeartsOptions(), seed: Long = 7L) = TableConfig(
|
||||
gameId = "hearts",
|
||||
seats = (0 until options.playerCount).map {
|
||||
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
|
||||
},
|
||||
optionsJson = json.encodeToString(options),
|
||||
seed = seed,
|
||||
)
|
||||
|
||||
private fun c(rank: Rank, suit: Suit) = Card(rank, suit)
|
||||
|
||||
/** A state part way through a round, with passing already done. */
|
||||
private fun playing(
|
||||
hands: List<List<Card>>,
|
||||
turn: Int = 0,
|
||||
leader: Int = 0,
|
||||
trick: List<PlayedCard> = emptyList(),
|
||||
trickNumber: Int = 1,
|
||||
heartsBroken: Boolean = false,
|
||||
taken: List<List<Card>> = List(hands.size) { emptyList() },
|
||||
options: HeartsOptions = HeartsOptions(),
|
||||
) = HeartsState(
|
||||
options = options,
|
||||
seed = 1L,
|
||||
round = 0,
|
||||
phase = HeartsPhase.PLAYING,
|
||||
hands = hands,
|
||||
handCounts = hands.map { it.size },
|
||||
passSelections = List(hands.size) { emptyList() },
|
||||
turn = turn,
|
||||
leader = leader,
|
||||
trickNumber = trickNumber,
|
||||
trick = trick,
|
||||
taken = taken,
|
||||
heartsBroken = heartsBroken,
|
||||
scores = List(hands.size) { 0 },
|
||||
roundScores = List(hands.size) { 0 },
|
||||
log = emptyList(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a deal gives four hands of thirteen from one pack`() {
|
||||
val state = HeartsRules.initialState(config())
|
||||
assertEquals(4, state.hands.size)
|
||||
state.hands.forEach { assertEquals(13, it.size) }
|
||||
val all = state.hands.flatten()
|
||||
assertEquals(52, all.size)
|
||||
assertEquals("no card is dealt twice", 52, all.distinct().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the pass direction cycles and the fourth round holds`() {
|
||||
assertEquals(PassDirection.LEFT, PassDirection.forRound(0))
|
||||
assertEquals(PassDirection.RIGHT, PassDirection.forRound(1))
|
||||
assertEquals(PassDirection.ACROSS, PassDirection.forRound(2))
|
||||
assertEquals(PassDirection.HOLD, PassDirection.forRound(3))
|
||||
assertEquals(PassDirection.LEFT, PassDirection.forRound(4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a hold round opens straight onto the lead`() {
|
||||
// Round three holds, so there is nothing to swap and play begins at once
|
||||
// with whoever holds the two of clubs.
|
||||
var state = HeartsRules.initialState(config())
|
||||
// Walk to round three through the public entry point rather than
|
||||
// reaching into the private deal.
|
||||
repeat(3) {
|
||||
state = HeartsRules.startNextRound(state.copy(phase = HeartsPhase.ROUND_OVER))
|
||||
}
|
||||
assertEquals(3, state.round)
|
||||
assertEquals(PassDirection.HOLD, state.passDirection)
|
||||
assertEquals(HeartsPhase.PLAYING, state.phase)
|
||||
assertTrue(state.hands[state.turn].contains(TWO_OF_CLUBS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `passing left moves three cards to the next seat`() {
|
||||
var state = HeartsRules.initialState(config())
|
||||
assertEquals(HeartsPhase.PASSING, state.phase)
|
||||
val given = (0 until 4).map { state.hands[it].take(PASS_SIZE) }
|
||||
for (seat in 0 until 4) {
|
||||
assertEquals(seat, HeartsRules.currentSeat(state))
|
||||
state = HeartsRules.applyMove(state, seat, PassCards(given[seat]))
|
||||
}
|
||||
assertEquals(HeartsPhase.PLAYING, state.phase)
|
||||
for (seat in 0 until 4) {
|
||||
val receiver = (seat + 1) % 4
|
||||
assertTrue(
|
||||
"seat $receiver should hold what seat $seat passed",
|
||||
state.hands[receiver].containsAll(given[seat]),
|
||||
)
|
||||
assertFalse(
|
||||
"seat $seat should not still hold what it passed",
|
||||
state.hands[seat].any { given[seat].contains(it) },
|
||||
)
|
||||
}
|
||||
state.hands.forEach { assertEquals(13, it.size) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pass must be three distinct cards from the hand`() {
|
||||
val state = HeartsRules.initialState(config())
|
||||
val hand = state.hands[0]
|
||||
assertThrows { HeartsRules.applyMove(state, 0, PassCards(hand.take(2))) }
|
||||
assertThrows { HeartsRules.applyMove(state, 0, PassCards(listOf(hand[0], hand[0], hand[1]))) }
|
||||
// A card this seat does not hold — it is in somebody else's thirteen.
|
||||
val notHeld = state.hands[1].first()
|
||||
assertThrows { HeartsRules.applyMove(state, 0, PassCards(listOf(notHeld) + hand.take(2))) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the two of clubs opens the round`() {
|
||||
val hands = listOf(
|
||||
listOf(c(Rank.TWO, Suit.CLUBS), c(Rank.KING, Suit.SPADES)),
|
||||
listOf(c(Rank.THREE, Suit.CLUBS), c(Rank.FOUR, Suit.HEARTS)),
|
||||
listOf(c(Rank.FIVE, Suit.CLUBS), c(Rank.SIX, Suit.DIAMONDS)),
|
||||
listOf(c(Rank.SEVEN, Suit.CLUBS), c(Rank.EIGHT, Suit.DIAMONDS)),
|
||||
)
|
||||
val state = playing(hands, trickNumber = 0)
|
||||
assertEquals(listOf(TWO_OF_CLUBS), HeartsRules.playableCards(state, 0))
|
||||
assertThrows { HeartsRules.applyMove(state, 0, PlayCard(c(Rank.KING, Suit.SPADES))) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a seat holding the led suit must follow it`() {
|
||||
val hands = listOf(
|
||||
listOf(c(Rank.TWO, Suit.CLUBS)),
|
||||
listOf(c(Rank.THREE, Suit.CLUBS), c(Rank.ACE, Suit.HEARTS)),
|
||||
listOf(c(Rank.FIVE, Suit.CLUBS)),
|
||||
listOf(c(Rank.SEVEN, Suit.CLUBS)),
|
||||
)
|
||||
val state = playing(
|
||||
hands,
|
||||
turn = 1,
|
||||
trick = listOf(PlayedCard(0, c(Rank.TWO, Suit.CLUBS))),
|
||||
)
|
||||
assertEquals(listOf(c(Rank.THREE, Suit.CLUBS)), HeartsRules.playableCards(state, 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no point card may be discarded on the first trick`() {
|
||||
val hands = listOf(
|
||||
listOf(c(Rank.TWO, Suit.CLUBS)),
|
||||
// Void in clubs, holding the queen, a heart, and one harmless card.
|
||||
listOf(QUEEN_OF_SPADES, c(Rank.ACE, Suit.HEARTS), c(Rank.NINE, Suit.DIAMONDS)),
|
||||
listOf(c(Rank.FIVE, Suit.CLUBS)),
|
||||
listOf(c(Rank.SEVEN, Suit.CLUBS)),
|
||||
)
|
||||
val state = playing(
|
||||
hands,
|
||||
turn = 1,
|
||||
trickNumber = 0,
|
||||
trick = listOf(PlayedCard(0, c(Rank.TWO, Suit.CLUBS))),
|
||||
)
|
||||
assertEquals(listOf(c(Rank.NINE, Suit.DIAMONDS)), HeartsRules.playableCards(state, 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the first trick ban lifts when a seat holds nothing but points`() {
|
||||
val hands = listOf(
|
||||
listOf(c(Rank.TWO, Suit.CLUBS)),
|
||||
listOf(QUEEN_OF_SPADES, c(Rank.ACE, Suit.HEARTS)),
|
||||
listOf(c(Rank.FIVE, Suit.CLUBS)),
|
||||
listOf(c(Rank.SEVEN, Suit.CLUBS)),
|
||||
)
|
||||
val state = playing(
|
||||
hands,
|
||||
turn = 1,
|
||||
trickNumber = 0,
|
||||
trick = listOf(PlayedCard(0, c(Rank.TWO, Suit.CLUBS))),
|
||||
)
|
||||
assertEquals(
|
||||
setOf(QUEEN_OF_SPADES, c(Rank.ACE, Suit.HEARTS)),
|
||||
HeartsRules.playableCards(state, 1).toSet(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hearts cannot be led until one has been played`() {
|
||||
val hands = listOf(
|
||||
listOf(c(Rank.ACE, Suit.HEARTS), c(Rank.THREE, Suit.CLUBS)),
|
||||
listOf(c(Rank.FOUR, Suit.CLUBS)),
|
||||
listOf(c(Rank.FIVE, Suit.CLUBS)),
|
||||
listOf(c(Rank.SIX, Suit.CLUBS)),
|
||||
)
|
||||
val unbroken = playing(hands, heartsBroken = false)
|
||||
assertEquals(listOf(c(Rank.THREE, Suit.CLUBS)), HeartsRules.playableCards(unbroken, 0))
|
||||
|
||||
val broken = playing(hands, heartsBroken = true)
|
||||
assertEquals(2, HeartsRules.playableCards(broken, 0).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hearts may be led when they are all that is left`() {
|
||||
val hands = listOf(
|
||||
listOf(c(Rank.ACE, Suit.HEARTS), c(Rank.TWO, Suit.HEARTS)),
|
||||
listOf(c(Rank.FOUR, Suit.CLUBS)),
|
||||
listOf(c(Rank.FIVE, Suit.CLUBS)),
|
||||
listOf(c(Rank.SIX, Suit.CLUBS)),
|
||||
)
|
||||
val state = playing(hands, heartsBroken = false)
|
||||
assertEquals(2, HeartsRules.playableCards(state, 0).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the highest card of the led suit takes the trick`() {
|
||||
val hands = listOf(
|
||||
listOf(c(Rank.TWO, Suit.CLUBS)),
|
||||
listOf(c(Rank.KING, Suit.CLUBS)),
|
||||
// An ace off suit cannot win, however high.
|
||||
listOf(c(Rank.ACE, Suit.DIAMONDS)),
|
||||
listOf(c(Rank.FIVE, Suit.CLUBS)),
|
||||
)
|
||||
var state = playing(hands)
|
||||
state = HeartsRules.applyMove(state, 0, PlayCard(c(Rank.TWO, Suit.CLUBS)))
|
||||
state = HeartsRules.applyMove(state, 1, PlayCard(c(Rank.KING, Suit.CLUBS)))
|
||||
state = HeartsRules.applyMove(state, 2, PlayCard(c(Rank.ACE, Suit.DIAMONDS)))
|
||||
state = HeartsRules.applyMove(state, 3, PlayCard(c(Rank.FIVE, Suit.CLUBS)))
|
||||
assertEquals("king of clubs takes it", 1, state.leader)
|
||||
assertEquals(4, state.taken[1].size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `playing a heart breaks them`() {
|
||||
val hands = listOf(
|
||||
listOf(c(Rank.TWO, Suit.CLUBS)),
|
||||
listOf(c(Rank.ACE, Suit.HEARTS)),
|
||||
listOf(c(Rank.FIVE, Suit.CLUBS)),
|
||||
listOf(c(Rank.SEVEN, Suit.CLUBS)),
|
||||
)
|
||||
var state = playing(hands)
|
||||
assertFalse(state.heartsBroken)
|
||||
state = HeartsRules.applyMove(state, 0, PlayCard(c(Rank.TWO, Suit.CLUBS)))
|
||||
state = HeartsRules.applyMove(state, 1, PlayCard(c(Rank.ACE, Suit.HEARTS)))
|
||||
assertTrue(state.heartsBroken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hearts cost a point each and the queen costs thirteen`() {
|
||||
assertEquals(1, pointsOf(c(Rank.TWO, Suit.HEARTS)))
|
||||
assertEquals(1, pointsOf(c(Rank.ACE, Suit.HEARTS)))
|
||||
assertEquals(13, pointsOf(QUEEN_OF_SPADES))
|
||||
assertEquals(0, pointsOf(c(Rank.KING, Suit.SPADES)))
|
||||
assertEquals(0, pointsOf(c(Rank.ACE, Suit.DIAMONDS)))
|
||||
// The whole pack is worth twenty-six and nothing more.
|
||||
assertEquals(ALL_POINTS, org.prolibertate.games.game.cards.Decks.standard52().sumOf { pointsOf(it) })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an ordinary round shares out twenty-six points`() {
|
||||
val hearts = Rank.standard.map { Card(it, Suit.HEARTS) }
|
||||
val taken = listOf(
|
||||
hearts.take(6),
|
||||
hearts.drop(6) + QUEEN_OF_SPADES,
|
||||
emptyList(),
|
||||
emptyList(),
|
||||
)
|
||||
val scored = scoreRound(taken, allowShootTheMoon = true)
|
||||
assertEquals(listOf(6, 20, 0, 0), scored)
|
||||
assertEquals(ALL_POINTS, scored.sum())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shooting the moon scores everyone else instead`() {
|
||||
val everything = Rank.standard.map { Card(it, Suit.HEARTS) } + QUEEN_OF_SPADES
|
||||
val taken = listOf(emptyList(), everything, emptyList<Card>(), emptyList())
|
||||
assertEquals(listOf(26, 0, 26, 26), scoreRound(taken, allowShootTheMoon = true))
|
||||
// Switched off, a moon is simply the worst hand at the table.
|
||||
assertEquals(listOf(0, 26, 0, 0), scoreRound(taken, allowShootTheMoon = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a client sees neither other hands nor what they are passing`() {
|
||||
var state = HeartsRules.initialState(config())
|
||||
state = HeartsRules.applyMove(state, 0, PassCards(state.hands[0].take(PASS_SIZE)))
|
||||
val view = HeartsRules.viewFor(state, 0)
|
||||
assertEquals(state.hands[0], view.hands[0])
|
||||
assertTrue("other hands are stripped", view.hands.drop(1).all { it.isEmpty() })
|
||||
assertEquals("counts survive so backs can be drawn", state.handCounts, view.handCounts)
|
||||
assertEquals(state.passSelections[0], view.passSelections[0])
|
||||
assertTrue(view.passSelections.drop(1).all { it.isEmpty() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state and moves survive a round trip`() {
|
||||
val state = HeartsRules.initialState(config())
|
||||
val decoded = HeartsRules.decodeState(HeartsRules.encodeState(state))
|
||||
assertEquals(state, decoded)
|
||||
|
||||
val pass: HeartsMove = PassCards(state.hands[0].take(PASS_SIZE))
|
||||
assertEquals(pass, HeartsRules.decodeMove(HeartsRules.encodeMove(pass)))
|
||||
val play: HeartsMove = PlayCard(TWO_OF_CLUBS)
|
||||
assertEquals(play, HeartsRules.decodeMove(HeartsRules.encodeMove(play)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nobody is on the clock once the game is over`() {
|
||||
val state = HeartsRules.initialState(config()).copy(phase = HeartsPhase.GAME_OVER)
|
||||
assertNull(HeartsRules.currentSeat(state))
|
||||
assertTrue(HeartsRules.legalMoves(state, 0).isEmpty())
|
||||
assertTrue(HeartsRules.isFinished(state))
|
||||
}
|
||||
|
||||
private fun assertThrows(block: () -> Unit) {
|
||||
try {
|
||||
block()
|
||||
throw AssertionError("expected the move to be rejected")
|
||||
} catch (expected: IllegalArgumentException) {
|
||||
// The rules rejected it, which is the point.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package org.prolibertate.games.game.klondike
|
||||
|
||||
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.solitaire.acceptsOnFoundation
|
||||
import org.prolibertate.games.game.solitaire.patienceOrder
|
||||
|
||||
class KlondikeRulesTest {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private fun config(options: KlondikeOptions = KlondikeOptions(), seed: Long = 5L) = TableConfig(
|
||||
gameId = "klondike",
|
||||
seats = listOf(PlayerSlot(seat = 0, name = "P0", kind = PlayerKind.HUMAN_LOCAL, team = 0)),
|
||||
optionsJson = json.encodeToString(options),
|
||||
seed = seed,
|
||||
)
|
||||
|
||||
private fun c(rank: Rank, suit: Suit) = Card(rank, suit)
|
||||
|
||||
// ---- the deal ----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the deal is seven columns and a stock of twenty-four`() {
|
||||
val state = KlondikeRules.initialState(config())
|
||||
assertEquals(TABLEAU_PILES, state.tableau.size)
|
||||
state.tableau.forEachIndexed { index, pile ->
|
||||
assertEquals("column ${index + 1}", index + 1, pile.faceDown.size + pile.faceUp.size)
|
||||
assertEquals("only the last card is turned up", 1, pile.faceUp.size)
|
||||
}
|
||||
assertEquals(24, state.stock.size)
|
||||
assertTrue(state.waste.isEmpty())
|
||||
assertTrue(state.foundations.all { it.isEmpty() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the pack is dealt whole and no card is dealt twice`() {
|
||||
val state = KlondikeRules.initialState(config())
|
||||
val all = state.stock + state.waste + state.tableau.flatMap { it.faceDown + it.faceUp }
|
||||
assertEquals(52, all.size)
|
||||
assertEquals(52, all.distinct().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a table for more than one seat is refused`() {
|
||||
val two = TableConfig(
|
||||
gameId = "klondike",
|
||||
seats = (0..1).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
|
||||
optionsJson = json.encodeToString(KlondikeOptions()),
|
||||
seed = 1L,
|
||||
)
|
||||
assertThrows { KlondikeRules.initialState(two) }
|
||||
}
|
||||
|
||||
// ---- stock and waste ---------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `drawing one turns one card up`() {
|
||||
val state = KlondikeRules.initialState(config(KlondikeOptions(drawCount = 1)))
|
||||
val drawn = KlondikeRules.applyMove(state, 0, Draw)
|
||||
assertEquals(23, drawn.stock.size)
|
||||
assertEquals(1, drawn.waste.size)
|
||||
assertEquals("the top of the stock is the card turned", state.stock.last(), drawn.waste.last())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drawing three leaves the deepest of them showing`() {
|
||||
// Turning a packet of three over reverses it, so the card that was
|
||||
// furthest down is the one in play.
|
||||
val state = KlondikeRules.initialState(config(KlondikeOptions(drawCount = 3)))
|
||||
val drawn = KlondikeRules.applyMove(state, 0, Draw)
|
||||
assertEquals(21, drawn.stock.size)
|
||||
assertEquals(3, drawn.waste.size)
|
||||
val top3 = state.stock.takeLast(3)
|
||||
assertEquals(top3.reversed(), drawn.waste)
|
||||
assertEquals(top3.first(), drawn.waste.last())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the waste turns back over into a new stock`() {
|
||||
var state = KlondikeRules.initialState(config(KlondikeOptions(drawCount = 3)))
|
||||
repeat(8) { state = KlondikeRules.applyMove(state, 0, Draw) }
|
||||
assertTrue(state.stock.isEmpty())
|
||||
assertEquals(24, state.waste.size)
|
||||
assertTrue(state.canRedeal)
|
||||
|
||||
val wasteBefore = state.waste
|
||||
val turned = KlondikeRules.applyMove(state, 0, Redeal)
|
||||
assertEquals(wasteBefore.reversed(), turned.stock)
|
||||
assertTrue(turned.waste.isEmpty())
|
||||
assertEquals(1, turned.redealsUsed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a redeal limit of zero means one pass through the pack`() {
|
||||
var state = KlondikeRules.initialState(
|
||||
config(KlondikeOptions(drawCount = 3, redealLimit = 0)),
|
||||
)
|
||||
repeat(8) { state = KlondikeRules.applyMove(state, 0, Draw) }
|
||||
assertFalse("no turning it back over", state.canRedeal)
|
||||
assertFalse(KlondikeRules.legalMoves(state, 0).contains(Redeal))
|
||||
}
|
||||
|
||||
// ---- building ----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the ace is low here, whatever the rest of the deck thinks`() {
|
||||
// Rank.order puts the ace at fourteen, because the deck was built for
|
||||
// trick-taking games where it is the highest card there is. Patience
|
||||
// counts it as one, and every comparison in this game has to agree —
|
||||
// otherwise a two can never follow an ace home and the game is
|
||||
// unwinnable in a way nothing else would show.
|
||||
assertEquals(14, Rank.ACE.order)
|
||||
assertEquals(1, Rank.ACE.patienceOrder)
|
||||
assertEquals(13, Rank.KING.patienceOrder)
|
||||
|
||||
var foundation = emptyList<Card>()
|
||||
Rank.standard.sortedBy { it.patienceOrder }.forEach { rank ->
|
||||
val card = c(rank, Suit.SPADES)
|
||||
assertTrue("$rank should go home next", acceptsOnFoundation(foundation, card))
|
||||
foundation = foundation + card
|
||||
}
|
||||
assertEquals(13, foundation.size)
|
||||
|
||||
// And a column runs the other way, down to an ace.
|
||||
assertTrue(buildsDown(c(Rank.ACE, Suit.HEARTS), c(Rank.TWO, Suit.SPADES)))
|
||||
assertTrue(buildsDown(c(Rank.QUEEN, Suit.HEARTS), c(Rank.KING, Suit.SPADES)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `foundations run ace upwards in one suit`() {
|
||||
assertTrue(acceptsOnFoundation(emptyList(), c(Rank.ACE, Suit.SPADES)))
|
||||
assertFalse(acceptsOnFoundation(emptyList(), c(Rank.TWO, Suit.SPADES)))
|
||||
|
||||
val ace = listOf(c(Rank.ACE, Suit.SPADES))
|
||||
assertTrue(acceptsOnFoundation(ace, c(Rank.TWO, Suit.SPADES)))
|
||||
assertFalse("wrong suit", acceptsOnFoundation(ace, c(Rank.TWO, Suit.HEARTS)))
|
||||
assertFalse("skips a rank", acceptsOnFoundation(ace, c(Rank.THREE, Suit.SPADES)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `columns build down and in the other colour`() {
|
||||
assertTrue(buildsDown(c(Rank.NINE, Suit.HEARTS), c(Rank.TEN, Suit.SPADES)))
|
||||
assertTrue(buildsDown(c(Rank.NINE, Suit.DIAMONDS), c(Rank.TEN, Suit.CLUBS)))
|
||||
assertFalse("same colour", buildsDown(c(Rank.NINE, Suit.HEARTS), c(Rank.TEN, Suit.DIAMONDS)))
|
||||
assertFalse("wrong way up", buildsDown(c(Rank.JACK, Suit.HEARTS), c(Rank.TEN, Suit.SPADES)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a run travels only if it is a run`() {
|
||||
assertTrue(
|
||||
isMovableRun(
|
||||
listOf(
|
||||
c(Rank.TEN, Suit.SPADES),
|
||||
c(Rank.NINE, Suit.HEARTS),
|
||||
c(Rank.EIGHT, Suit.CLUBS),
|
||||
),
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
"colours repeat",
|
||||
isMovableRun(listOf(c(Rank.TEN, Suit.SPADES), c(Rank.NINE, Suit.CLUBS))),
|
||||
)
|
||||
assertTrue("one card is a run", isMovableRun(listOf(c(Rank.TEN, Suit.SPADES))))
|
||||
assertFalse(isMovableRun(emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty column takes a king, or anything if the table says so`() {
|
||||
assertTrue(acceptsInSpace(c(Rank.KING, Suit.SPADES), kingsOnly = true))
|
||||
assertFalse(acceptsInSpace(c(Rank.QUEEN, Suit.SPADES), kingsOnly = true))
|
||||
assertTrue(acceptsInSpace(c(Rank.TWO, Suit.SPADES), kingsOnly = false))
|
||||
}
|
||||
|
||||
// ---- moving ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `emptying a column turns the card underneath up`() {
|
||||
val base = KlondikeRules.initialState(config())
|
||||
// Column 1 holds one buried card and a red six; column 2's black seven
|
||||
// will take it.
|
||||
val state = base.copy(
|
||||
tableau = base.tableau.toMutableList().also {
|
||||
it[1] = TableauPile(
|
||||
faceDown = listOf(c(Rank.ACE, Suit.CLUBS)),
|
||||
faceUp = listOf(c(Rank.SIX, Suit.HEARTS)),
|
||||
)
|
||||
it[2] = TableauPile(faceUp = listOf(c(Rank.SEVEN, Suit.SPADES)))
|
||||
},
|
||||
)
|
||||
val move = MoveCards(Spot.tableau(1), Spot.tableau(2))
|
||||
assertTrue(KlondikeRules.legalMoves(state, 0).contains(move))
|
||||
|
||||
val after = KlondikeRules.applyMove(state, 0, move)
|
||||
assertEquals(listOf(c(Rank.ACE, Suit.CLUBS)), after.tableau[1].faceUp)
|
||||
assertTrue("nothing left buried", after.tableau[1].faceDown.isEmpty())
|
||||
assertEquals(
|
||||
listOf(c(Rank.SEVEN, Suit.SPADES), c(Rank.SIX, Suit.HEARTS)),
|
||||
after.tableau[2].faceUp,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shifting a whole column into an empty one is not offered`() {
|
||||
// It gains nothing and would let a game shuffle back and forth for ever.
|
||||
val base = KlondikeRules.initialState(config())
|
||||
val state = base.copy(
|
||||
tableau = base.tableau.toMutableList().also {
|
||||
it[0] = TableauPile(faceUp = listOf(c(Rank.KING, Suit.SPADES)))
|
||||
it[1] = TableauPile()
|
||||
},
|
||||
)
|
||||
assertFalse(
|
||||
KlondikeRules.legalMoves(state, 0)
|
||||
.contains(MoveCards(Spot.tableau(0), Spot.tableau(1))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an illegal move is refused rather than applied`() {
|
||||
val state = KlondikeRules.initialState(config())
|
||||
// Nothing is ever taken off the stock by hand, and an empty foundation
|
||||
// has nothing to give — neither depends on how the pack fell.
|
||||
assertThrows {
|
||||
KlondikeRules.applyMove(state, 0, MoveCards(Spot.stock, Spot.tableau(0)))
|
||||
}
|
||||
assertThrows {
|
||||
KlondikeRules.applyMove(state, 0, MoveCards(Spot.foundation(0), Spot.tableau(0)))
|
||||
}
|
||||
assertThrows { KlondikeRules.applyMove(state, 1, Draw) }
|
||||
}
|
||||
|
||||
// ---- finishing ---------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `four full foundations is out`() {
|
||||
val base = KlondikeRules.initialState(config())
|
||||
val full = Suit.entries.map { suit -> Rank.standard.map { Card(it, suit) } }
|
||||
val won = base.copy(foundations = full, tableau = List(TABLEAU_PILES) { TableauPile() })
|
||||
assertTrue(won.isWon)
|
||||
assertTrue(KlondikeRules.isFinished(won))
|
||||
assertNull(KlondikeRules.currentSeat(won))
|
||||
assertTrue(KlondikeRules.summary(won).contains("Out"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a deal with nothing left to try is finished too`() {
|
||||
val base = KlondikeRules.initialState(config())
|
||||
// No stock, no waste, and two columns that cannot touch each other.
|
||||
val stuck = base.copy(
|
||||
stock = emptyList(),
|
||||
waste = emptyList(),
|
||||
tableau = List(TABLEAU_PILES) { index ->
|
||||
when (index) {
|
||||
0 -> TableauPile(faceUp = listOf(c(Rank.FIVE, Suit.SPADES)))
|
||||
1 -> TableauPile(faceUp = listOf(c(Rank.NINE, Suit.CLUBS)))
|
||||
else -> TableauPile(faceUp = listOf(c(Rank.SEVEN, Suit.CLUBS)))
|
||||
}
|
||||
},
|
||||
)
|
||||
assertFalse(stuck.isWon)
|
||||
assertTrue(KlondikeRules.isFinished(stuck))
|
||||
assertTrue(KlondikeRules.summary(stuck).contains("Blocked"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a deal can be played a long way without the rules complaining`() {
|
||||
var state = KlondikeRules.initialState(config())
|
||||
var guard = 400
|
||||
while (!KlondikeRules.isFinished(state) && guard-- > 0) {
|
||||
val legal = KlondikeRules.legalMoves(state, 0)
|
||||
state = KlondikeRules.applyMove(state, 0, KlondikeAi.chooseMove(state, 0, legal))
|
||||
// Every card is somewhere, and only ever in one place.
|
||||
val all = state.stock + state.waste +
|
||||
state.foundations.flatten() +
|
||||
state.tableau.flatMap { it.faceDown + it.faceUp }
|
||||
assertEquals("no card lost or copied", 52, all.size)
|
||||
assertEquals(52, all.distinct().size)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- housekeeping ------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `restart deals the same pack again`() {
|
||||
val state = KlondikeRules.initialState(config())
|
||||
val played = KlondikeRules.applyMove(state, 0, Draw)
|
||||
val again = KlondikeRules.restart(played)
|
||||
assertEquals(state.tableau, again.tableau)
|
||||
assertEquals(state.stock, again.stock)
|
||||
assertEquals(0, again.moves)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing is hidden from the only player`() {
|
||||
val state = KlondikeRules.initialState(config())
|
||||
assertEquals(state, KlondikeRules.viewFor(state, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state and moves survive a round trip`() {
|
||||
val state = KlondikeRules.applyMove(KlondikeRules.initialState(config()), 0, Draw)
|
||||
assertEquals(state, KlondikeRules.decodeState(KlondikeRules.encodeState(state)))
|
||||
|
||||
listOf<KlondikeMove>(
|
||||
Draw,
|
||||
Redeal,
|
||||
MoveCards(Spot.waste, Spot.foundation(2)),
|
||||
MoveCards(Spot.tableau(3), Spot.tableau(5), 4),
|
||||
).forEach {
|
||||
assertEquals(it, KlondikeRules.decodeMove(KlondikeRules.encodeMove(it)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertThrows(block: () -> Unit) {
|
||||
try {
|
||||
block()
|
||||
throw AssertionError("expected the rules to reject that")
|
||||
} catch (expected: IllegalArgumentException) {
|
||||
// Rejected, which is the point.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package org.prolibertate.games.game.pegsolitaire
|
||||
|
||||
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.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
|
||||
class PegSolitaireRulesTest {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private fun config(options: PegSolitaireOptions = PegSolitaireOptions()) = TableConfig(
|
||||
gameId = "pegsolitaire",
|
||||
seats = listOf(PlayerSlot(seat = 0, name = "P0", kind = PlayerKind.HUMAN_LOCAL, team = 0)),
|
||||
optionsJson = json.encodeToString(options),
|
||||
seed = 1L,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `every board has the number of holes it claims`() {
|
||||
PegBoard.entries.forEach { board ->
|
||||
assertEquals(board.label, board.holeCount, board.holes().size)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a triangle staggers its rows and a cross does not`() {
|
||||
// Six ways off a hole on a triangle, four on a square grid. This is the
|
||||
// only thing that differs between the two shapes.
|
||||
assertEquals(6, PegBoard.TRIANGLE_15.directions().size)
|
||||
assertEquals(6, PegBoard.TRIANGLE_21.directions().size)
|
||||
assertEquals(4, PegBoard.ENGLISH_CROSS.directions().size)
|
||||
assertEquals(4, PegBoard.FRENCH_CROSS.directions().size)
|
||||
assertEquals(4, PegBoard.DIAMOND_41.directions().size)
|
||||
// No diagonal creeps into a cross board.
|
||||
assertTrue(PegBoard.ENGLISH_CROSS.directions().all { (dr, dc) -> dr == 0 || dc == 0 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the cross boards are cut where they should be`() {
|
||||
val english = PegBoard.ENGLISH_CROSS.holes()
|
||||
assertFalse("a two-by-two corner is gone", english.contains(Hole(0, 0)))
|
||||
assertFalse(english.contains(Hole(1, 1)))
|
||||
assertTrue(english.contains(Hole(0, 3)))
|
||||
assertTrue(english.contains(Hole(3, 3)))
|
||||
|
||||
val french = PegBoard.FRENCH_CROSS.holes()
|
||||
// The shallower cut takes three holes per corner, so (1,1) survives.
|
||||
assertFalse(french.contains(Hole(0, 0)))
|
||||
assertFalse(french.contains(Hole(0, 1)))
|
||||
assertFalse(french.contains(Hole(1, 0)))
|
||||
assertTrue("the French cut leaves this one", french.contains(Hole(1, 1)))
|
||||
|
||||
val diamond = PegBoard.DIAMOND_41.holes()
|
||||
assertTrue(diamond.contains(Hole(0, 4)))
|
||||
assertTrue(diamond.contains(Hole(4, 0)))
|
||||
assertFalse(diamond.contains(Hole(0, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a board opens one peg short`() {
|
||||
val state = PegSolitaireRules.initialState(config())
|
||||
assertEquals(PegBoard.TRIANGLE_15.holeCount - 1, state.remaining)
|
||||
assertFalse("the opening hole is empty", state.pegs.contains(Hole(0, 0)))
|
||||
assertEquals(0, state.jumps)
|
||||
assertNull(state.lastJump)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the tee game opens with exactly two jumps, both into the apex`() {
|
||||
val state = PegSolitaireRules.initialState(config())
|
||||
val jumps = PegSolitaireRules.legalJumps(state)
|
||||
assertEquals(2, jumps.size)
|
||||
assertTrue("every opening jump lands in the empty hole", jumps.all { it.to == Hole(0, 0) })
|
||||
assertEquals(setOf(Hole(2, 0), Hole(2, 2)), jumps.map { it.from }.toSet())
|
||||
// The jumped peg is the one between the ends, and it is never invented.
|
||||
assertEquals(setOf(Hole(1, 0), Hole(1, 1)), jumps.map { it.over }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a jump moves one peg and takes the one it passed`() {
|
||||
val state = PegSolitaireRules.initialState(config())
|
||||
val jump = PegJump(from = Hole(2, 0), to = Hole(0, 0))
|
||||
val after = PegSolitaireRules.applyMove(state, 0, jump)
|
||||
assertFalse("the mover left", after.pegs.contains(Hole(2, 0)))
|
||||
assertFalse("the jumped peg was taken", after.pegs.contains(Hole(1, 0)))
|
||||
assertTrue("the mover arrived", after.pegs.contains(Hole(0, 0)))
|
||||
assertEquals(state.remaining - 1, after.remaining)
|
||||
assertEquals(1, after.jumps)
|
||||
assertEquals(jump, after.lastJump)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a jump needs a peg to pass and an empty hole to land in`() {
|
||||
val state = PegSolitaireRules.initialState(config())
|
||||
// Landing hole already occupied.
|
||||
assertThrows { PegSolitaireRules.applyMove(state, 0, PegJump(Hole(4, 0), Hole(2, 0))) }
|
||||
// Nothing to jump over: (1,0) is empty only after a jump, but (0,0) is
|
||||
// the opening hole, so a jump starting there has no peg to move.
|
||||
assertThrows { PegSolitaireRules.applyMove(state, 0, PegJump(Hole(0, 0), Hole(2, 0))) }
|
||||
// Straight off the board.
|
||||
assertThrows { PegSolitaireRules.applyMove(state, 0, PegJump(Hole(2, 0), Hole(2, 4))) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only the one seat may play`() {
|
||||
val state = PegSolitaireRules.initialState(config())
|
||||
assertEquals(0, PegSolitaireRules.currentSeat(state))
|
||||
assertTrue(PegSolitaireRules.legalMoves(state, 1).isEmpty())
|
||||
assertThrows { PegSolitaireRules.applyMove(state, 1, PegJump(Hole(2, 0), Hole(0, 0))) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a table for anything but one seat is refused`() {
|
||||
val twoSeats = TableConfig(
|
||||
gameId = "pegsolitaire",
|
||||
seats = (0..1).map {
|
||||
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
|
||||
},
|
||||
optionsJson = json.encodeToString(PegSolitaireOptions()),
|
||||
seed = 1L,
|
||||
)
|
||||
assertThrows { PegSolitaireRules.initialState(twoSeats) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one peg left is solved, and the stricter goal asks where`() {
|
||||
val loose = PegSolitaireOptions(goal = PegGoal.ONE_PEG)
|
||||
val strict = PegSolitaireOptions(goal = PegGoal.ONE_PEG_IN_START)
|
||||
val base = PegSolitaireRules.initialState(config(loose))
|
||||
|
||||
val elsewhere = base.copy(pegs = setOf(Hole(4, 2)))
|
||||
assertTrue(elsewhere.solved)
|
||||
assertFalse("the strict goal wants it back where it started", elsewhere.copy(options = strict).solved)
|
||||
|
||||
val atStart = base.copy(options = strict, pegs = setOf(Hole(0, 0)))
|
||||
assertTrue("the survivor is in the opening hole", atStart.solved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a board with no jumps left is finished, solved or not`() {
|
||||
val state = PegSolitaireRules.initialState(config())
|
||||
// Two pegs far apart cannot reach each other, so nothing can jump.
|
||||
val stuck = state.copy(pegs = setOf(Hole(4, 0), Hole(4, 4)))
|
||||
assertTrue(PegSolitaireRules.isFinished(stuck))
|
||||
assertFalse(stuck.solved)
|
||||
assertNull(PegSolitaireRules.currentSeat(stuck))
|
||||
assertTrue(PegSolitaireRules.summary(stuck).contains("Stuck"))
|
||||
|
||||
val won = state.copy(pegs = setOf(Hole(2, 1)))
|
||||
assertTrue(PegSolitaireRules.isFinished(won))
|
||||
assertTrue(won.solved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an opening hole has to be a hole on the board`() {
|
||||
assertThrows { PegSolitaireOptions(board = PegBoard.TRIANGLE_15, startEmpty = Hole(9, 9)) }
|
||||
// A hole that exists on one board but not another.
|
||||
assertThrows {
|
||||
PegSolitaireOptions(board = PegBoard.ENGLISH_CROSS, startEmpty = Hole(0, 0))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every board can be opened and played`() {
|
||||
// Each board deals, offers jumps, and runs to a finish without the rules
|
||||
// throwing. This is what catches a geometry that produces a board nobody
|
||||
// can move on at all.
|
||||
PegBoard.entries.forEach { board ->
|
||||
val state = PegSolitaireRules.initialState(
|
||||
config(PegSolitaireOptions(board = board)),
|
||||
)
|
||||
assertEquals(board.holeCount - 1, state.remaining)
|
||||
assertTrue(
|
||||
"${board.label} should have an opening jump",
|
||||
PegSolitaireRules.legalJumps(state).isNotEmpty(),
|
||||
)
|
||||
val end = playOut(state)
|
||||
assertTrue(PegSolitaireRules.isFinished(end))
|
||||
assertTrue("${board.label} should take pegs off", end.remaining < state.remaining)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restart puts every peg back`() {
|
||||
val state = PegSolitaireRules.initialState(config())
|
||||
val played = PegSolitaireRules.applyMove(state, 0, PegJump(Hole(2, 0), Hole(0, 0)))
|
||||
val again = PegSolitaireRules.restart(played)
|
||||
assertEquals(state.pegs, again.pegs)
|
||||
assertEquals(0, again.jumps)
|
||||
assertNull(again.lastJump)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing is hidden from the only player`() {
|
||||
val state = PegSolitaireRules.initialState(config())
|
||||
assertEquals(state, PegSolitaireRules.viewFor(state, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state and moves survive a round trip`() {
|
||||
PegBoard.entries.forEach { board ->
|
||||
val state = PegSolitaireRules.initialState(
|
||||
config(PegSolitaireOptions(board = board, goal = PegGoal.ONE_PEG_IN_START)),
|
||||
)
|
||||
assertEquals(state, PegSolitaireRules.decodeState(PegSolitaireRules.encodeState(state)))
|
||||
}
|
||||
val move: PegSolitaireMove = PegJump(Hole(2, 0), Hole(0, 0))
|
||||
assertEquals(move, PegSolitaireRules.decodeMove(PegSolitaireRules.encodeMove(move)))
|
||||
}
|
||||
|
||||
/** Runs a board to a standstill using the AI, which never has to guess. */
|
||||
private fun playOut(from: PegSolitaireState): PegSolitaireState {
|
||||
var state = from
|
||||
// Each jump takes exactly one peg off, so the board cannot outlast its
|
||||
// own peg count — a loop that did would be a bug in applyMove.
|
||||
var guard = from.remaining + 1
|
||||
while (!PegSolitaireRules.isFinished(state) && guard-- > 0) {
|
||||
val legal = PegSolitaireRules.legalMoves(state, 0)
|
||||
state = PegSolitaireRules.applyMove(state, 0, PegSolitaireAi.chooseMove(state, 0, legal))
|
||||
}
|
||||
assertTrue("playing out should terminate", guard > 0)
|
||||
return state
|
||||
}
|
||||
|
||||
private fun assertThrows(block: () -> Unit) {
|
||||
try {
|
||||
block()
|
||||
throw AssertionError("expected the rules to reject that")
|
||||
} catch (expected: IllegalArgumentException) {
|
||||
// Rejected, which is the point.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package org.prolibertate.games.game.yahtzee
|
||||
|
||||
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.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
|
||||
class YahtzeeRulesTest {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private fun config(options: YahtzeeOptions = YahtzeeOptions(), seed: Long = 11L) = TableConfig(
|
||||
gameId = "yahtzee",
|
||||
seats = (0 until options.playerCount).map {
|
||||
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
|
||||
},
|
||||
optionsJson = json.encodeToString(options),
|
||||
seed = seed,
|
||||
)
|
||||
|
||||
// ---- scoring -----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the upper six count only the die they name`() {
|
||||
val dice = listOf(3, 3, 5, 1, 3)
|
||||
assertEquals(1, scoreOf(YahtzeeCategory.ONES, dice))
|
||||
assertEquals(0, scoreOf(YahtzeeCategory.TWOS, dice))
|
||||
assertEquals(9, scoreOf(YahtzeeCategory.THREES, dice))
|
||||
assertEquals(5, scoreOf(YahtzeeCategory.FIVES, dice))
|
||||
assertEquals(0, scoreOf(YahtzeeCategory.SIXES, dice))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `three and four of a kind pay the whole throw`() {
|
||||
val three = listOf(4, 4, 4, 2, 6)
|
||||
assertEquals(20, scoreOf(YahtzeeCategory.THREE_OF_A_KIND, three))
|
||||
assertEquals(0, scoreOf(YahtzeeCategory.FOUR_OF_A_KIND, three))
|
||||
|
||||
val four = listOf(4, 4, 4, 4, 6)
|
||||
assertEquals(22, scoreOf(YahtzeeCategory.THREE_OF_A_KIND, four))
|
||||
assertEquals(22, scoreOf(YahtzeeCategory.FOUR_OF_A_KIND, four))
|
||||
|
||||
assertEquals(0, scoreOf(YahtzeeCategory.THREE_OF_A_KIND, listOf(1, 2, 3, 4, 5)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a full house is three of one and two of another, and nothing else`() {
|
||||
assertEquals(25, scoreOf(YahtzeeCategory.FULL_HOUSE, listOf(2, 2, 5, 5, 5)))
|
||||
assertEquals(0, scoreOf(YahtzeeCategory.FULL_HOUSE, listOf(2, 2, 2, 5, 6)))
|
||||
// Five of a kind is not a full house on the printed card, whatever the
|
||||
// joker variations do with it.
|
||||
assertEquals(0, scoreOf(YahtzeeCategory.FULL_HOUSE, listOf(4, 4, 4, 4, 4)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `straights want consecutive faces, and a repeat does not break them`() {
|
||||
assertEquals(4, longestRun(listOf(1, 2, 3, 4, 4)))
|
||||
assertEquals(5, longestRun(listOf(2, 3, 4, 5, 6)))
|
||||
assertEquals(1, longestRun(listOf(3, 3, 3, 3, 3)))
|
||||
|
||||
assertEquals(30, scoreOf(YahtzeeCategory.SMALL_STRAIGHT, listOf(1, 2, 3, 4, 4)))
|
||||
assertEquals(0, scoreOf(YahtzeeCategory.LARGE_STRAIGHT, listOf(1, 2, 3, 4, 4)))
|
||||
assertEquals(40, scoreOf(YahtzeeCategory.LARGE_STRAIGHT, listOf(3, 4, 5, 6, 2)))
|
||||
// A large straight is a small one as well.
|
||||
assertEquals(30, scoreOf(YahtzeeCategory.SMALL_STRAIGHT, listOf(3, 4, 5, 6, 2)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `five of a kind is fifty, and chance is whatever is showing`() {
|
||||
assertEquals(50, scoreOf(YahtzeeCategory.YAHTZEE, listOf(6, 6, 6, 6, 6)))
|
||||
assertEquals(0, scoreOf(YahtzeeCategory.YAHTZEE, listOf(6, 6, 6, 6, 1)))
|
||||
assertEquals(19, scoreOf(YahtzeeCategory.CHANCE, listOf(6, 6, 4, 2, 1)))
|
||||
assertTrue(isYahtzee(listOf(2, 2, 2, 2, 2)))
|
||||
assertFalse(isYahtzee(listOf(2, 2, 2, 2, 3)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the upper bonus arrives at sixty-three`() {
|
||||
var card = YahtzeeCard()
|
||||
// Exactly three of each is sixty-three, which is what the bonus is for.
|
||||
YahtzeeCategory.entries.filter { it.section == YahtzeeSection.UPPER }.forEach {
|
||||
card = card.with(it, it.face!! * 3)
|
||||
}
|
||||
assertEquals(63, card.upperSubtotal)
|
||||
assertEquals(35, card.upperBonus)
|
||||
|
||||
var short = YahtzeeCard()
|
||||
YahtzeeCategory.entries.filter { it.section == YahtzeeSection.UPPER }.forEach {
|
||||
short = short.with(it, it.face!! * 2)
|
||||
}
|
||||
assertEquals(42, short.upperSubtotal)
|
||||
assertEquals(0, short.upperBonus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a box can only be written in once`() {
|
||||
val card = YahtzeeCard().with(YahtzeeCategory.CHANCE, 20)
|
||||
assertTrue(card.isFilled(YahtzeeCategory.CHANCE))
|
||||
assertFalse(card.isFilled(YahtzeeCategory.ONES))
|
||||
try {
|
||||
card.with(YahtzeeCategory.CHANCE, 5)
|
||||
throw AssertionError("expected a rejection")
|
||||
} catch (expected: IllegalArgumentException) {
|
||||
// As intended.
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the state machine -------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a turn opens with one thing to do, and it is throwing`() {
|
||||
val state = YahtzeeRules.initialState(config())
|
||||
assertEquals(0, YahtzeeRules.currentSeat(state))
|
||||
val legal = YahtzeeRules.legalMoves(state, 0)
|
||||
assertEquals(listOf(RollDice(emptyList())), legal)
|
||||
// Nothing may be written in before the dice have been thrown.
|
||||
assertThrows { YahtzeeRules.applyMove(state, 0, ScoreIn(YahtzeeCategory.CHANCE)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a throw puts five dice on the table`() {
|
||||
val state = YahtzeeRules.initialState(config())
|
||||
val rolled = YahtzeeRules.applyMove(state, 0, RollDice())
|
||||
assertEquals(DICE_COUNT, rolled.dice.size)
|
||||
assertTrue(rolled.dice.all { it in 1..6 })
|
||||
assertEquals(1, rolled.rollsUsed)
|
||||
assertEquals(2, rolled.rollsLeft)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same state and move always throw the same dice`() {
|
||||
// The host and every client run applyMove separately; a throw that
|
||||
// differed between them would split the game in two.
|
||||
val state = YahtzeeRules.initialState(config())
|
||||
val once = YahtzeeRules.applyMove(state, 0, RollDice())
|
||||
val again = YahtzeeRules.applyMove(state, 0, RollDice())
|
||||
assertEquals(once.dice, again.dice)
|
||||
|
||||
val keptOnce = YahtzeeRules.applyMove(once, 0, RollDice(listOf(0, 1)))
|
||||
val keptAgain = YahtzeeRules.applyMove(once, 0, RollDice(listOf(0, 1)))
|
||||
assertEquals(keptOnce.dice, keptAgain.dice)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `kept dice stay put and the rest are thrown again`() {
|
||||
val state = YahtzeeRules.applyMove(YahtzeeRules.initialState(config()), 0, RollDice())
|
||||
val kept = YahtzeeRules.applyMove(state, 0, RollDice(listOf(0, 2, 4)))
|
||||
assertEquals(state.dice[0], kept.dice[0])
|
||||
assertEquals(state.dice[2], kept.dice[2])
|
||||
assertEquals(state.dice[4], kept.dice[4])
|
||||
assertEquals(2, kept.rollsUsed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `there are three throws in a turn and no more`() {
|
||||
var state = YahtzeeRules.applyMove(YahtzeeRules.initialState(config()), 0, RollDice())
|
||||
state = YahtzeeRules.applyMove(state, 0, RollDice())
|
||||
state = YahtzeeRules.applyMove(state, 0, RollDice())
|
||||
assertEquals(ROLLS_PER_TURN, state.rollsUsed)
|
||||
assertEquals(0, state.rollsLeft)
|
||||
assertThrows { YahtzeeRules.applyMove(state, 0, RollDice()) }
|
||||
// Only writing it down is left.
|
||||
assertTrue(YahtzeeRules.legalMoves(state, 0).all { it is ScoreIn })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing may be kept before the first throw`() {
|
||||
val state = YahtzeeRules.initialState(config())
|
||||
assertThrows { YahtzeeRules.applyMove(state, 0, RollDice(listOf(0))) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `writing a box passes the dice on and the round turns over`() {
|
||||
val state = YahtzeeRules.applyMove(YahtzeeRules.initialState(config()), 0, RollDice())
|
||||
val written = YahtzeeRules.applyMove(state, 0, ScoreIn(YahtzeeCategory.CHANCE))
|
||||
assertEquals("the dice go back in the cup", emptyList<Int>(), written.dice)
|
||||
assertEquals(0, written.rollsUsed)
|
||||
assertEquals(1, written.turn)
|
||||
assertEquals("still the first round", 0, written.round)
|
||||
assertEquals(state.dice.sum(), written.cards[0][YahtzeeCategory.CHANCE])
|
||||
|
||||
val second = YahtzeeRules.applyMove(written, 1, RollDice())
|
||||
val wrapped = YahtzeeRules.applyMove(second, 1, ScoreIn(YahtzeeCategory.CHANCE))
|
||||
assertEquals(0, wrapped.turn)
|
||||
assertEquals("round turns over once everyone has written", 1, wrapped.round)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a seat that is not on the clock cannot move`() {
|
||||
val state = YahtzeeRules.initialState(config())
|
||||
assertTrue(YahtzeeRules.legalMoves(state, 1).isEmpty())
|
||||
assertThrows { YahtzeeRules.applyMove(state, 1, RollDice()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a second Yahtzee pays a hundred, but only over a fifty`() {
|
||||
val base = YahtzeeRules.initialState(config())
|
||||
val fives = listOf(5, 5, 5, 5, 5)
|
||||
|
||||
// The box already holds the fifty, so the bonus is earned.
|
||||
val earned = base.copy(
|
||||
dice = fives,
|
||||
rollsUsed = 1,
|
||||
cards = listOf(
|
||||
YahtzeeCard().with(YahtzeeCategory.YAHTZEE, YAHTZEE_SCORE),
|
||||
YahtzeeCard(),
|
||||
),
|
||||
)
|
||||
val after = YahtzeeRules.applyMove(earned, 0, ScoreIn(YahtzeeCategory.FIVES))
|
||||
assertEquals(YAHTZEE_BONUS, after.yahtzeeBonuses[0])
|
||||
assertEquals(25, after.cards[0][YahtzeeCategory.FIVES])
|
||||
|
||||
// A zero written there earlier forfeits it.
|
||||
val forfeited = base.copy(
|
||||
dice = fives,
|
||||
rollsUsed = 1,
|
||||
cards = listOf(YahtzeeCard().with(YahtzeeCategory.YAHTZEE, 0), YahtzeeCard()),
|
||||
)
|
||||
assertEquals(
|
||||
0,
|
||||
YahtzeeRules.applyMove(forfeited, 0, ScoreIn(YahtzeeCategory.FIVES)).yahtzeeBonuses[0],
|
||||
)
|
||||
|
||||
// And it can be switched off at the table.
|
||||
val off = earned.copy(options = YahtzeeOptions(playerCount = 2, yahtzeeBonus = false))
|
||||
assertEquals(
|
||||
0,
|
||||
YahtzeeRules.applyMove(off, 0, ScoreIn(YahtzeeCategory.FIVES)).yahtzeeBonuses[0],
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a full card ends the game`() {
|
||||
var state = YahtzeeRules.initialState(config(YahtzeeOptions(playerCount = 1)))
|
||||
// Thirteen turns, each filling one box.
|
||||
var guard = 200
|
||||
while (!YahtzeeRules.isFinished(state) && guard-- > 0) {
|
||||
val legal = YahtzeeRules.legalMoves(state, state.turn)
|
||||
state = YahtzeeRules.applyMove(state, state.turn, YahtzeeAi.chooseMove(state, state.turn, legal))
|
||||
}
|
||||
assertTrue("the game should end on its own", guard > 0)
|
||||
assertTrue(YahtzeeRules.isFinished(state))
|
||||
assertTrue(state.cards[0].isComplete)
|
||||
assertEquals(YahtzeeCategory.entries.size, state.round)
|
||||
assertNull(YahtzeeRules.currentSeat(state))
|
||||
assertTrue(YahtzeeRules.summary(state).contains("Winner"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two can play it through as well`() {
|
||||
var state = YahtzeeRules.initialState(config(YahtzeeOptions(playerCount = 2)))
|
||||
var guard = 400
|
||||
while (!YahtzeeRules.isFinished(state) && guard-- > 0) {
|
||||
val legal = YahtzeeRules.legalMoves(state, state.turn)
|
||||
state = YahtzeeRules.applyMove(state, state.turn, YahtzeeAi.chooseMove(state, state.turn, legal))
|
||||
}
|
||||
assertTrue(guard > 0)
|
||||
assertTrue(state.cards.all { it.isComplete })
|
||||
state.cards.indices.forEach { assertTrue(state.totalFor(it) >= 0) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing is hidden from anyone`() {
|
||||
val state = YahtzeeRules.initialState(config())
|
||||
assertEquals(state, YahtzeeRules.viewFor(state, 0))
|
||||
assertEquals(state, YahtzeeRules.viewFor(state, 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state and moves survive a round trip`() {
|
||||
val state = YahtzeeRules.applyMove(YahtzeeRules.initialState(config()), 0, RollDice())
|
||||
assertEquals(state, YahtzeeRules.decodeState(YahtzeeRules.encodeState(state)))
|
||||
|
||||
val roll: YahtzeeMove = RollDice(listOf(0, 3))
|
||||
assertEquals(roll, YahtzeeRules.decodeMove(YahtzeeRules.encodeMove(roll)))
|
||||
val write: YahtzeeMove = ScoreIn(YahtzeeCategory.LARGE_STRAIGHT)
|
||||
assertEquals(write, YahtzeeRules.decodeMove(YahtzeeRules.encodeMove(write)))
|
||||
}
|
||||
|
||||
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