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
|
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
|
* A tree rather than a list, because "trick-taking" is a kind of card game
|
||||||
* engine exists yet. [GameDescriptor.available] is what the menu keys off to
|
* rather than a rival to it. [parent] is read from a `when` rather than passed
|
||||||
* decide between "play" and "coming soon", so adding a game is a matter of
|
* to the constructor: an enum entry cannot refer to another entry while the
|
||||||
* flipping that flag once its engine and screen land.
|
* class is still initialising, and a lookup afterwards is free.
|
||||||
*/
|
*/
|
||||||
enum class GameCategory(val label: String) {
|
enum class GameMenu(val label: String, val blurb: String) {
|
||||||
CARD("Playing Card Games"),
|
SOLITAIRE("Solitaire", "Played alone, against the deal or the board."),
|
||||||
BOARD("Board Games"),
|
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(
|
data class GameDescriptor(
|
||||||
val id: String,
|
val id: String,
|
||||||
val title: 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 minPlayers: Int,
|
||||||
val maxPlayers: Int,
|
val maxPlayers: Int,
|
||||||
/** True when players are grouped into partnerships rather than playing solo. */
|
/** True when players are grouped into partnerships rather than playing solo. */
|
||||||
@@ -25,6 +42,14 @@ data class GameDescriptor(
|
|||||||
val available: Boolean,
|
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 {
|
object GameCatalog {
|
||||||
|
|
||||||
const val EUCHRE = "euchre"
|
const val EUCHRE = "euchre"
|
||||||
@@ -34,6 +59,15 @@ object GameCatalog {
|
|||||||
const val WIZARD = "wizard"
|
const val WIZARD = "wizard"
|
||||||
const val CRAZY_EIGHTS = "crazy8s"
|
const val CRAZY_EIGHTS = "crazy8s"
|
||||||
const val CRIBBAGE = "cribbage"
|
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 SEQUENCE = "sequence"
|
||||||
const val MASTERMIND = "mastermind"
|
const val MASTERMIND = "mastermind"
|
||||||
const val BACKGAMMON = "backgammon"
|
const val BACKGAMMON = "backgammon"
|
||||||
@@ -47,7 +81,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = EUCHRE,
|
id = EUCHRE,
|
||||||
title = "Euchre",
|
title = "Euchre",
|
||||||
category = GameCategory.CARD,
|
menus = setOf(GameMenu.CARDS, GameMenu.TRICK_TAKING),
|
||||||
minPlayers = 4,
|
minPlayers = 4,
|
||||||
maxPlayers = 4,
|
maxPlayers = 4,
|
||||||
teamBased = true,
|
teamBased = true,
|
||||||
@@ -57,7 +91,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = KAISER,
|
id = KAISER,
|
||||||
title = "Kaiser",
|
title = "Kaiser",
|
||||||
category = GameCategory.CARD,
|
menus = setOf(GameMenu.CARDS, GameMenu.TRICK_TAKING),
|
||||||
minPlayers = 4,
|
minPlayers = 4,
|
||||||
maxPlayers = 4,
|
maxPlayers = 4,
|
||||||
teamBased = true,
|
teamBased = true,
|
||||||
@@ -67,7 +101,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = PRESIDENT,
|
id = PRESIDENT,
|
||||||
title = "President",
|
title = "President",
|
||||||
category = GameCategory.CARD,
|
menus = setOf(GameMenu.CARDS),
|
||||||
minPlayers = 3,
|
minPlayers = 3,
|
||||||
maxPlayers = 7,
|
maxPlayers = 7,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -77,7 +111,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = GOLF,
|
id = GOLF,
|
||||||
title = "Golf",
|
title = "Golf",
|
||||||
category = GameCategory.CARD,
|
menus = setOf(GameMenu.CARDS),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 6,
|
maxPlayers = 6,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -87,7 +121,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = WIZARD,
|
id = WIZARD,
|
||||||
title = "Wizard",
|
title = "Wizard",
|
||||||
category = GameCategory.CARD,
|
menus = setOf(GameMenu.CARDS, GameMenu.TRICK_TAKING),
|
||||||
minPlayers = 3,
|
minPlayers = 3,
|
||||||
maxPlayers = 6,
|
maxPlayers = 6,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -97,7 +131,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = CRAZY_EIGHTS,
|
id = CRAZY_EIGHTS,
|
||||||
title = "Crazy 8s",
|
title = "Crazy 8s",
|
||||||
category = GameCategory.CARD,
|
menus = setOf(GameMenu.CARDS),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 6,
|
maxPlayers = 6,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -107,7 +141,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = CRIBBAGE,
|
id = CRIBBAGE,
|
||||||
title = "Cribbage",
|
title = "Cribbage",
|
||||||
category = GameCategory.CARD,
|
menus = setOf(GameMenu.CARDS),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 4,
|
maxPlayers = 4,
|
||||||
// Only at four, where the two pairs share a board between them.
|
// Only at four, where the two pairs share a board between them.
|
||||||
@@ -116,10 +150,65 @@ object GameCatalog {
|
|||||||
"twice round the board to 121.",
|
"twice round the board to 121.",
|
||||||
available = true,
|
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(
|
GameDescriptor(
|
||||||
id = SEQUENCE,
|
id = SEQUENCE,
|
||||||
title = "Sequence",
|
title = "Sequence",
|
||||||
category = GameCategory.BOARD,
|
menus = setOf(GameMenu.BOARD, GameMenu.CARDS),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 12,
|
maxPlayers = 12,
|
||||||
teamBased = true,
|
teamBased = true,
|
||||||
@@ -129,7 +218,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = MASTERMIND,
|
id = MASTERMIND,
|
||||||
title = "Mastermind",
|
title = "Mastermind",
|
||||||
category = GameCategory.BOARD,
|
menus = setOf(GameMenu.BOARD),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 2,
|
maxPlayers = 2,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -140,7 +229,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = BACKGAMMON,
|
id = BACKGAMMON,
|
||||||
title = "Backgammon",
|
title = "Backgammon",
|
||||||
category = GameCategory.BOARD,
|
menus = setOf(GameMenu.BOARD),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 2,
|
maxPlayers = 2,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -150,7 +239,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = CHESS,
|
id = CHESS,
|
||||||
title = "Chess",
|
title = "Chess",
|
||||||
category = GameCategory.BOARD,
|
menus = setOf(GameMenu.BOARD),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 2,
|
maxPlayers = 2,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -160,7 +249,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = CHECKERS,
|
id = CHECKERS,
|
||||||
title = "Checkers",
|
title = "Checkers",
|
||||||
category = GameCategory.BOARD,
|
menus = setOf(GameMenu.BOARD),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 2,
|
maxPlayers = 2,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -170,17 +259,41 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = MORRIS,
|
id = MORRIS,
|
||||||
title = "Nine Men's Morris",
|
title = "Nine Men's Morris",
|
||||||
category = GameCategory.BOARD,
|
menus = setOf(GameMenu.BOARD),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 2,
|
maxPlayers = 2,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
blurb = "Three in a row takes a piece. Reduce them to two and the game is yours.",
|
blurb = "Three in a row takes a piece. Reduce them to two and the game is yours.",
|
||||||
available = true,
|
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(
|
GameDescriptor(
|
||||||
id = PIRATES,
|
id = PIRATES,
|
||||||
title = "Pirates and Bulgars",
|
title = "Pirates and Bulgars",
|
||||||
category = GameCategory.BOARD,
|
menus = setOf(GameMenu.BOARD),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 2,
|
maxPlayers = 2,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -191,7 +304,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = TAYU,
|
id = TAYU,
|
||||||
title = "Ta Yü",
|
title = "Ta Yü",
|
||||||
category = GameCategory.BOARD,
|
menus = setOf(GameMenu.BOARD),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 4,
|
maxPlayers = 4,
|
||||||
// Two play solo, four play in partnerships across the two axes.
|
// 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
|
* production build lists only what can actually be played, so nobody
|
||||||
* downloads a release and taps into a dead end.
|
* downloads a release and taps into a dead end.
|
||||||
*/
|
*/
|
||||||
fun byCategory(
|
fun inMenu(
|
||||||
category: GameCategory,
|
menu: GameMenu,
|
||||||
includeComingSoon: Boolean = true,
|
includeComingSoon: Boolean = true,
|
||||||
): List<GameDescriptor> = all.filter {
|
): 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 }
|
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.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.background
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
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.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@@ -36,7 +38,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import org.prolibertate.games.R
|
import org.prolibertate.games.R
|
||||||
import org.prolibertate.games.game.GameCatalog
|
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.GameDescriptor
|
||||||
import org.prolibertate.games.game.engine.TableConfig
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
import org.prolibertate.games.net.LobbyController
|
import org.prolibertate.games.net.LobbyController
|
||||||
@@ -64,6 +66,9 @@ class AppEnv(
|
|||||||
*/
|
*/
|
||||||
sealed interface Route {
|
sealed interface Route {
|
||||||
data object Menu : 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 Settings : Route
|
||||||
data object Scorekeeper : Route
|
data object Scorekeeper : Route
|
||||||
data class Setup(val gameId: String) : 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
|
// Dev builds show the games that are not finished yet; production
|
||||||
// releases list only what can actually be played.
|
// releases list only what can actually be played.
|
||||||
showComingSoon = showComingSoon,
|
showComingSoon = showComingSoon,
|
||||||
onPickGame = { push(Route.Setup(it.id)) },
|
onPickSection = { push(Route.Section(it)) },
|
||||||
onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) },
|
onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) },
|
||||||
onScorekeeper = { push(Route.Scorekeeper) },
|
onScorekeeper = { push(Route.Scorekeeper) },
|
||||||
onSettings = { push(Route.Settings) },
|
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(
|
is Route.Settings -> SettingsScreen(
|
||||||
env = env,
|
env = env,
|
||||||
settings = settings,
|
settings = settings,
|
||||||
@@ -202,11 +215,11 @@ private fun AppContent(
|
|||||||
// Main menu
|
// Main menu
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MainMenuScreen(
|
fun MainMenuScreen(
|
||||||
showComingSoon: Boolean,
|
showComingSoon: Boolean,
|
||||||
onPickGame: (GameDescriptor) -> Unit,
|
onPickSection: (GameMenu) -> Unit,
|
||||||
onJoinGame: () -> Unit,
|
onJoinGame: () -> Unit,
|
||||||
onScorekeeper: () -> Unit,
|
onScorekeeper: () -> Unit,
|
||||||
onSettings: () -> Unit,
|
onSettings: () -> Unit,
|
||||||
@@ -221,51 +234,157 @@ fun MainMenuScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { padding ->
|
) { 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
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.padding(horizontal = 12.dp),
|
.padding(horizontal = 12.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
// Wraps rather than running off the edge: two buttons and a narrow
|
Column(
|
||||||
// phone would otherwise push the second one past the screen.
|
modifier = Modifier
|
||||||
FlowRow(
|
.widthIn(max = 340.dp)
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
.fillMaxWidth()
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
OutlinedButton(onClick = onJoinGame) { Text("Join a game nearby") }
|
sections.forEach { section ->
|
||||||
// Not a game, and deliberately not in the catalogue: it keeps
|
SectionTile(
|
||||||
// score for whatever is being played on the actual table.
|
menu = section,
|
||||||
OutlinedButton(onClick = onScorekeeper) { Text("Scorekeeper") }
|
count = countIn(section, showComingSoon),
|
||||||
|
onClick = { onPickSection(section) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The column count follows the available width, so a phone in
|
// Below the games, and full width like them, so the column
|
||||||
// portrait gets two and a tablet in landscape gets four or more.
|
// 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(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Adaptive(minSize = 172.dp),
|
columns = GridCells.Adaptive(minSize = 172.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
GameCategory.entries.forEach { category ->
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
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) }) {
|
|
||||||
Text(
|
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,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
|
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
items(games) { game ->
|
items(games) { game ->
|
||||||
GameTile(game = game, onClick = { onPickGame(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),
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = if (game.available) {
|
text = when {
|
||||||
if (game.minPlayers == game.maxPlayers) {
|
!game.available -> "Coming soon"
|
||||||
"${game.minPlayers} players"
|
// A puzzle for one, rather than "1 players".
|
||||||
} else {
|
game.maxPlayers == 1 -> "On your own"
|
||||||
"${game.minPlayers}–${game.maxPlayers} players"
|
game.minPlayers == game.maxPlayers -> "${game.minPlayers} players"
|
||||||
}
|
else -> "${game.minPlayers}–${game.maxPlayers} players"
|
||||||
} else {
|
|
||||||
"Coming soon"
|
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
modifier = Modifier.padding(top = 8.dp),
|
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.cribbage.CribbageOptions
|
||||||
import org.prolibertate.games.game.euchre.EuchreOptions
|
import org.prolibertate.games.game.euchre.EuchreOptions
|
||||||
import org.prolibertate.games.game.golf.GolfOptions
|
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.kaiser.KaiserOptions
|
||||||
import org.prolibertate.games.game.mastermind.MastermindLevel
|
import org.prolibertate.games.game.mastermind.MastermindLevel
|
||||||
import org.prolibertate.games.game.mastermind.MastermindOptions
|
import org.prolibertate.games.game.mastermind.MastermindOptions
|
||||||
@@ -88,6 +97,13 @@ fun GameSetupScreen(
|
|||||||
var backgammon by remember { mutableStateOf(BackgammonOptions()) }
|
var backgammon by remember { mutableStateOf(BackgammonOptions()) }
|
||||||
var mastermind by remember { mutableStateOf(MastermindOptions()) }
|
var mastermind by remember { mutableStateOf(MastermindOptions()) }
|
||||||
var pirates by remember { mutableStateOf(PiratesOptions()) }
|
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
|
// Not an engine option: which seat the local player takes. Seat 0 is
|
||||||
// always White, so choosing Black means sitting in seat 1.
|
// always White, so choosing Black means sitting in seat 1.
|
||||||
var playWhite by remember { mutableStateOf(true) }
|
var playWhite by remember { mutableStateOf(true) }
|
||||||
@@ -111,6 +127,13 @@ fun GameSetupScreen(
|
|||||||
GameCatalog.BACKGAMMON -> setupJson.encodeToString(backgammon)
|
GameCatalog.BACKGAMMON -> setupJson.encodeToString(backgammon)
|
||||||
GameCatalog.MASTERMIND -> setupJson.encodeToString(mastermind)
|
GameCatalog.MASTERMIND -> setupJson.encodeToString(mastermind)
|
||||||
GameCatalog.PIRATES -> setupJson.encodeToString(pirates)
|
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 -> "{}"
|
else -> "{}"
|
||||||
}
|
}
|
||||||
val seatCount = seatCountFor(descriptor.id, optionsJson)
|
val seatCount = seatCountFor(descriptor.id, optionsJson)
|
||||||
@@ -162,15 +185,37 @@ fun GameSetupScreen(
|
|||||||
onSide = { playPirates = it },
|
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.")
|
else -> Text("No options yet for this game.")
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider()
|
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(
|
||||||
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 " +
|
"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,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -182,17 +227,19 @@ fun GameSetupScreen(
|
|||||||
descriptor.id == GameCatalog.PIRATES && playPirates -> 1
|
descriptor.id == GameCatalog.PIRATES && playPirates -> 1
|
||||||
else -> 0
|
else -> 0
|
||||||
}
|
}
|
||||||
PrimaryAction(text = "Play against the computer") {
|
PrimaryAction(text = if (solo) "Play" else "Play against the computer") {
|
||||||
onPlayOffline(
|
onPlayOffline(
|
||||||
offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat),
|
offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat),
|
||||||
localSeat,
|
localSeat,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (!solo) {
|
||||||
PrimaryAction(text = "Host a game for others to join") {
|
PrimaryAction(text = "Host a game for others to join") {
|
||||||
onHostOnline(optionsJson)
|
onHostOnline(optionsJson)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** [localSeat] is the person at this device; every other seat starts as AI. */
|
/** [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.CHESS, GameCatalog.MORRIS, GameCatalog.CHECKERS,
|
||||||
GameCatalog.BACKGAMMON, GameCatalog.MASTERMIND, GameCatalog.PIRATES -> 2
|
GameCatalog.BACKGAMMON, GameCatalog.MASTERMIND, GameCatalog.PIRATES -> 2
|
||||||
|
|
||||||
|
GameCatalog.YAHTZEE ->
|
||||||
|
setupJson.decodeFromString<YahtzeeOptions>(optionsJson).playerCount
|
||||||
|
|
||||||
GameCatalog.TAYU ->
|
GameCatalog.TAYU ->
|
||||||
setupJson.decodeFromString<TayuOptions>(optionsJson).playerCount
|
setupJson.decodeFromString<TayuOptions>(optionsJson).playerCount
|
||||||
|
|
||||||
@@ -944,3 +994,150 @@ private fun ToggleRow(
|
|||||||
Switch(checked = checked, onCheckedChange = onChange)
|
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.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import org.prolibertate.games.game.GameCatalog
|
import org.prolibertate.games.game.GameCatalog
|
||||||
import org.prolibertate.games.game.chess.ChessAi
|
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.EuchrePhase
|
||||||
import org.prolibertate.games.game.euchre.EuchreRules
|
import org.prolibertate.games.game.euchre.EuchreRules
|
||||||
import org.prolibertate.games.game.euchre.EuchreState
|
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.GolfAi
|
||||||
import org.prolibertate.games.game.golf.GolfMove
|
import org.prolibertate.games.game.golf.GolfMove
|
||||||
import org.prolibertate.games.game.golf.GolfPhase
|
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.ChessScreen
|
||||||
import org.prolibertate.games.ui.game.CrazyEightsScreen
|
import org.prolibertate.games.ui.game.CrazyEightsScreen
|
||||||
import org.prolibertate.games.ui.game.CribbageScreen
|
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.EuchreScreen
|
||||||
import org.prolibertate.games.ui.game.GolfScreen
|
import org.prolibertate.games.ui.game.GolfScreen
|
||||||
import org.prolibertate.games.ui.game.KaiserScreen
|
import org.prolibertate.games.ui.game.KaiserScreen
|
||||||
import org.prolibertate.games.ui.game.MastermindScreen
|
import org.prolibertate.games.ui.game.MastermindScreen
|
||||||
import org.prolibertate.games.ui.game.MorrisScreen
|
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.PiratesScreen
|
||||||
import org.prolibertate.games.ui.game.PresidentScreen
|
import org.prolibertate.games.ui.game.PresidentScreen
|
||||||
import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS
|
import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS
|
||||||
@@ -430,6 +467,162 @@ fun PlayScreen(
|
|||||||
PiratesScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
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 -> {
|
GameCatalog.MORRIS -> {
|
||||||
val controller = remember(route) {
|
val controller = remember(route) {
|
||||||
MatchController<MorrisState, MorrisMove>(
|
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
|
@Test
|
||||||
fun `a production build lists only games that can be played`() {
|
fun `a production build lists only games that can be played`() {
|
||||||
GameCategory.entries.forEach { category ->
|
GameMenu.entries.forEach { menu ->
|
||||||
val shown = GameCatalog.byCategory(category, includeComingSoon = false)
|
val shown = GameCatalog.inMenu(menu, includeComingSoon = false)
|
||||||
assertTrue(
|
assertTrue(
|
||||||
"$category offers an unfinished game in a release build",
|
"$menu offers an unfinished game in a release build",
|
||||||
shown.all { it.available },
|
shown.all { it.available },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a dev build lists the whole catalogue`() {
|
fun `every game is reachable from at least one menu`() {
|
||||||
val shown = GameCategory.entries.flatMap {
|
// Counting is no good now that a game can be in several menus: the
|
||||||
GameCatalog.byCategory(it, includeComingSoon = true)
|
// 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
|
@Test
|
||||||
@@ -42,19 +75,19 @@ class CatalogTest {
|
|||||||
// engine is written.
|
// engine is written.
|
||||||
assertTrue(GameCatalog.all.all { it.available })
|
assertTrue(GameCatalog.all.all { it.available })
|
||||||
assertEquals(
|
assertEquals(
|
||||||
GameCatalog.byCategory(GameCategory.BOARD, includeComingSoon = false),
|
GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = false),
|
||||||
GameCatalog.byCategory(GameCategory.BOARD, includeComingSoon = true),
|
GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = true),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `hiding unfinished games still leaves something to play in each category`() {
|
fun `hiding unfinished games still leaves something to play in every menu`() {
|
||||||
// A category filtered down to nothing would leave a stranded heading,
|
// A menu filtered down to nothing would leave a stranded heading, and a
|
||||||
// and a menu with no playable game at all would be a broken release.
|
// release where a whole section was empty would be a broken one.
|
||||||
GameCategory.entries.forEach { category ->
|
GameMenu.entries.forEach { menu ->
|
||||||
assertFalse(
|
assertTrue(
|
||||||
"$category has nothing playable in a release build",
|
"$menu has nothing playable in a release build",
|
||||||
GameCatalog.byCategory(category, includeComingSoon = false).isEmpty(),
|
GameCatalog.hasAnything(menu, includeComingSoon = false),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
assertTrue(GameCatalog.playable.isNotEmpty())
|
assertTrue(GameCatalog.playable.isNotEmpty())
|
||||||
@@ -71,6 +104,13 @@ class CatalogTest {
|
|||||||
GameCatalog.KAISER,
|
GameCatalog.KAISER,
|
||||||
GameCatalog.CRAZY_EIGHTS,
|
GameCatalog.CRAZY_EIGHTS,
|
||||||
GameCatalog.CRIBBAGE,
|
GameCatalog.CRIBBAGE,
|
||||||
|
GameCatalog.HEARTS,
|
||||||
|
GameCatalog.PEG_SOLITAIRE,
|
||||||
|
GameCatalog.YAHTZEE,
|
||||||
|
GameCatalog.KLONDIKE,
|
||||||
|
GameCatalog.FREECELL,
|
||||||
|
GameCatalog.SPIDER,
|
||||||
|
GameCatalog.PYRAMID,
|
||||||
GameCatalog.WIZARD,
|
GameCatalog.WIZARD,
|
||||||
GameCatalog.CHESS,
|
GameCatalog.CHESS,
|
||||||
GameCatalog.TAYU,
|
GameCatalog.TAYU,
|
||||||
@@ -87,7 +127,11 @@ class CatalogTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `player counts are sane`() {
|
fun `player counts are sane`() {
|
||||||
GameCatalog.all.forEach { game ->
|
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)
|
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