diff --git a/app/src/main/java/org/prolibertate/games/game/Catalog.kt b/app/src/main/java/org/prolibertate/games/game/Catalog.kt index 63a926b..ec88e0b 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -1,22 +1,39 @@ package org.prolibertate.games.game /** - * The catalogue the main menu is built from. + * The menus the main screen is built from. * - * Every game the app intends to ship appears here, whether or not its rules - * engine exists yet. [GameDescriptor.available] is what the menu keys off to - * decide between "play" and "coming soon", so adding a game is a matter of - * flipping that flag once its engine and screen land. + * A tree rather than a list, because "trick-taking" is a kind of card game + * rather than a rival to it. [parent] is read from a `when` rather than passed + * to the constructor: an enum entry cannot refer to another entry while the + * class is still initialising, and a lookup afterwards is free. */ -enum class GameCategory(val label: String) { - CARD("Playing Card Games"), - BOARD("Board Games"), +enum class GameMenu(val label: String, val blurb: String) { + SOLITAIRE("Solitaire", "Played alone, against the deal or the board."), + CARDS("Card Games", "Anything played with a pack."), + TRICK_TAKING("Trick-taking", "Lead, follow, and count what you took."), + BOARD("Board Games", "Boards, pieces and dice."), + ; + + val parent: GameMenu? get() = when (this) { + TRICK_TAKING -> CARDS + else -> null + } + + val children: List get() = entries.filter { it.parent == this } + + val isTopLevel: Boolean get() = parent == null } data class GameDescriptor( val id: String, val title: String, - val category: GameCategory, + /** + * Every menu this game appears under. A patience is both solitaire and a + * card game, and Euchre is both a card game and a trick-taking one — so + * this is a set, and the same game is listed in each place it belongs. + */ + val menus: Set, val minPlayers: Int, val maxPlayers: Int, /** True when players are grouped into partnerships rather than playing solo. */ @@ -25,6 +42,14 @@ data class GameDescriptor( val available: Boolean, ) +/** + * The catalogue the menus are built from. + * + * Every game the app intends to ship appears here, whether or not its rules + * engine exists yet. [GameDescriptor.available] is what the menu keys off to + * decide between "play" and "coming soon", so adding a game is a matter of + * flipping that flag once its engine and screen land. + */ object GameCatalog { const val EUCHRE = "euchre" @@ -34,6 +59,15 @@ object GameCatalog { const val WIZARD = "wizard" const val CRAZY_EIGHTS = "crazy8s" const val CRIBBAGE = "cribbage" + + const val HEARTS = "hearts" + const val PEG_SOLITAIRE = "pegsolitaire" + + const val YAHTZEE = "yahtzee" + const val KLONDIKE = "klondike" + const val FREECELL = "freecell" + const val SPIDER = "spider" + const val PYRAMID = "pyramid" const val SEQUENCE = "sequence" const val MASTERMIND = "mastermind" const val BACKGAMMON = "backgammon" @@ -47,7 +81,7 @@ object GameCatalog { GameDescriptor( id = EUCHRE, title = "Euchre", - category = GameCategory.CARD, + menus = setOf(GameMenu.CARDS, GameMenu.TRICK_TAKING), minPlayers = 4, maxPlayers = 4, teamBased = true, @@ -57,7 +91,7 @@ object GameCatalog { GameDescriptor( id = KAISER, title = "Kaiser", - category = GameCategory.CARD, + menus = setOf(GameMenu.CARDS, GameMenu.TRICK_TAKING), minPlayers = 4, maxPlayers = 4, teamBased = true, @@ -67,7 +101,7 @@ object GameCatalog { GameDescriptor( id = PRESIDENT, title = "President", - category = GameCategory.CARD, + menus = setOf(GameMenu.CARDS), minPlayers = 3, maxPlayers = 7, teamBased = false, @@ -77,7 +111,7 @@ object GameCatalog { GameDescriptor( id = GOLF, title = "Golf", - category = GameCategory.CARD, + menus = setOf(GameMenu.CARDS), minPlayers = 2, maxPlayers = 6, teamBased = false, @@ -87,7 +121,7 @@ object GameCatalog { GameDescriptor( id = WIZARD, title = "Wizard", - category = GameCategory.CARD, + menus = setOf(GameMenu.CARDS, GameMenu.TRICK_TAKING), minPlayers = 3, maxPlayers = 6, teamBased = false, @@ -97,7 +131,7 @@ object GameCatalog { GameDescriptor( id = CRAZY_EIGHTS, title = "Crazy 8s", - category = GameCategory.CARD, + menus = setOf(GameMenu.CARDS), minPlayers = 2, maxPlayers = 6, teamBased = false, @@ -107,7 +141,7 @@ object GameCatalog { GameDescriptor( id = CRIBBAGE, title = "Cribbage", - category = GameCategory.CARD, + menus = setOf(GameMenu.CARDS), minPlayers = 2, maxPlayers = 4, // Only at four, where the two pairs share a board between them. @@ -116,10 +150,65 @@ object GameCatalog { "twice round the board to 121.", available = true, ), + GameDescriptor( + id = HEARTS, + title = "Hearts", + menus = setOf(GameMenu.CARDS, GameMenu.TRICK_TAKING), + minPlayers = 4, + maxPlayers = 4, + teamBased = false, + blurb = "Take no trick worth taking. Every heart costs a point, the queen " + + "of spades costs thirteen, and taking all of them costs everybody else.", + available = true, + ), + GameDescriptor( + id = KLONDIKE, + title = "Klondike", + menus = setOf(GameMenu.SOLITAIRE, GameMenu.CARDS), + minPlayers = 1, + maxPlayers = 1, + teamBased = false, + blurb = "Patience, as everyone means it. Seven columns down to the aces, " + + "and a pack that decides most of it before you start.", + available = true, + ), + GameDescriptor( + id = FREECELL, + title = "FreeCell", + menus = setOf(GameMenu.SOLITAIRE, GameMenu.CARDS), + minPlayers = 1, + maxPlayers = 1, + teamBased = false, + blurb = "Every card face up from the start, and four cells to park what is " + + "in the way. Nearly every deal can be won by someone good enough.", + available = true, + ), + GameDescriptor( + id = SPIDER, + title = "Spider", + menus = setOf(GameMenu.SOLITAIRE, GameMenu.CARDS), + minPlayers = 1, + maxPlayers = 1, + teamBased = false, + blurb = "Two packs, ten columns, eight runs to build from king to ace. " + + "One suit is a pastime; four is a fight.", + available = true, + ), + GameDescriptor( + id = PYRAMID, + title = "Pyramid", + menus = setOf(GameMenu.SOLITAIRE, GameMenu.CARDS), + minPlayers = 1, + maxPlayers = 1, + teamBased = false, + blurb = "Take cards away in pairs that make thirteen, kings alone. " + + "Nothing is built and nothing goes home — the pyramid just has to go.", + available = true, + ), GameDescriptor( id = SEQUENCE, title = "Sequence", - category = GameCategory.BOARD, + menus = setOf(GameMenu.BOARD, GameMenu.CARDS), minPlayers = 2, maxPlayers = 12, teamBased = true, @@ -129,7 +218,7 @@ object GameCatalog { GameDescriptor( id = MASTERMIND, title = "Mastermind", - category = GameCategory.BOARD, + menus = setOf(GameMenu.BOARD), minPlayers = 2, maxPlayers = 2, teamBased = false, @@ -140,7 +229,7 @@ object GameCatalog { GameDescriptor( id = BACKGAMMON, title = "Backgammon", - category = GameCategory.BOARD, + menus = setOf(GameMenu.BOARD), minPlayers = 2, maxPlayers = 2, teamBased = false, @@ -150,7 +239,7 @@ object GameCatalog { GameDescriptor( id = CHESS, title = "Chess", - category = GameCategory.BOARD, + menus = setOf(GameMenu.BOARD), minPlayers = 2, maxPlayers = 2, teamBased = false, @@ -160,7 +249,7 @@ object GameCatalog { GameDescriptor( id = CHECKERS, title = "Checkers", - category = GameCategory.BOARD, + menus = setOf(GameMenu.BOARD), minPlayers = 2, maxPlayers = 2, teamBased = false, @@ -170,17 +259,41 @@ object GameCatalog { GameDescriptor( id = MORRIS, title = "Nine Men's Morris", - category = GameCategory.BOARD, + menus = setOf(GameMenu.BOARD), minPlayers = 2, maxPlayers = 2, teamBased = false, blurb = "Three in a row takes a piece. Reduce them to two and the game is yours.", available = true, ), + GameDescriptor( + id = PEG_SOLITAIRE, + title = "Peg Solitaire", + menus = setOf(GameMenu.SOLITAIRE, GameMenu.BOARD), + // The first game here played alone. Everything downstream reads the + // seat count from these two numbers, so one seat is all it takes. + minPlayers = 1, + maxPlayers = 1, + teamBased = false, + blurb = "The tee game from the table, and four larger boards. Jump a peg, " + + "take the one you passed, and try to finish with one standing.", + available = true, + ), + GameDescriptor( + id = YAHTZEE, + title = "Yahtzee", + menus = setOf(GameMenu.BOARD), + minPlayers = 1, + maxPlayers = 6, + teamBased = false, + blurb = "Five dice, three throws, thirteen boxes to fill. Every box can be " + + "written in once, so the hard part is what to give up on.", + available = true, + ), GameDescriptor( id = PIRATES, title = "Pirates and Bulgars", - category = GameCategory.BOARD, + menus = setOf(GameMenu.BOARD), minPlayers = 2, maxPlayers = 2, teamBased = false, @@ -191,7 +304,7 @@ object GameCatalog { GameDescriptor( id = TAYU, title = "Ta Yü", - category = GameCategory.BOARD, + menus = setOf(GameMenu.BOARD), minPlayers = 2, maxPlayers = 4, // Two play solo, four play in partnerships across the two axes. @@ -213,13 +326,24 @@ object GameCatalog { * production build lists only what can actually be played, so nobody * downloads a release and taps into a dead end. */ - fun byCategory( - category: GameCategory, + fun inMenu( + menu: GameMenu, includeComingSoon: Boolean = true, ): List = 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 get() = all.filter { it.available } } diff --git a/app/src/main/java/org/prolibertate/games/game/freecell/FreeCellModel.kt b/app/src/main/java/org/prolibertate/games/game/freecell/FreeCellModel.kt new file mode 100644 index 0000000..620c9e8 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/freecell/FreeCellModel.kt @@ -0,0 +1,81 @@ +package org.prolibertate.games.game.freecell + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.prolibertate.games.game.cards.Card + +const val COLUMNS: Int = 8 +const val FOUNDATIONS: Int = 4 + +@Serializable +enum class CellKind { CELL, TABLEAU, FOUNDATION } + +@Serializable +data class Place(val kind: CellKind, val index: Int) { + companion object { + fun cell(index: Int) = Place(CellKind.CELL, index) + fun column(index: Int) = Place(CellKind.TABLEAU, index) + fun foundation(index: Int) = Place(CellKind.FOUNDATION, index) + } +} + +@Serializable +data class FreeCellOptions( + /** + * Four cells is the game as everyone knows it. Fewer is the same game made + * hard, since the cells are the only place a card can wait. + */ + val freeCells: Int = 4, + /** + * Move a run in one gesture rather than a card at a time. The rules do not + * actually allow it — a run is moved by shuffling cards through the cells — + * but every computer version does it for you, because doing it by hand is + * bookkeeping rather than a decision. + */ + val allowSupermoves: Boolean = true, +) { + init { + require(freeCells in 1..6) { "One to six cells" } + } +} + +@Serializable +data class FreeCellState( + val options: FreeCellOptions, + val seed: Long, + /** Null where a cell is empty. */ + val cells: List, + val foundations: List>, + /** Every card is face up in FreeCell; there is nothing buried to turn. */ + val tableau: List>, + val moves: Int, + val log: List, +) { + val freeCellCount: Int get() = cells.count { it == null } + val emptyColumns: Int get() = tableau.count { it.isEmpty() } + val isWon: Boolean get() = foundations.all { it.size == 13 } +} + +@Serializable +sealed interface FreeCellMove + +@Serializable +@SerialName("move") +data class MoveTo(val from: Place, val to: Place, val count: Int = 1) : FreeCellMove + +/** + * How many cards can travel together. + * + * A run is really moved one card at a time through the free cells and the empty + * columns, so what can be shifted is what those could have held: one per free + * cell plus the card itself, doubled for every empty column that could be used + * as a staging pile. Moving *into* an empty column cannot also use it, which is + * the fiddly part and the reason [intoEmptyColumn] exists. + */ +fun maxRunLength(freeCells: Int, emptyColumns: Int, intoEmptyColumn: Boolean): Int { + val usable = if (intoEmptyColumn) emptyColumns - 1 else emptyColumns + if (usable < 0) return 1 + var limit = freeCells + 1 + repeat(usable) { limit *= 2 } + return limit +} diff --git a/app/src/main/java/org/prolibertate/games/game/freecell/FreeCellRules.kt b/app/src/main/java/org/prolibertate/games/game/freecell/FreeCellRules.kt new file mode 100644 index 0000000..c082c0c --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/freecell/FreeCellRules.kt @@ -0,0 +1,193 @@ +package org.prolibertate.games.game.freecell + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.prolibertate.games.game.GameCatalog +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.cards.Decks +import org.prolibertate.games.game.cards.shuffledWith +import org.prolibertate.games.game.engine.GameRules +import org.prolibertate.games.game.engine.TableConfig +import org.prolibertate.games.game.solitaire.acceptsOnFoundation +import org.prolibertate.games.game.solitaire.buildsDownAlternating +import org.prolibertate.games.game.solitaire.isRun +import kotlin.random.Random + +/** + * FreeCell as a pure state machine. See RULES-freecell.md. + * + * The whole pack is face up from the first move, so unlike Klondike there is + * nothing hidden and nothing to be lucky about — very nearly every deal can be + * won by someone who plays it well enough. + */ +object FreeCellRules : GameRules { + + override val gameId: String = GameCatalog.FREECELL + const val SOLO_SEAT: Int = 0 + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + override fun initialState(config: TableConfig): FreeCellState { + val options = json.decodeFromString(config.optionsJson) + require(config.seats.size == 1) { "FreeCell seats one, not ${config.seats.size}" } + return deal(options, config.seed) + } + + private fun deal(options: FreeCellOptions, seed: Long): FreeCellState { + val deck = Decks.standard52().shuffledWith(Random(seed)) + // Fifty-two into eight leaves four columns of seven and four of six. + val columns = mutableListOf>() + var at = 0 + repeat(COLUMNS) { index -> + val size = if (index < 4) 7 else 6 + columns += deck.subList(at, at + size).toList() + at += size + } + return FreeCellState( + options = options, + seed = seed, + cells = List(options.freeCells) { null }, + foundations = List(FOUNDATIONS) { emptyList() }, + tableau = columns, + moves = 0, + log = listOf("Dealt, with ${options.freeCells} cells."), + ) + } + + override fun currentSeat(state: FreeCellState): Int? = + if (isFinished(state)) null else SOLO_SEAT + + /** The longest run sitting on the end of a column, by the build rule. */ + fun movableTail(column: List): List { + if (column.isEmpty()) return emptyList() + var take = 1 + while (take < column.size) { + val candidate = column.takeLast(take + 1) + if (!isRun(candidate, ::buildsDownAlternating)) break + take++ + } + return column.takeLast(take) + } + + override fun legalMoves(state: FreeCellState, seat: Int): List { + if (seat != SOLO_SEAT || state.isWon) return emptyList() + val moves = mutableListOf() + + fun offer(from: Place, cards: List) { + val head = cards.first() + if (cards.size == 1) { + val foundation = head.suit.ordinal + if (acceptsOnFoundation(state.foundations[foundation], head)) { + moves += MoveTo(from, Place.foundation(foundation), 1) + } + state.cells.indices.forEach { cell -> + if (state.cells[cell] == null && from.kind != CellKind.CELL) { + moves += MoveTo(from, Place.cell(cell), 1) + } + } + } + state.tableau.indices.forEach { column -> + if (from.kind == CellKind.TABLEAU && from.index == column) return@forEach + val target = state.tableau[column] + val fits = target.lastOrNull()?.let { buildsDownAlternating(head, it) } ?: true + if (!fits) return@forEach + // Moving a whole column into an empty one is a no-op that would + // let a game shuffle for ever. + if (target.isEmpty() && from.kind == CellKind.TABLEAU && + cards.size == state.tableau[from.index].size + ) { + return@forEach + } + val allowed = maxRunLength( + freeCells = state.freeCellCount, + emptyColumns = state.emptyColumns, + intoEmptyColumn = target.isEmpty(), + ).let { if (state.options.allowSupermoves) it else 1 } + if (cards.size <= allowed) moves += MoveTo(from, Place.column(column), cards.size) + } + } + + state.cells.forEachIndexed { index, card -> + card?.let { offer(Place.cell(index), listOf(it)) } + } + state.tableau.forEachIndexed { index, column -> + val tail = movableTail(column) + // Every length of the tail run can travel, not just the whole of it. + for (count in 1..tail.size) { + offer(Place.column(index), tail.takeLast(count)) + } + } + return moves + } + + override fun applyMove(state: FreeCellState, seat: Int, move: FreeCellMove): FreeCellState { + require(seat == SOLO_SEAT) { "FreeCell has one seat" } + require(legalMoves(state, seat).contains(move)) { "$move is not legal here" } + move as MoveTo + val cards = when (move.from.kind) { + CellKind.CELL -> listOfNotNull(state.cells[move.from.index]) + CellKind.TABLEAU -> state.tableau[move.from.index].takeLast(move.count) + CellKind.FOUNDATION -> state.foundations[move.from.index].takeLast(move.count) + } + var next = when (move.from.kind) { + CellKind.CELL -> state.copy(cells = state.cells.replacing(move.from.index, null)) + CellKind.TABLEAU -> state.copy( + tableau = state.tableau.replacing( + move.from.index, + state.tableau[move.from.index].dropLast(move.count), + ), + ) + + CellKind.FOUNDATION -> state.copy( + foundations = state.foundations.replacing( + move.from.index, + state.foundations[move.from.index].dropLast(move.count), + ), + ) + } + next = when (move.to.kind) { + CellKind.CELL -> next.copy(cells = next.cells.replacing(move.to.index, cards.single())) + CellKind.TABLEAU -> next.copy( + tableau = next.tableau.replacing( + move.to.index, + next.tableau[move.to.index] + cards, + ), + ) + + CellKind.FOUNDATION -> next.copy( + foundations = next.foundations.replacing( + move.to.index, + next.foundations[move.to.index] + cards, + ), + ) + } + return next.copy( + moves = state.moves + 1, + log = if (next.isWon) next.log + "Out, in ${state.moves + 1} moves." else next.log, + ) + } + + private fun List.replacing(index: Int, value: T): List = + toMutableList().also { it[index] = value } + + override fun isFinished(state: FreeCellState): Boolean = + state.isWon || legalMoves(state, SOLO_SEAT).isEmpty() + + override fun summary(state: FreeCellState): String = when { + state.isWon -> "Out in ${state.moves} moves" + isFinished(state) -> "Blocked, ${state.foundations.sumOf { it.size }} cards home" + else -> "${state.foundations.sumOf { it.size }} of 52 home" + } + + override fun viewFor(state: FreeCellState, seat: Int): FreeCellState = state + + override fun encodeState(state: FreeCellState): String = json.encodeToString(state) + override fun decodeState(json: String): FreeCellState = this.json.decodeFromString(json) + override fun encodeMove(move: FreeCellMove): String = json.encodeToString(move) + override fun decodeMove(json: String): FreeCellMove = this.json.decodeFromString(json) + + fun restart(state: FreeCellState): FreeCellState = deal(state.options, state.seed) +} diff --git a/app/src/main/java/org/prolibertate/games/game/hearts/HeartsAi.kt b/app/src/main/java/org/prolibertate/games/game/hearts/HeartsAi.kt new file mode 100644 index 0000000..a9e1ca9 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/hearts/HeartsAi.kt @@ -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 { + + override fun chooseMove(state: HeartsState, seat: Int, legal: List): 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 { + 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): 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 { + val cards = legal.filterIsInstance().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 { + 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 { + 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 { + 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 } + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/hearts/HeartsModel.kt b/app/src/main/java/org/prolibertate/games/game/hearts/HeartsModel.kt new file mode 100644 index 0000000..63c56ef --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/hearts/HeartsModel.kt @@ -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>, + /** Survives redaction so a client can draw the backs of other hands. */ + val handCounts: List, + /** What each seat has chosen to pass, before the swap happens. */ + val passSelections: List>, + 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, + /** Held on the table so a finished trick can be read before it is swept. */ + val completedTrick: List = 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>, + val heartsBroken: Boolean, + val scores: List, + val roundScores: List, + val log: List, +) { + 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) : 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): 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>, allowShootTheMoon: Boolean): List { + 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 } +} diff --git a/app/src/main/java/org/prolibertate/games/game/hearts/HeartsRules.kt b/app/src/main/java/org/prolibertate/games/game/hearts/HeartsRules.kt new file mode 100644 index 0000000..4185381 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/hearts/HeartsRules.kt @@ -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 { + + override val gameId: String = GameCatalog.HEARTS + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + override fun initialState(config: TableConfig): HeartsState { + val options = json.decodeFromString(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, + log: List, + ): 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({ it.suit.ordinal }, { it.rank.order }) + + private fun holderOfTwoOfClubs(hands: List>): 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 = + 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 { + 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() } + 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, size: Int): List> { + if (size == 0) return listOf(emptyList()) + if (cards.size < size) return emptyList() + val result = mutableListOf>() + fun walk(start: Int, chosen: List) { + 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 + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeAi.kt b/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeAi.kt new file mode 100644 index 0000000..b542471 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeAi.kt @@ -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 { + + override fun chooseMove(state: KlondikeState, seat: Int, legal: List): 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() + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeModel.kt b/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeModel.kt new file mode 100644 index 0000000..24f29b5 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeModel.kt @@ -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 = emptyList(), + val faceUp: List = 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, + /** Face up; the last card is the one in play. */ + val waste: List, + /** One per suit, in [Suit] order, each running ace upwards. */ + val foundations: List>, + val tableau: List, + val redealsUsed: Int, + val moves: Int, + val log: List, +) { + /** 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): 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] diff --git a/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeRules.kt b/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeRules.kt new file mode 100644 index 0000000..5419e0c --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/klondike/KlondikeRules.kt @@ -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 { + + 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(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 { + if (seat != SOLO_SEAT || state.isWon) return emptyList() + val moves = mutableListOf() + 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 = 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): 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 List.replacing(index: Int, value: T): List = + 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) +} diff --git a/app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireAi.kt b/app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireAi.kt new file mode 100644 index 0000000..0b73946 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireAi.kt @@ -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 { + + override fun chooseMove( + state: PegSolitaireState, + seat: Int, + legal: List, + ): PegSolitaireMove { + require(legal.isNotEmpty()) { "No jump available" } + val jumps = legal.filterIsInstance() + 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) + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireModel.kt b/app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireModel.kt new file mode 100644 index 0000000..2112031 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireModel.kt @@ -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 = 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> = 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 = + (0 until rows).flatMap { r -> (0..r).map { c -> Hole(r, c) } }.toSet() + + private fun square(size: Int, keep: (Int, Int) -> Boolean): Set = + (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, + val jumps: Int, + /** Held so the screen can show what just happened. */ + val lastJump: PegJump? = null, + val log: List, +) { + 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) +} diff --git a/app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireRules.kt b/app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireRules.kt new file mode 100644 index 0000000..e885805 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireRules.kt @@ -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 { + + 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(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 { + 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 = + 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.", + ) + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/pyramid/PyramidModel.kt b/app/src/main/java/org/prolibertate/games/game/pyramid/PyramidModel.kt new file mode 100644 index 0000000..59fcabd --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/pyramid/PyramidModel.kt @@ -0,0 +1,98 @@ +package org.prolibertate.games.game.pyramid + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.solitaire.patienceOrder + +/** Seven rows of one to seven cards. */ +const val ROWS: Int = 7 +const val PYRAMID_SIZE: Int = ROWS * (ROWS + 1) / 2 + +/** A pair has to make this, and a king makes it alone. */ +const val TARGET: Int = 13 + +@Serializable +enum class PyramidZone { PYRAMID, WASTE } + +@Serializable +data class PyramidSpot(val zone: PyramidZone, val index: Int = 0) + +@Serializable +data class PyramidOptions( + /** How many times the waste may be turned back over. */ + val redeals: Int = 2, +) { + init { + require(redeals >= 0) { "redeals cannot be negative" } + } +} + +@Serializable +data class PyramidState( + val options: PyramidOptions, + val seed: Long, + /** Twenty-eight places; null where the card has been taken. */ + val pyramid: List, + val stock: List, + val waste: List, + val redealsUsed: Int, + val moves: Int, + val log: List, +) { + val isWon: Boolean get() = pyramid.all { it == null } + + val canRedeal: Boolean + get() = stock.isEmpty() && waste.isNotEmpty() && redealsUsed < options.redeals +} + +@Serializable +sealed interface PyramidMove + +@Serializable +@SerialName("draw") +data object DrawCard : PyramidMove + +@Serializable +@SerialName("redeal") +data object RecycleWaste : PyramidMove + +/** A king is worth thirteen on its own, so it needs no partner. */ +@Serializable +@SerialName("king") +data class TakeKing(val spot: PyramidSpot) : PyramidMove + +@Serializable +@SerialName("pair") +data class TakePair(val first: PyramidSpot, val second: PyramidSpot) : PyramidMove + +/** Ace one through king thirteen, which is what the pairing is counted in. */ +fun valueOf(card: Card): Int = card.rank.patienceOrder + +/** Where a row starts in the flattened pyramid. */ +fun rowStart(row: Int): Int = row * (row + 1) / 2 + +/** Which row a place is in. */ +fun rowOf(index: Int): Int { + var row = 0 + while (rowStart(row + 1) <= index) row++ + return row +} + +/** + * The two places resting on [index], if any. + * + * A card is only in play once both of them are gone, which is the whole shape of + * the game: the pyramid has to be taken apart from the bottom. + */ +fun coveredBy(index: Int): List { + val row = rowOf(index) + if (row == ROWS - 1) return emptyList() + val offset = index - rowStart(row) + val below = rowStart(row + 1) + offset + return listOf(below, below + 1) +} + +/** Whether the card at [index] has nothing left sitting on it. */ +fun isExposed(pyramid: List, index: Int): Boolean = + pyramid[index] != null && coveredBy(index).all { pyramid[it] == null } diff --git a/app/src/main/java/org/prolibertate/games/game/pyramid/PyramidRules.kt b/app/src/main/java/org/prolibertate/games/game/pyramid/PyramidRules.kt new file mode 100644 index 0000000..98aed3d --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/pyramid/PyramidRules.kt @@ -0,0 +1,150 @@ +package org.prolibertate.games.game.pyramid + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.prolibertate.games.game.GameCatalog +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.cards.Decks +import org.prolibertate.games.game.cards.shuffledWith +import org.prolibertate.games.game.engine.GameRules +import org.prolibertate.games.game.engine.TableConfig +import kotlin.random.Random + +/** + * Pyramid as a pure state machine. See RULES-pyramid.md. + * + * The odd one out among the patiences here: nothing is built and nothing goes + * home in order. Cards are taken away in pairs that make thirteen, and the only + * structure is which of them are still buried. + */ +object PyramidRules : GameRules { + + override val gameId: String = GameCatalog.PYRAMID + const val SOLO_SEAT: Int = 0 + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + override fun initialState(config: TableConfig): PyramidState { + val options = json.decodeFromString(config.optionsJson) + require(config.seats.size == 1) { "Pyramid seats one, not ${config.seats.size}" } + return deal(options, config.seed) + } + + private fun deal(options: PyramidOptions, seed: Long): PyramidState { + val deck = Decks.standard52().shuffledWith(Random(seed)) + return PyramidState( + options = options, + seed = seed, + pyramid = deck.take(PYRAMID_SIZE), + stock = deck.drop(PYRAMID_SIZE), + waste = emptyList(), + redealsUsed = 0, + moves = 0, + log = listOf("Dealt."), + ) + } + + override fun currentSeat(state: PyramidState): Int? = + if (isFinished(state)) null else SOLO_SEAT + + /** Every card that can be reached: the exposed pyramid, and the waste's top. */ + fun available(state: PyramidState): List> { + val fromPyramid = state.pyramid.indices + .filter { isExposed(state.pyramid, it) } + .map { PyramidSpot(PyramidZone.PYRAMID, it) to state.pyramid[it]!! } + val fromWaste = state.waste.lastOrNull() + ?.let { listOf(PyramidSpot(PyramidZone.WASTE) to it) } + .orEmpty() + return fromPyramid + fromWaste + } + + override fun legalMoves(state: PyramidState, seat: Int): List { + if (seat != SOLO_SEAT || state.isWon) return emptyList() + val moves = mutableListOf() + if (state.stock.isNotEmpty()) moves += DrawCard + if (state.canRedeal) moves += RecycleWaste + + val reachable = available(state) + reachable.forEach { (spot, card) -> + if (valueOf(card) == TARGET) moves += TakeKing(spot) + } + // Each pair once: the second card is only ever looked for after the + // first, so a pair cannot be offered both ways round. + for (i in reachable.indices) { + for (j in i + 1 until reachable.size) { + val (spotA, cardA) = reachable[i] + val (spotB, cardB) = reachable[j] + if (valueOf(cardA) + valueOf(cardB) == TARGET) { + moves += TakePair(spotA, spotB) + } + } + } + return moves + } + + override fun applyMove(state: PyramidState, seat: Int, move: PyramidMove): PyramidState { + require(seat == SOLO_SEAT) { "Pyramid has one seat" } + require(legalMoves(state, seat).contains(move)) { "$move is not legal here" } + val next = when (move) { + DrawCard -> state.copy( + stock = state.stock.dropLast(1), + waste = state.waste + state.stock.last(), + ) + + RecycleWaste -> state.copy( + stock = state.waste.reversed(), + waste = emptyList(), + redealsUsed = state.redealsUsed + 1, + log = state.log + "Waste turned back over.", + ) + + is TakeKing -> remove(state, listOf(move.spot)) + is TakePair -> remove(state, listOf(move.first, move.second)) + } + return next.copy( + moves = state.moves + 1, + log = if (next.isWon) next.log + "Pyramid cleared, in ${state.moves + 1} moves." else next.log, + ) + } + + /** + * Takes cards off the table. + * + * The pyramid places are blanked rather than compacted, because a place is + * what tells the two cards above it that they are free — an index that + * shifted would uncover the wrong cards. + */ + private fun remove(state: PyramidState, spots: List): PyramidState { + val pyramid = state.pyramid.toMutableList() + var waste = state.waste + spots.forEach { spot -> + when (spot.zone) { + PyramidZone.PYRAMID -> pyramid[spot.index] = null + // Only ever the top card, so dropping the last is enough. + PyramidZone.WASTE -> waste = waste.dropLast(1) + } + } + return state.copy(pyramid = pyramid.toList(), waste = waste) + } + + override fun isFinished(state: PyramidState): Boolean = + state.isWon || legalMoves(state, SOLO_SEAT).isEmpty() + + override fun summary(state: PyramidState): String = when { + state.isWon -> "Cleared in ${state.moves} moves" + isFinished(state) -> "Blocked, ${state.pyramid.count { it != null }} cards left" + else -> "${state.pyramid.count { it != null }} of $PYRAMID_SIZE left" + } + + override fun viewFor(state: PyramidState, seat: Int): PyramidState = state + + override fun encodeState(state: PyramidState): String = json.encodeToString(state) + override fun decodeState(json: String): PyramidState = this.json.decodeFromString(json) + override fun encodeMove(move: PyramidMove): String = json.encodeToString(move) + override fun decodeMove(json: String): PyramidMove = this.json.decodeFromString(json) + + fun restart(state: PyramidState): PyramidState = deal(state.options, state.seed) +} diff --git a/app/src/main/java/org/prolibertate/games/game/solitaire/HintAi.kt b/app/src/main/java/org/prolibertate/games/game/solitaire/HintAi.kt new file mode 100644 index 0000000..682067e --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/solitaire/HintAi.kt @@ -0,0 +1,24 @@ +package org.prolibertate.games.game.solitaire + +import org.prolibertate.games.game.engine.GameAi + +/** + * Takes the first move on offer. + * + * Every patience here has one seat and it belongs to the person playing, so + * nothing asks this during an ordinary game. [MatchController] needs an AI to + * construct, though, and a seat that could be left empty has to be playable by + * something. + * + * It is deliberately not clever. A greedy patience player that looked good would + * be worse than this: it would be trusted as a hint, and a hint that walks a run + * back and forth is more annoying than no hint at all. Klondike has a real one + * because its move ordering is obvious; these three do not, and pretending + * otherwise would be the wrong kind of helpful. + */ +class FirstLegalAi : GameAi { + override fun chooseMove(state: S, seat: Int, legal: List): M { + require(legal.isNotEmpty()) { "No legal move for seat $seat" } + return legal.first() + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/solitaire/Patience.kt b/app/src/main/java/org/prolibertate/games/game/solitaire/Patience.kt new file mode 100644 index 0000000..653561f --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/solitaire/Patience.kt @@ -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): 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, builds: (Card, Card) -> Boolean): Boolean = + cards.isNotEmpty() && cards.zipWithNext().all { (upper, lower) -> builds(lower, upper) } diff --git a/app/src/main/java/org/prolibertate/games/game/spider/SpiderModel.kt b/app/src/main/java/org/prolibertate/games/game/spider/SpiderModel.kt new file mode 100644 index 0000000..e8fc7fb --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/spider/SpiderModel.kt @@ -0,0 +1,107 @@ +package org.prolibertate.games.game.spider + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.cards.Rank +import org.prolibertate.games.game.cards.Suit +import org.prolibertate.games.game.solitaire.patienceOrder + +const val COLUMNS: Int = 10 + +/** Eight complete runs, king down to ace, is the whole pack cleared. */ +const val RUNS_TO_WIN: Int = 8 +const val RUN_LENGTH: Int = 13 + +@Serializable +data class SpiderOptions( + /** + * How many suits the two packs are made of. One suit is the game people + * actually finish; four is the real thing and is brutal. Two sits between + * them. The pack is always a hundred and four cards either way — only how + * many different suits those cards are drawn from changes. + */ + val suits: Int = 1, +) { + init { + require(suits == 1 || suits == 2 || suits == 4) { "Spider is played with one, two or four suits" } + } +} + +@Serializable +data class SpiderPile( + val faceDown: List = emptyList(), + val faceUp: List = emptyList(), +) { + val isEmpty: Boolean get() = faceDown.isEmpty() && faceUp.isEmpty() + val top: Card? get() = faceUp.lastOrNull() +} + +@Serializable +data class SpiderState( + val options: SpiderOptions, + val seed: Long, + val stock: List, + val tableau: List, + /** Runs sent away complete. Eight of them wins. */ + val completed: Int, + val moves: Int, + val log: List, +) { + val isWon: Boolean get() = completed == RUNS_TO_WIN + + /** + * A row cannot be dealt onto an empty column — the rule that stops a deal + * from being used to dodge a position with nowhere to put anything. + */ + val canDeal: Boolean get() = stock.isNotEmpty() && tableau.none { it.isEmpty } +} + +@Serializable +sealed interface SpiderMove + +/** One card face up onto every column at once. */ +@Serializable +@SerialName("deal") +data object DealRow : SpiderMove + +@Serializable +@SerialName("move") +data class MoveRun(val from: Int, val to: Int, val count: Int) : SpiderMove + +/** + * Down by one in the same suit. + * + * Spider lets a card *land* on any suit a rank higher, but only a same-suit run + * may travel as a unit — which is the whole difficulty of the four-suit game. + */ +fun sameSuitRun(cards: List): Boolean = + cards.isNotEmpty() && cards.zipWithNext().all { (upper, lower) -> + lower.suit == upper.suit && lower.rank.patienceOrder == upper.rank.patienceOrder - 1 + } + +/** What a column will take on top: anything one rank higher, of any suit. */ +fun landsOn(card: Card, onto: Card): Boolean = + card.rank.patienceOrder == onto.rank.patienceOrder - 1 + +/** King down to ace in one suit, which is a run ready to go away. */ +fun isCompleteRun(cards: List): Boolean = + cards.size == RUN_LENGTH && + cards.first().rank == Rank.KING && + cards.last().rank == Rank.ACE && + sameSuitRun(cards) + +/** + * A hundred and four cards drawn from [suits] suits. + * + * One suit means eight identical spade packs; four means two of each ordinary + * suit. The count never changes, so the deal is the same shape whatever the + * table chose. + */ +fun spiderDeck(suits: Int): List { + val used = Suit.entries.take(suits) + val copies = 8 / suits + return used.flatMap { suit -> + List(copies) { Rank.standard.map { Card(it, suit) } }.flatten() + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/spider/SpiderRules.kt b/app/src/main/java/org/prolibertate/games/game/spider/SpiderRules.kt new file mode 100644 index 0000000..c16ac0e --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/spider/SpiderRules.kt @@ -0,0 +1,177 @@ +package org.prolibertate.games.game.spider + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.prolibertate.games.game.GameCatalog +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.cards.shuffledWith +import org.prolibertate.games.game.engine.GameRules +import org.prolibertate.games.game.engine.TableConfig +import kotlin.random.Random + +/** + * Spider as a pure state machine. See RULES-spider.md. + */ +object SpiderRules : GameRules { + + override val gameId: String = GameCatalog.SPIDER + const val SOLO_SEAT: Int = 0 + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + override fun initialState(config: TableConfig): SpiderState { + val options = json.decodeFromString(config.optionsJson) + require(config.seats.size == 1) { "Spider seats one, not ${config.seats.size}" } + return deal(options, config.seed) + } + + private fun deal(options: SpiderOptions, seed: Long): SpiderState { + val deck = spiderDeck(options.suits).shuffledWith(Random(seed)).toMutableList() + // Fifty-four cards go out: six to the first four columns, five to the + // rest. The remaining fifty are the five rows still to come. + val piles = (0 until COLUMNS).map { index -> + val size = if (index < 4) 6 else 5 + val taken = List(size) { deck.removeAt(deck.size - 1) } + SpiderPile(faceDown = taken.dropLast(1), faceUp = listOf(taken.last())) + } + return SpiderState( + options = options, + seed = seed, + stock = deck.toList(), + tableau = piles, + completed = 0, + moves = 0, + log = listOf("Dealt, ${options.suits} suit${if (options.suits == 1) "" else "s"}."), + ) + } + + override fun currentSeat(state: SpiderState): Int? = + if (isFinished(state)) null else SOLO_SEAT + + /** The longest same-suit descending run on the end of a column. */ + fun movableTail(pile: SpiderPile): List { + if (pile.faceUp.isEmpty()) return emptyList() + var take = 1 + while (take < pile.faceUp.size) { + if (!sameSuitRun(pile.faceUp.takeLast(take + 1))) break + take++ + } + return pile.faceUp.takeLast(take) + } + + override fun legalMoves(state: SpiderState, seat: Int): List { + if (seat != SOLO_SEAT || state.isWon) return emptyList() + val moves = mutableListOf() + if (state.canDeal) moves += DealRow + + state.tableau.forEachIndexed { from, pile -> + val tail = movableTail(pile) + for (count in 1..tail.size) { + val run = tail.takeLast(count) + val head = run.first() + state.tableau.indices.forEach { to -> + if (to == from) return@forEach + val target = state.tableau[to] + val fits = target.top?.let { landsOn(head, it) } ?: true + if (!fits) return@forEach + // Emptying one column only to fill another gains nothing. + if (target.isEmpty && pile.faceDown.isEmpty() && count == pile.faceUp.size) { + return@forEach + } + moves += MoveRun(from, to, count) + } + } + } + return moves + } + + override fun applyMove(state: SpiderState, seat: Int, move: SpiderMove): SpiderState { + require(seat == SOLO_SEAT) { "Spider has one seat" } + require(legalMoves(state, seat).contains(move)) { "$move is not legal here" } + val next = when (move) { + DealRow -> dealRow(state) + is MoveRun -> moveRun(state, move) + } + return next.copy( + moves = state.moves + 1, + log = if (next.isWon) next.log + "Out, in ${state.moves + 1} moves." else next.log, + ) + } + + private fun dealRow(state: SpiderState): SpiderState { + var stock = state.stock + val piles = state.tableau.map { pile -> + val card = stock.lastOrNull() ?: return@map pile + stock = stock.dropLast(1) + pile.copy(faceUp = pile.faceUp + card) + } + // A dealt row can complete a run in any column it landed on. + return sweep(state.copy(stock = stock, tableau = piles), piles.indices.toList()) + } + + private fun moveRun(state: SpiderState, move: MoveRun): SpiderState { + val source = state.tableau[move.from] + val run = source.faceUp.takeLast(move.count) + val left = source.faceUp.dropLast(move.count) + val stripped = if (left.isEmpty() && source.faceDown.isNotEmpty()) { + SpiderPile(source.faceDown.dropLast(1), listOf(source.faceDown.last())) + } else { + source.copy(faceUp = left) + } + val target = state.tableau[move.to] + val piles = state.tableau.toMutableList().also { + it[move.from] = stripped + it[move.to] = target.copy(faceUp = target.faceUp + run) + } + return sweep(state.copy(tableau = piles), listOf(move.to)) + } + + /** + * Sends away any king-to-ace run finished by the last move. + * + * Only the columns that changed are looked at, and only their ends: a + * complete run can only ever be sitting on the bottom of a pile, because + * anything laid on an ace would have to be lower than one. + */ + private fun sweep(state: SpiderState, touched: List): SpiderState { + var piles = state.tableau + var completed = state.completed + var notes = state.log + touched.forEach { index -> + val pile = piles[index] + if (pile.faceUp.size < RUN_LENGTH) return@forEach + if (!isCompleteRun(pile.faceUp.takeLast(RUN_LENGTH))) return@forEach + val left = pile.faceUp.dropLast(RUN_LENGTH) + val stripped = if (left.isEmpty() && pile.faceDown.isNotEmpty()) { + SpiderPile(pile.faceDown.dropLast(1), listOf(pile.faceDown.last())) + } else { + pile.copy(faceUp = left) + } + piles = piles.toMutableList().also { it[index] = stripped } + completed++ + notes = notes + "A run went away — $completed of $RUNS_TO_WIN." + } + return state.copy(tableau = piles, completed = completed, log = notes) + } + + override fun isFinished(state: SpiderState): Boolean = + state.isWon || legalMoves(state, SOLO_SEAT).isEmpty() + + override fun summary(state: SpiderState): String = when { + state.isWon -> "All eight runs, in ${state.moves} moves" + isFinished(state) -> "Blocked with ${state.completed} runs away" + else -> "${state.completed} of $RUNS_TO_WIN runs away" + } + + override fun viewFor(state: SpiderState, seat: Int): SpiderState = state + + override fun encodeState(state: SpiderState): String = json.encodeToString(state) + override fun decodeState(json: String): SpiderState = this.json.decodeFromString(json) + override fun encodeMove(move: SpiderMove): String = json.encodeToString(move) + override fun decodeMove(json: String): SpiderMove = this.json.decodeFromString(json) + + fun restart(state: SpiderState): SpiderState = deal(state.options, state.seed) +} diff --git a/app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeAi.kt b/app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeAi.kt new file mode 100644 index 0000000..b6ecc05 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeAi.kt @@ -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 { + + override fun chooseMove(state: YahtzeeState, seat: Int, legal: List): YahtzeeMove { + require(legal.isNotEmpty()) { "No legal move for seat $seat" } + val rolls = legal.filterIsInstance() + val writes = legal.filterIsInstance() + + // 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): List { + val run = runIndices(dice) + if (run.size >= 4) return run + + val counts = countsOf(dice) + val most = counts.entries + .sortedWith(compareByDescending> { 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): List { + 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() + return dice.indices.filter { index -> + val face = dice[index] + face in wanted && taken.add(face) + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeModel.kt b/app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeModel.kt new file mode 100644 index 0000000..4e14cc3 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeModel.kt @@ -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 = 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, + val rollsUsed: Int, + val cards: List, + /** Hundreds earned for extra Yahtzees, counted per seat. */ + val yahtzeeBonuses: List, + val log: List, +) { + 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 = 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): Map = dice.groupingBy { it }.eachCount() + +/** The longest run of consecutive faces present, however many of each. */ +fun longestRun(dice: List): 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 { + 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): Boolean = + dice.size == DICE_COUNT && dice.toSet().size == 1 diff --git a/app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeRules.kt b/app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeRules.kt new file mode 100644 index 0000000..3e82893 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeRules.kt @@ -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 { + + override val gameId: String = GameCatalog.YAHTZEE + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + override fun initialState(config: TableConfig): YahtzeeState { + val options = json.decodeFromString(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 { + if (state.isOver || state.turn != seat) return emptyList() + val moves = mutableListOf() + 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> = + (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): List { + 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) +} diff --git a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt index e7ae875..5c2c3bd 100644 --- a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt +++ b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt @@ -2,15 +2,17 @@ package org.prolibertate.games.ui import androidx.activity.compose.BackHandler import androidx.compose.foundation.background +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.material3.Button @@ -36,7 +38,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.prolibertate.games.R import org.prolibertate.games.game.GameCatalog -import org.prolibertate.games.game.GameCategory +import org.prolibertate.games.game.GameMenu import org.prolibertate.games.game.GameDescriptor import org.prolibertate.games.game.engine.TableConfig import org.prolibertate.games.net.LobbyController @@ -64,6 +66,9 @@ class AppEnv( */ sealed interface Route { data object Menu : Route + + /** One section of the menu: its games, and any sub-section under it. */ + data class Section(val menu: GameMenu) : Route data object Settings : Route data object Scorekeeper : Route data class Setup(val gameId: String) : Route @@ -152,12 +157,20 @@ private fun AppContent( // Dev builds show the games that are not finished yet; production // releases list only what can actually be played. showComingSoon = showComingSoon, - onPickGame = { push(Route.Setup(it.id)) }, + onPickSection = { push(Route.Section(it)) }, onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) }, onScorekeeper = { push(Route.Scorekeeper) }, onSettings = { push(Route.Settings) }, ) + is Route.Section -> SectionScreen( + menu = route.menu, + showComingSoon = showComingSoon, + onPickGame = { push(Route.Setup(it.id)) }, + onPickSection = { push(Route.Section(it)) }, + onBack = pop, + ) + is Route.Settings -> SettingsScreen( env = env, settings = settings, @@ -202,11 +215,11 @@ private fun AppContent( // Main menu // --------------------------------------------------------------------------- -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun MainMenuScreen( showComingSoon: Boolean, - onPickGame: (GameDescriptor) -> Unit, + onPickSection: (GameMenu) -> Unit, onJoinGame: () -> Unit, onScorekeeper: () -> Unit, onSettings: () -> Unit, @@ -221,51 +234,157 @@ fun MainMenuScreen( ) } ) { padding -> - Column( + // Only the top of the tree. Trick-taking is reached through Card Games, + // which is where somebody looking for it would go. + val sections = GameMenu.entries.filter { + it.isTopLevel && GameCatalog.hasAnything(it, includeComingSoon = showComingSoon) + } + + // One column, held in the middle. There are only ever a handful of + // entries here now that the games themselves live a level down, and a + // short list spread across a grid reads as a page half empty rather + // than as a menu. Capped in width so it does not become a row of very + // wide buttons on a tablet. + Box( modifier = Modifier .fillMaxSize() .padding(padding) .padding(horizontal = 12.dp), + contentAlignment = Alignment.Center, ) { - // Wraps rather than running off the edge: two buttons and a narrow - // phone would otherwise push the second one past the screen. - FlowRow( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), + Column( + modifier = Modifier + .widthIn(max = 340.dp) + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { - OutlinedButton(onClick = onJoinGame) { Text("Join a game nearby") } + sections.forEach { section -> + SectionTile( + menu = section, + count = countIn(section, showComingSoon), + onClick = { onPickSection(section) }, + ) + } + + // Below the games, and full width like them, so the column + // stays one column all the way down. + OutlinedButton( + onClick = onJoinGame, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) { Text("Join a game nearby") } // Not a game, and deliberately not in the catalogue: it keeps // score for whatever is being played on the actual table. - OutlinedButton(onClick = onScorekeeper) { Text("Scorekeeper") } + OutlinedButton( + onClick = onScorekeeper, + modifier = Modifier.fillMaxWidth(), + ) { Text("Scorekeeper") } } + } + } +} - // The column count follows the available width, so a phone in - // portrait gets two and a tablet in landscape gets four or more. - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 172.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxSize(), - ) { - GameCategory.entries.forEach { category -> - val games = GameCatalog.byCategory(category, includeComingSoon = showComingSoon) - // Don't leave a heading stranded over nothing. - if (games.isEmpty()) return@forEach - item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) { - Text( - text = category.label, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), - ) - } - items(games) { game -> - GameTile(game = game, onClick = { onPickGame(game) }) - } +/** + * How many games a section offers, counting whatever is under it as well. + * + * A game in both a section and its sub-section is one game, not two, so this + * counts a set rather than adding the lists together. + */ +private fun countIn(menu: GameMenu, showComingSoon: Boolean): Int = + (GameCatalog.inMenu(menu, showComingSoon) + + menu.children.flatMap { GameCatalog.inMenu(it, showComingSoon) }) + .toSet().size + +/** + * One section of the menu. + * + * Sub-sections come first and its own games after, so Card Games opens on + * "Trick-taking" and then everything played with a pack — including the + * trick-taking games themselves, which belong in both places and are listed in + * both. A game reachable one way and not the other would just be a game + * somebody could not find. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SectionScreen( + menu: GameMenu, + showComingSoon: Boolean, + onPickGame: (GameDescriptor) -> Unit, + onPickSection: (GameMenu) -> Unit, + onBack: () -> Unit, +) { + val children = menu.children.filter { + GameCatalog.hasAnything(it, includeComingSoon = showComingSoon) + } + val games = GameCatalog.inMenu(menu, includeComingSoon = showComingSoon) + + ScreenScaffold(title = menu.label, onBack = onBack) { modifier -> + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 172.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier.fillMaxSize(), + ) { + item(span = { GridItemSpan(maxLineSpan) }) { + Text( + text = menu.blurb, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + items(children) { child -> + SectionTile( + menu = child, + count = countIn(child, showComingSoon), + onClick = { onPickSection(child) }, + ) + } + if (games.isNotEmpty() && children.isNotEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + Text( + text = "All ${menu.label.lowercase()}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), + ) } } + items(games) { game -> + GameTile(game = game, onClick = { onPickGame(game) }) + } + } + } +} + +/** A way into a section, rather than into a game. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SectionTile(menu: GameMenu, count: Int, onClick: () -> Unit) { + Card( + onClick = onClick, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = menu.label, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = menu.blurb, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 4.dp), + ) + Text( + text = if (count == 1) "1 game" else "$count games", + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(top = 8.dp), + ) } } } @@ -297,14 +416,12 @@ private fun GameTile(game: GameDescriptor, onClick: () -> Unit) { modifier = Modifier.padding(top = 4.dp), ) Text( - text = if (game.available) { - if (game.minPlayers == game.maxPlayers) { - "${game.minPlayers} players" - } else { - "${game.minPlayers}–${game.maxPlayers} players" - } - } else { - "Coming soon" + text = when { + !game.available -> "Coming soon" + // A puzzle for one, rather than "1 players". + game.maxPlayers == 1 -> "On your own" + game.minPlayers == game.maxPlayers -> "${game.minPlayers} players" + else -> "${game.minPlayers}–${game.maxPlayers} players" }, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(top = 8.dp), diff --git a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt index 1e03faa..80632ea 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -40,6 +40,15 @@ import org.prolibertate.games.game.crazyeights.CrazyEightsOptions import org.prolibertate.games.game.cribbage.CribbageOptions import org.prolibertate.games.game.euchre.EuchreOptions import org.prolibertate.games.game.golf.GolfOptions +import org.prolibertate.games.game.hearts.HeartsOptions +import org.prolibertate.games.game.klondike.KlondikeOptions +import org.prolibertate.games.game.freecell.FreeCellOptions +import org.prolibertate.games.game.pyramid.PyramidOptions +import org.prolibertate.games.game.spider.SpiderOptions +import org.prolibertate.games.game.yahtzee.YahtzeeOptions +import org.prolibertate.games.game.pegsolitaire.PegBoard +import org.prolibertate.games.game.pegsolitaire.PegGoal +import org.prolibertate.games.game.pegsolitaire.PegSolitaireOptions import org.prolibertate.games.game.kaiser.KaiserOptions import org.prolibertate.games.game.mastermind.MastermindLevel import org.prolibertate.games.game.mastermind.MastermindOptions @@ -88,6 +97,13 @@ fun GameSetupScreen( var backgammon by remember { mutableStateOf(BackgammonOptions()) } var mastermind by remember { mutableStateOf(MastermindOptions()) } var pirates by remember { mutableStateOf(PiratesOptions()) } + var hearts by remember { mutableStateOf(HeartsOptions()) } + var pegs by remember { mutableStateOf(PegSolitaireOptions()) } + var yahtzee by remember { mutableStateOf(YahtzeeOptions()) } + var klondike by remember { mutableStateOf(KlondikeOptions()) } + var freecell by remember { mutableStateOf(FreeCellOptions()) } + var spider by remember { mutableStateOf(SpiderOptions()) } + var pyramid by remember { mutableStateOf(PyramidOptions()) } // Not an engine option: which seat the local player takes. Seat 0 is // always White, so choosing Black means sitting in seat 1. var playWhite by remember { mutableStateOf(true) } @@ -111,6 +127,13 @@ fun GameSetupScreen( GameCatalog.BACKGAMMON -> setupJson.encodeToString(backgammon) GameCatalog.MASTERMIND -> setupJson.encodeToString(mastermind) GameCatalog.PIRATES -> setupJson.encodeToString(pirates) + GameCatalog.HEARTS -> setupJson.encodeToString(hearts) + GameCatalog.PEG_SOLITAIRE -> setupJson.encodeToString(pegs) + GameCatalog.YAHTZEE -> setupJson.encodeToString(yahtzee) + GameCatalog.KLONDIKE -> setupJson.encodeToString(klondike) + GameCatalog.FREECELL -> setupJson.encodeToString(freecell) + GameCatalog.SPIDER -> setupJson.encodeToString(spider) + GameCatalog.PYRAMID -> setupJson.encodeToString(pyramid) else -> "{}" } val seatCount = seatCountFor(descriptor.id, optionsJson) @@ -162,15 +185,37 @@ fun GameSetupScreen( onSide = { playPirates = it }, ) + GameCatalog.HEARTS -> HeartsOptionsEditor(hearts) { hearts = it } + + GameCatalog.PEG_SOLITAIRE -> PegSolitaireOptionsEditor(pegs) { pegs = it } + + GameCatalog.YAHTZEE -> YahtzeeOptionsEditor(yahtzee) { yahtzee = it } + + GameCatalog.KLONDIKE -> KlondikeOptionsEditor(klondike) { klondike = it } + + GameCatalog.FREECELL -> FreeCellOptionsEditor(freecell) { freecell = it } + + GameCatalog.SPIDER -> SpiderOptionsEditor(spider) { spider = it } + + GameCatalog.PYRAMID -> PyramidOptionsEditor(pyramid) { pyramid = it } + else -> Text("No options yet for this game.") } Divider() - Text("Opponents", fontWeight = FontWeight.Bold) + // A one-seat game has nobody to play against and no seat anyone + // could join, so it is offered as itself rather than as a table + // with every other chair filled by the computer. + val solo = seatCount == 1 + Text(if (solo) "On your own" else "Opponents", fontWeight = FontWeight.Bold) Text( - text = "Play now and every other seat is taken by the computer. Host a game " + - "instead and people can claim those seats as they join — whatever is " + - "still empty when you start stays computer-controlled.", + text = if (solo) { + "A puzzle for one. There is nobody to wait for and nothing to host." + } else { + "Play now and every other seat is taken by the computer. Host a game " + + "instead and people can claim those seats as they join — whatever is " + + "still empty when you start stays computer-controlled." + }, style = MaterialTheme.typography.bodySmall, ) @@ -182,14 +227,16 @@ fun GameSetupScreen( descriptor.id == GameCatalog.PIRATES && playPirates -> 1 else -> 0 } - PrimaryAction(text = "Play against the computer") { + PrimaryAction(text = if (solo) "Play" else "Play against the computer") { onPlayOffline( offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat), localSeat, ) } - PrimaryAction(text = "Host a game for others to join") { - onHostOnline(optionsJson) + if (!solo) { + PrimaryAction(text = "Host a game for others to join") { + onHostOnline(optionsJson) + } } } } @@ -250,6 +297,9 @@ fun seatCountFor(gameId: String, optionsJson: String): Int { GameCatalog.CHESS, GameCatalog.MORRIS, GameCatalog.CHECKERS, GameCatalog.BACKGAMMON, GameCatalog.MASTERMIND, GameCatalog.PIRATES -> 2 + GameCatalog.YAHTZEE -> + setupJson.decodeFromString(optionsJson).playerCount + GameCatalog.TAYU -> setupJson.decodeFromString(optionsJson).playerCount @@ -944,3 +994,150 @@ private fun ToggleRow( Switch(checked = checked, onCheckedChange = onChange) } } + +@Composable +private fun HeartsOptionsEditor(options: HeartsOptions, onChange: (HeartsOptions) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ChipRow( + label = "Game ends at", + values = listOf(50, 100), + selected = options.targetScore, + display = { "$it points" }, + onSelect = { onChange(options.copy(targetScore = it)) }, + ) + ToggleRow( + title = "Shooting the moon", + subtitle = "Take all twenty-six and score nothing, while everyone else takes " + + "the lot. Switched off, a moon is simply the worst hand at the table.", + checked = options.allowShootTheMoon, + onChange = { onChange(options.copy(allowShootTheMoon = it)) }, + ) + } +} + +@Composable +private fun PegSolitaireOptionsEditor( + options: PegSolitaireOptions, + onChange: (PegSolitaireOptions) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ChipRow( + label = "Board", + values = PegBoard.entries.toList(), + selected = options.board, + display = { it.label }, + // The opening hole is cleared as well: a hole that exists on the old + // board may be a missing corner on the new one, and the options + // refuse to be built that way. + onSelect = { onChange(options.copy(board = it, startEmpty = null)) }, + ) + ChipRow( + label = "Finish", + values = PegGoal.entries.toList(), + selected = options.goal, + display = { it.label }, + onSelect = { onChange(options.copy(goal = it)) }, + ) + } +} + +@Composable +private fun YahtzeeOptionsEditor(options: YahtzeeOptions, onChange: (YahtzeeOptions) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ChipRow( + label = "Players", + values = (1..6).toList(), + selected = options.playerCount, + display = { if (it == 1) "On your own" else "$it" }, + onSelect = { onChange(options.copy(playerCount = it)) }, + ) + ToggleRow( + title = "Second Yahtzee scores a hundred", + subtitle = "Only once the Yahtzee box holds the fifty. A zero written there " + + "earlier forfeits it, as the printed rule has it.", + checked = options.yahtzeeBonus, + onChange = { onChange(options.copy(yahtzeeBonus = it)) }, + ) + } +} + +@Composable +private fun KlondikeOptionsEditor(options: KlondikeOptions, onChange: (KlondikeOptions) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ChipRow( + label = "Turn from the stock", + values = listOf(1, 3), + selected = options.drawCount, + display = { "$it at a time" }, + onSelect = { onChange(options.copy(drawCount = it)) }, + ) + ChipRow( + label = "Turning the waste back over", + values = listOf(null, 0, 1, 2), + selected = options.redealLimit, + display = { + when (it) { + null -> "As often as you like" + 0 -> "Never — one pass" + else -> "$it times" + } + }, + onSelect = { onChange(options.copy(redealLimit = it)) }, + ) + ToggleRow( + title = "Only a king fills a space", + subtitle = "Turning this off lets any card into an empty column, which makes " + + "far more deals winnable.", + checked = options.kingsOnlyInSpaces, + onChange = { onChange(options.copy(kingsOnlyInSpaces = it)) }, + ) + } +} + +@Composable +private fun FreeCellOptionsEditor(options: FreeCellOptions, onChange: (FreeCellOptions) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ChipRow( + label = "Free cells", + values = (1..6).toList(), + selected = options.freeCells, + display = { "$it" }, + onSelect = { onChange(options.copy(freeCells = it)) }, + ) + ToggleRow( + title = "Move a run in one go", + subtitle = "The rules move a run one card at a time through the cells. This " + + "does the shuffling for you, which is bookkeeping rather than a decision.", + checked = options.allowSupermoves, + onChange = { onChange(options.copy(allowSupermoves = it)) }, + ) + } +} + +@Composable +private fun SpiderOptionsEditor(options: SpiderOptions, onChange: (SpiderOptions) -> Unit) { + ChipRow( + label = "Suits", + values = listOf(1, 2, 4), + selected = options.suits, + display = { + when (it) { + 1 -> "One — a pastime" + 2 -> "Two" + else -> "Four — a fight" + } + }, + onSelect = { onChange(options.copy(suits = it)) }, + ) +} + +@Composable +private fun PyramidOptionsEditor(options: PyramidOptions, onChange: (PyramidOptions) -> Unit) { + ChipRow( + label = "Turns through the pack", + values = listOf(0, 1, 2, 3), + selected = options.redeals, + display = { if (it == 0) "One pass only" else "$it turns back" }, + onSelect = { onChange(options.copy(redeals = it)) }, + ) +} diff --git a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt index 56ecd49..3359b69 100644 --- a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt @@ -4,7 +4,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.runtime.rememberCoroutineScope import org.prolibertate.games.game.GameCatalog import org.prolibertate.games.game.chess.ChessAi @@ -26,6 +29,33 @@ import org.prolibertate.games.game.euchre.EuchreMove import org.prolibertate.games.game.euchre.EuchrePhase import org.prolibertate.games.game.euchre.EuchreRules import org.prolibertate.games.game.euchre.EuchreState +import org.prolibertate.games.game.freecell.FreeCellMove +import org.prolibertate.games.game.freecell.FreeCellRules +import org.prolibertate.games.game.freecell.FreeCellState +import org.prolibertate.games.game.pyramid.PyramidMove +import org.prolibertate.games.game.pyramid.PyramidRules +import org.prolibertate.games.game.pyramid.PyramidState +import org.prolibertate.games.game.solitaire.FirstLegalAi +import org.prolibertate.games.game.spider.SpiderMove +import org.prolibertate.games.game.spider.SpiderRules +import org.prolibertate.games.game.spider.SpiderState +import org.prolibertate.games.game.klondike.KlondikeAi +import org.prolibertate.games.game.klondike.KlondikeMove +import org.prolibertate.games.game.klondike.KlondikeRules +import org.prolibertate.games.game.klondike.KlondikeState +import org.prolibertate.games.game.yahtzee.YahtzeeAi +import org.prolibertate.games.game.yahtzee.YahtzeeMove +import org.prolibertate.games.game.yahtzee.YahtzeeRules +import org.prolibertate.games.game.yahtzee.YahtzeeState +import org.prolibertate.games.game.hearts.HeartsAi +import org.prolibertate.games.game.hearts.HeartsMove +import org.prolibertate.games.game.hearts.HeartsPhase +import org.prolibertate.games.game.hearts.HeartsRules +import org.prolibertate.games.game.hearts.HeartsState +import org.prolibertate.games.game.pegsolitaire.PegSolitaireAi +import org.prolibertate.games.game.pegsolitaire.PegSolitaireMove +import org.prolibertate.games.game.pegsolitaire.PegSolitaireRules +import org.prolibertate.games.game.pegsolitaire.PegSolitaireState import org.prolibertate.games.game.golf.GolfAi import org.prolibertate.games.game.golf.GolfMove import org.prolibertate.games.game.golf.GolfPhase @@ -81,11 +111,18 @@ import org.prolibertate.games.ui.game.CheckersScreen import org.prolibertate.games.ui.game.ChessScreen import org.prolibertate.games.ui.game.CrazyEightsScreen import org.prolibertate.games.ui.game.CribbageScreen +import org.prolibertate.games.ui.game.HeartsScreen +import org.prolibertate.games.ui.game.FreeCellScreen +import org.prolibertate.games.ui.game.KlondikeScreen +import org.prolibertate.games.ui.game.PyramidScreen import org.prolibertate.games.ui.game.EuchreScreen import org.prolibertate.games.ui.game.GolfScreen import org.prolibertate.games.ui.game.KaiserScreen import org.prolibertate.games.ui.game.MastermindScreen import org.prolibertate.games.ui.game.MorrisScreen +import org.prolibertate.games.ui.game.PegSolitaireScreen +import org.prolibertate.games.ui.game.SpiderScreen +import org.prolibertate.games.ui.game.YahtzeeScreen import org.prolibertate.games.ui.game.PiratesScreen import org.prolibertate.games.ui.game.PresidentScreen import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS @@ -430,6 +467,162 @@ fun PlayScreen( PiratesScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) } + GameCatalog.HEARTS -> { + val controller = remember(route) { + MatchController( + 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( + 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( + 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( + 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( + rules = FreeCellRules, + ai = FirstLegalAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + ) + } + StartMatch(env, controller, route) + FreeCellScreen(controller = controller, onRestart = { generation++ }, onExit = onExit) + } + + GameCatalog.SPIDER -> { + var generation by remember(route) { mutableIntStateOf(0) } + val controller = remember(route, generation) { + MatchController( + rules = SpiderRules, + ai = FirstLegalAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + ) + } + StartMatch(env, controller, route) + SpiderScreen(controller = controller, onRestart = { generation++ }, onExit = onExit) + } + + GameCatalog.PYRAMID -> { + var generation by remember(route) { mutableIntStateOf(0) } + val controller = remember(route, generation) { + MatchController( + rules = PyramidRules, + ai = FirstLegalAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + ) + } + StartMatch(env, controller, route) + PyramidScreen(controller = controller, onRestart = { generation++ }, onExit = onExit) + } + GameCatalog.MORRIS -> { val controller = remember(route) { MatchController( diff --git a/app/src/main/java/org/prolibertate/games/ui/game/CardTable.kt b/app/src/main/java/org/prolibertate/games/ui/game/CardTable.kt new file mode 100644 index 0000000..b296437 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/CardTable.kt @@ -0,0 +1,96 @@ +package org.prolibertate.games.ui.game + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.prolibertate.games.ui.common.CARD_ASPECT +import org.prolibertate.games.ui.theme.FeltGreenDark +import org.prolibertate.games.ui.theme.Parchment +import org.prolibertate.games.ui.theme.WallaceGold + +/** + * The furniture every patience table needs. + * + * FreeCell, Spider and Pyramid lay their cards out quite differently but they + * are all made of the same three things: a place a card sits, an empty place + * that can still be tapped into, and the two dialogs for leaving. Klondike keeps + * its own copies — it was written first and its layout constants are tuned to + * seven columns — but nothing new needs to repeat them. + */ +val TABLE_CARD_WIDTH: Dp = 44.dp +val TABLE_CARD_HEIGHT: Dp = TABLE_CARD_WIDTH / CARD_ASPECT + +/** Overlapped by a third, which hides as little as a stack can afford to. */ +val TABLE_STACK_STEP: Dp = TABLE_CARD_HEIGHT * 2f / 3f + +@Composable +fun TableSlot( + highlighted: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + width: Dp = TABLE_CARD_WIDTH, + content: @Composable () -> Unit, +) { + Box( + modifier = modifier + .width(width) + .border( + border = BorderStroke(if (highlighted) 3.dp else 0.dp, WallaceGold), + shape = RoundedCornerShape(4.dp), + ) + .clickable { onClick() }, + ) { + content() + } +} + +/** A place with no card on it, drawn so it is still somewhere to aim at. */ +@Composable +fun EmptySpace(label: String = "", width: Dp = TABLE_CARD_WIDTH) { + Box( + modifier = Modifier + .width(width) + .height(width / CARD_ASPECT) + .background(FeltGreenDark, RoundedCornerShape(4.dp)), + contentAlignment = Alignment.Center, + ) { + if (label.isNotEmpty()) { + Text(label, color = Parchment.copy(alpha = 0.5f)) + } + } +} + +@Composable +fun PatienceEndDialog(what: String, onEnd: () -> Unit, onKeepPlaying: () -> Unit) { + AlertDialog( + onDismissRequest = onKeepPlaying, + title = { Text("End this game?") }, + text = { Text(what) }, + confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } }, + dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } }, + ) +} + +@Composable +fun PatienceLeftDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) { + AlertDialog( + onDismissRequest = onStay, + title = { Text("The game has ended") }, + text = { Text(notice) }, + confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } }, + dismissButton = { TextButton(onClick = onStay) { Text("Look at the table") } }, + ) +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/FreeCellScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/FreeCellScreen.kt new file mode 100644 index 0000000..b2d58ab --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/FreeCellScreen.kt @@ -0,0 +1,188 @@ +package org.prolibertate.games.ui.game + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.prolibertate.games.game.cards.Suit +import org.prolibertate.games.game.freecell.FreeCellMove +import org.prolibertate.games.game.freecell.FreeCellState +import org.prolibertate.games.game.freecell.MoveTo +import org.prolibertate.games.game.freecell.Place +import org.prolibertate.games.net.MatchController +import org.prolibertate.games.ui.ScreenScaffold +import org.prolibertate.games.ui.common.PlayingCardView + +private const val TITLE = "FreeCell" + +/** + * The FreeCell table. + * + * Cells on the left, homes on the right, eight columns below — the standard + * arrangement, and the one that makes the cells read as somewhere to put things + * rather than somewhere cards come from. Everything is face up from the first + * move, so the whole game is on the screen and the only thing hidden is what to + * do about it. + */ +@Composable +fun FreeCellScreen( + controller: MatchController, + onRestart: () -> Unit, + onExit: () -> Unit, +) { + val state by controller.state.collectAsState() + val legal by controller.legalMoves.collectAsState() + val abandoned by controller.abandoned.collectAsState() + + var held by remember { mutableStateOf?>(null) } + var confirmingEnd by remember { mutableStateOf(false) } + + val endGameAction: @Composable () -> Unit = { + TextButton(onClick = { confirmingEnd = true }) { Text("End game") } + } + + val current = state + if (current == null) { + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") } + abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) { + PatienceEndDialog("The deal is left as it stands.", onExit) { confirmingEnd = false } + } + } + return + } + + val moves = legal.filterIsInstance() + val finished = legal.isEmpty() + val targets = held?.let { (from, count) -> + moves.filter { it.from == from && it.count == count }.map { it.to }.toSet() + }.orEmpty() + val sources = moves.map { it.from to it.count }.toSet() + + fun take(place: Place, count: Int) { + val inHand = held + held = when { + inHand != null && targets.contains(place) -> { + controller.submit(MoveTo(inHand.first, place, inHand.second)) + null + } + + inHand?.first == place -> null + sources.contains(place to count) -> place to count + else -> null + } + } + + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = when { + current.isWon -> "Out, in ${current.moves} moves." + finished -> "Blocked — nothing left to try." + held != null -> "Tap where it goes." + else -> "${current.foundations.sumOf { it.size }} of 52 home, " + + "${current.freeCellCount} cells free." + }, + fontWeight = FontWeight.Bold, + ) + + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + current.cells.forEachIndexed { index, card -> + TableSlot( + highlighted = held?.first == Place.cell(index) || + targets.contains(Place.cell(index)), + onClick = { take(Place.cell(index), 1) }, + ) { + card?.let { PlayingCardView(card = it, width = TABLE_CARD_WIDTH) } + ?: EmptySpace(label = "·") + } + } + current.foundations.forEachIndexed { index, pile -> + TableSlot( + highlighted = targets.contains(Place.foundation(index)), + onClick = { take(Place.foundation(index), 1) }, + ) { + pile.lastOrNull()?.let { PlayingCardView(card = it, width = TABLE_CARD_WIDTH) } + ?: EmptySpace(label = Suit.entries[index].symbol) + } + } + } + + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.Top, + ) { + current.tableau.forEachIndexed { index, column -> + if (column.isEmpty()) { + TableSlot( + highlighted = targets.contains(Place.column(index)), + onClick = { take(Place.column(index), 1) }, + ) { EmptySpace() } + } else { + Box( + modifier = Modifier + .width(TABLE_CARD_WIDTH) + .height(TABLE_CARD_HEIGHT + TABLE_STACK_STEP * (column.size - 1)), + ) { + column.forEachIndexed { depth, card -> + // Taking a card takes everything resting on it. + val count = column.size - depth + TableSlot( + modifier = Modifier.offset(y = TABLE_STACK_STEP * depth), + highlighted = held == (Place.column(index) to count) || + (depth == column.lastIndex && targets.contains(Place.column(index))), + onClick = { take(Place.column(index), count) }, + ) { PlayingCardView(card = card, width = TABLE_CARD_WIDTH) } + } + } + } + } + } + + if (finished) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onRestart) { Text("Deal again") } + TextButton(onClick = onExit) { Text("Leave") } + } + } + + current.log.takeLast(2).forEach { + Text(it, style = MaterialTheme.typography.bodySmall) + } + } + + abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) { + PatienceEndDialog("The deal is left as it stands.", onExit) { confirmingEnd = false } + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/HeartsScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/HeartsScreen.kt new file mode 100644 index 0000000..dd70ea6 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/HeartsScreen.kt @@ -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, + 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() } + 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().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) { + 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") } }, + ) +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/KlondikeScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/KlondikeScreen.kt new file mode 100644 index 0000000..af7920b --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/KlondikeScreen.kt @@ -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, + onRestart: () -> Unit, + onExit: () -> Unit, +) { + val state by controller.state.collectAsState() + val legal by controller.legalMoves.collectAsState() + val abandoned by controller.abandoned.collectAsState() + + var held by remember { mutableStateOf?>(null) } + var confirmingEnd by remember { mutableStateOf(false) } + + val endGameAction: @Composable () -> Unit = { + TextButton(onClick = { confirmingEnd = true }) { Text("End game") } + } + + val current = state + if (current == null) { + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") } + abandoned?.let { LeftTableDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) EndTableDialog(onExit) { confirmingEnd = false } + } + return + } + + val moves = legal.filterIsInstance() + 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?, + index: Int, + isTarget: Boolean, + onTap: (Int) -> Unit, +) { + val total = pile.faceDown.size + pile.faceUp.size + if (total == 0) { + Slot(highlighted = isTarget, onClick = { onTap(1) }) { Empty() } + return + } + Box( + modifier = Modifier + .width(CARD_WIDTH) + .height(CARD_HEIGHT + STACK_STEP * (total - 1)), + ) { + pile.faceDown.forEachIndexed { depth, _ -> + CardBackView( + width = CARD_WIDTH, + modifier = Modifier.offset(y = STACK_STEP * depth), + ) + } + pile.faceUp.forEachIndexed { depth, card -> + // Taking a card takes everything resting on it. + val count = pile.faceUp.size - depth + val row = pile.faceDown.size + depth + Slot( + modifier = Modifier.offset(y = STACK_STEP * row), + highlighted = held == (Spot.tableau(index) to count) || + (depth == pile.faceUp.lastIndex && isTarget), + onClick = { onTap(count) }, + ) { PlayingCardView(card = card, width = CARD_WIDTH) } + } + } +} + +/** + * The cards turned off the stock, fanned sideways. + * + * Drawing three and showing one would hide two cards the player is entitled to + * see — they are face up on the table. Only the last is playable, which the + * rules already enforce; the other two are there to be read. + */ +@Composable +private fun WasteFan( + waste: List, + fanned: Int, + highlighted: Boolean, + onTap: () -> Unit, +) { + val shown = waste.takeLast(fanned) + if (shown.isEmpty()) { + Slot(highlighted = false, onClick = onTap) { Empty() } + return + } + Box(modifier = Modifier.width(CARD_WIDTH + FAN_STEP * (shown.size - 1))) { + shown.forEachIndexed { position, card -> + val isTop = position == shown.lastIndex + Slot( + modifier = Modifier.offset(x = FAN_STEP * position), + highlighted = isTop && highlighted, + onClick = { if (isTop) onTap() }, + ) { PlayingCardView(card = card, width = CARD_WIDTH) } + } + } +} + +@Composable +private fun Slot( + highlighted: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Box( + modifier = modifier + .width(CARD_WIDTH) + .border( + border = BorderStroke(if (highlighted) 3.dp else 0.dp, WallaceGold), + 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") } }, + ) +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/PegSolitaireScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/PegSolitaireScreen.kt new file mode 100644 index 0000000..bb27414 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/PegSolitaireScreen.kt @@ -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, + 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(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() + 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, + destinations: Set, + 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 = + 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") } }, + ) +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/PyramidScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/PyramidScreen.kt new file mode 100644 index 0000000..4701a5f --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/PyramidScreen.kt @@ -0,0 +1,260 @@ +package org.prolibertate.games.ui.game + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.prolibertate.games.game.pyramid.DrawCard +import org.prolibertate.games.game.pyramid.PYRAMID_SIZE +import org.prolibertate.games.game.pyramid.PyramidMove +import org.prolibertate.games.game.pyramid.PyramidSpot +import org.prolibertate.games.game.pyramid.PyramidState +import org.prolibertate.games.game.pyramid.PyramidZone +import org.prolibertate.games.game.pyramid.RecycleWaste +import org.prolibertate.games.game.pyramid.ROWS +import org.prolibertate.games.game.pyramid.TakeKing +import org.prolibertate.games.game.pyramid.TakePair +import org.prolibertate.games.game.pyramid.rowStart +import org.prolibertate.games.game.pyramid.valueOf +import org.prolibertate.games.net.MatchController +import org.prolibertate.games.ui.ScreenScaffold +import org.prolibertate.games.ui.common.CardBackView +import org.prolibertate.games.ui.common.PlayingCardView + +private const val TITLE = "Pyramid" + +/** Cards are small here: the widest row is seven of them side by side. */ +private val PYRAMID_CARD: Dp = 40.dp +private val PYRAMID_CARD_HEIGHT: Dp = PYRAMID_CARD * 7f / 5f + +/** Rows overlap vertically so the whole pyramid fits above the pack. */ +private val ROW_STEP: Dp = PYRAMID_CARD_HEIGHT * 2f / 3f + +/** + * The pyramid. + * + * Drawn as it is dealt: seven rows, each one offset half a card so every card + * sits on the two below it, and rows overlapping by a third so all seven fit. + * The shape is the rules — a card is only in play once both the cards resting on + * it have gone, and being able to see that at a glance is most of playing it. + * + * Tapping a king takes it alone. Tapping any other card holds it, and tapping + * its partner takes the pair; the ones that would pair with what is held are the + * only cards that light up. + */ +@Composable +fun PyramidScreen( + controller: MatchController, + onRestart: () -> Unit, + onExit: () -> Unit, +) { + val state by controller.state.collectAsState() + val legal by controller.legalMoves.collectAsState() + val abandoned by controller.abandoned.collectAsState() + + var held by remember { mutableStateOf(null) } + var confirmingEnd by remember { mutableStateOf(false) } + + val endGameAction: @Composable () -> Unit = { + TextButton(onClick = { confirmingEnd = true }) { Text("End game") } + } + + val current = state + if (current == null) { + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") } + abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) { + PatienceEndDialog("The pyramid is left as it stands.", onExit) { confirmingEnd = false } + } + } + return + } + + val kings = legal.filterIsInstance().map { it.spot }.toSet() + val pairs = legal.filterIsInstance() + val canDraw = legal.contains(DrawCard) + val canRedeal = legal.contains(RecycleWaste) + val finished = legal.isEmpty() + + // Everything that could pair with what is in hand. + val partners = held?.let { spot -> + pairs.mapNotNull { + when (spot) { + it.first -> it.second + it.second -> it.first + else -> null + } + }.toSet() + }.orEmpty() + val pairable = pairs.flatMap { listOf(it.first, it.second) }.toSet() + + fun tap(spot: PyramidSpot) { + held = when { + kings.contains(spot) && held == null -> { + controller.submit(TakeKing(spot)) + null + } + + held != null && partners.contains(spot) -> { + controller.submit(TakePair(held!!, spot)) + null + } + + held == spot -> null + pairable.contains(spot) -> spot + else -> null + } + } + + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = when { + current.isWon -> "Pyramid cleared, in ${current.moves} moves." + finished -> "Blocked — nothing pairs to thirteen." + held != null -> "Tap the card that makes it thirteen." + else -> "${current.pyramid.count { it != null }} of $PYRAMID_SIZE left." + }, + fontWeight = FontWeight.Bold, + ) + + PyramidView( + state = current, + held = held, + kings = kings, + partners = partners, + pairable = pairable, + onTap = ::tap, + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TableSlot( + highlighted = false, + width = PYRAMID_CARD, + onClick = { + if (canDraw) { + controller.submit(DrawCard) + } else if (canRedeal) { + controller.submit(RecycleWaste) + } + }, + ) { + if (current.stock.isNotEmpty()) { + CardBackView(width = PYRAMID_CARD) + } else { + EmptySpace(label = if (canRedeal) "↻" else "", width = PYRAMID_CARD) + } + } + val wasteSpot = PyramidSpot(PyramidZone.WASTE) + TableSlot( + highlighted = held == wasteSpot || + partners.contains(wasteSpot) || + kings.contains(wasteSpot), + width = PYRAMID_CARD, + onClick = { tap(wasteSpot) }, + ) { + current.waste.lastOrNull() + ?.let { PlayingCardView(card = it, width = PYRAMID_CARD) } + ?: EmptySpace(width = PYRAMID_CARD) + } + Text( + text = "${current.stock.size} in hand, " + + "${current.options.redeals - current.redealsUsed} turns left", + style = MaterialTheme.typography.bodySmall, + ) + } + + if (finished) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onRestart) { Text("Deal again") } + TextButton(onClick = onExit) { Text("Leave") } + } + } + + current.log.takeLast(2).forEach { + Text(it, style = MaterialTheme.typography.bodySmall) + } + } + + abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) { + PatienceEndDialog("The pyramid is left as it stands.", onExit) { confirmingEnd = false } + } + } +} + +@Composable +private fun PyramidView( + state: PyramidState, + held: PyramidSpot?, + kings: Set, + partners: Set, + pairable: Set, + onTap: (PyramidSpot) -> Unit, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(PYRAMID_CARD_HEIGHT + ROW_STEP * (ROWS - 1)), + ) { + // Each row is half a card narrower on each side than the one below, so + // every card sits in the notch between the two under it. + for (row in 0 until ROWS) { + val cardsInRow = row + 1 + for (column in 0 until cardsInRow) { + val index = rowStart(row) + column + val card = state.pyramid[index] ?: continue + val spot = PyramidSpot(PyramidZone.PYRAMID, index) + val playable = pairable.contains(spot) || kings.contains(spot) + Box( + modifier = Modifier.offset( + // Centred on the widest row, which is seven wide. + x = PYRAMID_CARD * (ROWS - cardsInRow) / 2f + PYRAMID_CARD * column, + y = ROW_STEP * row, + ), + ) { + TableSlot( + highlighted = held == spot || partners.contains(spot), + width = PYRAMID_CARD, + onClick = { if (playable) onTap(spot) }, + ) { + PlayingCardView( + card = card, + width = PYRAMID_CARD, + enabled = playable, + caption = valueOf(card).toString(), + ) + } + } + } + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/SpiderScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/SpiderScreen.kt new file mode 100644 index 0000000..2abab04 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/SpiderScreen.kt @@ -0,0 +1,191 @@ +package org.prolibertate.games.ui.game + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.prolibertate.games.game.spider.DealRow +import org.prolibertate.games.game.spider.MoveRun +import org.prolibertate.games.game.spider.RUNS_TO_WIN +import org.prolibertate.games.game.spider.SpiderMove +import org.prolibertate.games.game.spider.SpiderState +import org.prolibertate.games.net.MatchController +import org.prolibertate.games.ui.ScreenScaffold +import org.prolibertate.games.ui.common.CardBackView +import org.prolibertate.games.ui.common.PlayingCardView + +private const val TITLE = "Spider" + +/** + * The Spider table. + * + * Ten columns of a hundred and four cards, so it scrolls sideways and the cards + * are the same third-overlapped stacks as everywhere else. What is worth showing + * above them is the two numbers that decide the game: how many runs have gone + * away, and how many rows are still in the stock waiting to bury everything. + */ +@Composable +fun SpiderScreen( + controller: MatchController, + onRestart: () -> Unit, + onExit: () -> Unit, +) { + val state by controller.state.collectAsState() + val legal by controller.legalMoves.collectAsState() + val abandoned by controller.abandoned.collectAsState() + + var held by remember { mutableStateOf?>(null) } + var confirmingEnd by remember { mutableStateOf(false) } + + val endGameAction: @Composable () -> Unit = { + TextButton(onClick = { confirmingEnd = true }) { Text("End game") } + } + + val current = state + if (current == null) { + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") } + abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) { + PatienceEndDialog("The deal is left as it stands.", onExit) { confirmingEnd = false } + } + } + return + } + + val runs = legal.filterIsInstance() + val canDeal = legal.contains(DealRow) + val finished = legal.isEmpty() + val targets = held?.let { (from, count) -> + runs.filter { it.from == from && it.count == count }.map { it.to }.toSet() + }.orEmpty() + val sources = runs.map { it.from to it.count }.toSet() + + fun take(column: Int, count: Int) { + val inHand = held + held = when { + inHand != null && targets.contains(column) -> { + controller.submit(MoveRun(inHand.first, column, inHand.second)) + null + } + + inHand?.first == column && inHand.second == count -> null + sources.contains(column to count) -> column to count + else -> null + } + } + + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = when { + current.isWon -> "All eight runs, in ${current.moves} moves." + finished -> "Blocked — nothing left to try." + held != null -> "Tap the column it goes to." + else -> "${current.completed} of $RUNS_TO_WIN runs away." + }, + fontWeight = FontWeight.Bold, + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TableSlot( + highlighted = false, + onClick = { if (canDeal) controller.submit(DealRow) }, + ) { + if (current.stock.isNotEmpty()) { + CardBackView(width = TABLE_CARD_WIDTH) + } else { + EmptySpace() + } + } + Text( + text = "${current.stock.size / 10} rows left" + + if (!canDeal && current.stock.isNotEmpty()) " — fill every column first" else "", + style = MaterialTheme.typography.bodySmall, + ) + } + + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.Top, + ) { + current.tableau.forEachIndexed { index, pile -> + val total = pile.faceDown.size + pile.faceUp.size + if (total == 0) { + TableSlot( + highlighted = targets.contains(index), + onClick = { take(index, 1) }, + ) { EmptySpace() } + } else { + Box( + modifier = Modifier + .width(TABLE_CARD_WIDTH) + .height(TABLE_CARD_HEIGHT + TABLE_STACK_STEP * (total - 1)), + ) { + pile.faceDown.forEachIndexed { depth, _ -> + CardBackView( + width = TABLE_CARD_WIDTH, + modifier = Modifier.offset(y = TABLE_STACK_STEP * depth), + ) + } + pile.faceUp.forEachIndexed { depth, card -> + val count = pile.faceUp.size - depth + val row = pile.faceDown.size + depth + TableSlot( + modifier = Modifier.offset(y = TABLE_STACK_STEP * row), + highlighted = held == (index to count) || + (depth == pile.faceUp.lastIndex && targets.contains(index)), + onClick = { take(index, count) }, + ) { PlayingCardView(card = card, width = TABLE_CARD_WIDTH) } + } + } + } + } + } + + if (finished) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onRestart) { Text("Deal again") } + TextButton(onClick = onExit) { Text("Leave") } + } + } + + current.log.takeLast(2).forEach { + Text(it, style = MaterialTheme.typography.bodySmall) + } + } + + abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) { + PatienceEndDialog("The deal is left as it stands.", onExit) { confirmingEnd = false } + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/YahtzeeScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/YahtzeeScreen.kt new file mode 100644 index 0000000..abf9903 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/YahtzeeScreen.kt @@ -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, + 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() } + 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().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, + kept: List, + 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, + 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, + 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, 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") } }, + ) +} diff --git a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt index 80766a4..674a2a4 100644 --- a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt @@ -16,21 +16,54 @@ class CatalogTest { @Test fun `a production build lists only games that can be played`() { - GameCategory.entries.forEach { category -> - val shown = GameCatalog.byCategory(category, includeComingSoon = false) + GameMenu.entries.forEach { menu -> + val shown = GameCatalog.inMenu(menu, includeComingSoon = false) assertTrue( - "$category offers an unfinished game in a release build", + "$menu offers an unfinished game in a release build", shown.all { it.available }, ) } } @Test - fun `a dev build lists the whole catalogue`() { - val shown = GameCategory.entries.flatMap { - GameCatalog.byCategory(it, includeComingSoon = true) + fun `every game is reachable from at least one menu`() { + // Counting is no good now that a game can be in several menus: the + // union is what matters, and a game in none of them would be in the + // catalogue but on no screen. + val reachable = GameMenu.entries.flatMap { + GameCatalog.inMenu(it, includeComingSoon = true) + }.toSet() + assertEquals(GameCatalog.all.toSet(), reachable) + GameCatalog.all.forEach { + assertTrue("${it.id} is in no menu", it.menus.isNotEmpty()) } - assertEquals(GameCatalog.all.size, shown.size) + } + + @Test + fun `a game can be in more than one menu`() { + // The whole point of the set. A patience is solitaire and a card game; + // Euchre is a card game and a trick-taking one. + val klondike = GameCatalog.byId(GameCatalog.KLONDIKE)!! + assertTrue(klondike.menus.containsAll(setOf(GameMenu.SOLITAIRE, GameMenu.CARDS))) + assertTrue(GameCatalog.inMenu(GameMenu.SOLITAIRE).contains(klondike)) + assertTrue(GameCatalog.inMenu(GameMenu.CARDS).contains(klondike)) + } + + @Test + fun `trick-taking sits under cards, and its games are card games too`() { + assertEquals(GameMenu.CARDS, GameMenu.TRICK_TAKING.parent) + assertEquals(listOf(GameMenu.TRICK_TAKING), GameMenu.CARDS.children) + assertTrue(GameMenu.TRICK_TAKING.isTopLevel.not()) + assertTrue(GameMenu.SOLITAIRE.isTopLevel) + + val tricks = GameCatalog.inMenu(GameMenu.TRICK_TAKING) + assertTrue("there should be some", tricks.isNotEmpty()) + // A sub-menu that listed something its parent did not would be a game + // you could reach one way and not the other. + assertTrue( + "every trick-taking game is also a card game", + tricks.all { it.menus.contains(GameMenu.CARDS) }, + ) } @Test @@ -42,19 +75,19 @@ class CatalogTest { // engine is written. assertTrue(GameCatalog.all.all { it.available }) assertEquals( - GameCatalog.byCategory(GameCategory.BOARD, includeComingSoon = false), - GameCatalog.byCategory(GameCategory.BOARD, includeComingSoon = true), + GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = false), + GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = true), ) } @Test - fun `hiding unfinished games still leaves something to play in each category`() { - // A category filtered down to nothing would leave a stranded heading, - // and a menu with no playable game at all would be a broken release. - GameCategory.entries.forEach { category -> - assertFalse( - "$category has nothing playable in a release build", - GameCatalog.byCategory(category, includeComingSoon = false).isEmpty(), + fun `hiding unfinished games still leaves something to play in every menu`() { + // A menu filtered down to nothing would leave a stranded heading, and a + // release where a whole section was empty would be a broken one. + GameMenu.entries.forEach { menu -> + assertTrue( + "$menu has nothing playable in a release build", + GameCatalog.hasAnything(menu, includeComingSoon = false), ) } assertTrue(GameCatalog.playable.isNotEmpty()) @@ -71,6 +104,13 @@ class CatalogTest { GameCatalog.KAISER, GameCatalog.CRAZY_EIGHTS, GameCatalog.CRIBBAGE, + GameCatalog.HEARTS, + GameCatalog.PEG_SOLITAIRE, + GameCatalog.YAHTZEE, + GameCatalog.KLONDIKE, + GameCatalog.FREECELL, + GameCatalog.SPIDER, + GameCatalog.PYRAMID, GameCatalog.WIZARD, GameCatalog.CHESS, GameCatalog.TAYU, @@ -87,7 +127,11 @@ class CatalogTest { @Test fun `player counts are sane`() { GameCatalog.all.forEach { game -> - assertTrue("${game.id} min players", game.minPlayers >= 2) + // One, not two. Peg solitaire is played alone, and the engine took + // that without changes — one seat, always on the clock, nothing to + // redact. Everything downstream reads the seat count from here, so + // this is the only place that had to allow it. + assertTrue("${game.id} min players", game.minPlayers >= 1) assertTrue("${game.id} max >= min", game.maxPlayers >= game.minPlayers) } } diff --git a/app/src/test/java/org/prolibertate/games/game/hearts/HeartsRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/hearts/HeartsRulesTest.kt new file mode 100644 index 0000000..db8b5d7 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/hearts/HeartsRulesTest.kt @@ -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>, + turn: Int = 0, + leader: Int = 0, + trick: List = emptyList(), + trickNumber: Int = 1, + heartsBroken: Boolean = false, + taken: List> = 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(), 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. + } + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/klondike/KlondikeRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/klondike/KlondikeRulesTest.kt new file mode 100644 index 0000000..e86778f --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/klondike/KlondikeRulesTest.kt @@ -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() + 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( + 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. + } + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireRulesTest.kt new file mode 100644 index 0000000..387bde0 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireRulesTest.kt @@ -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. + } + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/solitaire/SolitaireRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/solitaire/SolitaireRulesTest.kt new file mode 100644 index 0000000..c4d2d48 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/solitaire/SolitaireRulesTest.kt @@ -0,0 +1,368 @@ +package org.prolibertate.games.game.solitaire + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.cards.Rank +import org.prolibertate.games.game.cards.Suit +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig +import org.prolibertate.games.game.freecell.COLUMNS as FREECELL_COLUMNS +import org.prolibertate.games.game.freecell.FreeCellOptions +import org.prolibertate.games.game.freecell.FreeCellRules +import org.prolibertate.games.game.freecell.MoveTo +import org.prolibertate.games.game.freecell.Place +import org.prolibertate.games.game.freecell.maxRunLength +import org.prolibertate.games.game.pyramid.PYRAMID_SIZE +import org.prolibertate.games.game.pyramid.PyramidOptions +import org.prolibertate.games.game.pyramid.PyramidRules +import org.prolibertate.games.game.pyramid.PyramidZone +import org.prolibertate.games.game.pyramid.TARGET +import org.prolibertate.games.game.pyramid.coveredBy +import org.prolibertate.games.game.pyramid.isExposed +import org.prolibertate.games.game.pyramid.rowOf +import org.prolibertate.games.game.pyramid.valueOf +import org.prolibertate.games.game.spider.COLUMNS as SPIDER_COLUMNS +import org.prolibertate.games.game.spider.DealRow +import org.prolibertate.games.game.spider.RUN_LENGTH +import org.prolibertate.games.game.spider.SpiderOptions +import org.prolibertate.games.game.spider.SpiderRules +import org.prolibertate.games.game.spider.isCompleteRun +import org.prolibertate.games.game.spider.landsOn +import org.prolibertate.games.game.spider.sameSuitRun +import org.prolibertate.games.game.spider.spiderDeck + +/** + * The three patiences added together, tested together. + * + * They share the ace-low ordering and little else, so what is worth pinning is + * each one's own shape: how many cards it deals, what it will let travel, and + * what counts as finished. + */ +class SolitaireRulesTest { + + private val json = Json { encodeDefaults = true } + + private fun soloConfig(gameId: String, optionsJson: String, seed: Long = 3L) = TableConfig( + gameId = gameId, + seats = listOf(PlayerSlot(seat = 0, name = "P0", kind = PlayerKind.HUMAN_LOCAL, team = 0)), + optionsJson = optionsJson, + seed = seed, + ) + + private fun c(rank: Rank, suit: Suit) = Card(rank, suit) + + // ---- shared ------------------------------------------------------------ + + @Test + fun `the ace is low for all of them`() { + assertEquals(1, Rank.ACE.patienceOrder) + assertEquals(11, Rank.JACK.patienceOrder) + assertEquals(13, Rank.KING.patienceOrder) + assertTrue(buildsDownAlternating(c(Rank.ACE, Suit.HEARTS), c(Rank.TWO, Suit.SPADES))) + assertTrue(buildsDownInSuit(c(Rank.ACE, Suit.SPADES), c(Rank.TWO, Suit.SPADES))) + assertFalse(buildsDownInSuit(c(Rank.ACE, Suit.HEARTS), c(Rank.TWO, Suit.SPADES))) + } + + // ---- FreeCell ---------------------------------------------------------- + + @Test + fun `FreeCell deals the whole pack face up`() { + val state = FreeCellRules.initialState( + soloConfig("freecell", json.encodeToString(FreeCellOptions())), + ) + assertEquals(FREECELL_COLUMNS, state.tableau.size) + // Fifty-two into eight is four sevens and four sixes. + assertEquals(listOf(7, 7, 7, 7, 6, 6, 6, 6), state.tableau.map { it.size }) + val all = state.tableau.flatten() + assertEquals(52, all.size) + assertEquals(52, all.distinct().size) + assertEquals(4, state.freeCellCount) + assertEquals(0, state.emptyColumns) + } + + @Test + fun `how much can travel depends on cells and spaces`() { + // One card per free cell plus the card itself, doubled for each empty + // column that can be borrowed as a staging pile. + assertEquals(5, maxRunLength(freeCells = 4, emptyColumns = 0, intoEmptyColumn = false)) + assertEquals(10, maxRunLength(freeCells = 4, emptyColumns = 1, intoEmptyColumn = false)) + assertEquals(20, maxRunLength(freeCells = 4, emptyColumns = 2, intoEmptyColumn = false)) + // Moving into an empty column cannot also stage through it. + assertEquals(5, maxRunLength(freeCells = 4, emptyColumns = 1, intoEmptyColumn = true)) + assertEquals(1, maxRunLength(freeCells = 0, emptyColumns = 0, intoEmptyColumn = false)) + } + + @Test + fun `FreeCell refuses a table for more than one`() { + val two = TableConfig( + gameId = "freecell", + seats = (0..1).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) }, + optionsJson = json.encodeToString(FreeCellOptions()), + seed = 1L, + ) + assertThrows { FreeCellRules.initialState(two) } + } + + @Test + fun `FreeCell keeps every card wherever it is put`() { + var state = FreeCellRules.initialState( + soloConfig("freecell", json.encodeToString(FreeCellOptions())), + ) + val ai = FirstLegalAi() + var guard = 120 + while (!FreeCellRules.isFinished(state) && guard-- > 0) { + val legal = FreeCellRules.legalMoves(state, 0) + state = FreeCellRules.applyMove(state, 0, ai.chooseMove(state, 0, legal)) + val all = state.tableau.flatten() + state.foundations.flatten() + state.cells.filterNotNull() + assertEquals("no card lost or copied", 52, all.size) + assertEquals(52, all.distinct().size) + } + } + + @Test + fun `parking a card takes it off the column and into the cell`() { + val state = FreeCellRules.initialState( + soloConfig("freecell", json.encodeToString(FreeCellOptions())), + ) + val toCell = FreeCellRules.legalMoves(state, 0) + .filterIsInstance() + .first { it.to.kind == org.prolibertate.games.game.freecell.CellKind.CELL } + val column = toCell.from.index + val card = state.tableau[column].last() + + val parked = FreeCellRules.applyMove(state, 0, toCell) + assertEquals(3, parked.freeCellCount) + assertEquals(card, parked.cells[toCell.to.index]) + assertEquals(state.tableau[column].size - 1, parked.tableau[column].size) + // Deliberately not asserting the card can come back out. A parked card + // that nothing will take is stranded, and that is the whole danger of + // the cells rather than a fault in them. + } + + @Test + fun `an ace in a cell can always go home`() { + val base = FreeCellRules.initialState( + soloConfig("freecell", json.encodeToString(FreeCellOptions())), + ) + val ace = c(Rank.ACE, Suit.SPADES) + val held = base.copy(cells = listOf(ace, null, null, null)) + assertTrue( + "an ace is the one card a cell can never strand", + FreeCellRules.legalMoves(held, 0).contains( + MoveTo(Place.cell(0), Place.foundation(Suit.SPADES.ordinal), 1), + ), + ) + } + + // ---- Spider ------------------------------------------------------------ + + @Test + fun `a Spider pack is always a hundred and four`() { + listOf(1, 2, 4).forEach { suits -> + val deck = spiderDeck(suits) + assertEquals("$suits suits", 104, deck.size) + assertEquals("$suits suits", suits, deck.map { it.suit }.distinct().size) + // Eight of every rank, however the suits were shared out. + assertEquals(8, deck.count { it.rank == Rank.KING }) + } + } + + @Test + fun `Spider deals fifty-four and keeps fifty back`() { + val state = SpiderRules.initialState( + soloConfig("spider", json.encodeToString(SpiderOptions())), + ) + assertEquals(SPIDER_COLUMNS, state.tableau.size) + assertEquals(listOf(6, 6, 6, 6, 5, 5, 5, 5, 5, 5), state.tableau.map { it.faceDown.size + it.faceUp.size }) + state.tableau.forEach { assertEquals("only the last is turned up", 1, it.faceUp.size) } + assertEquals(50, state.stock.size) + assertEquals(0, state.completed) + } + + @Test + fun `only a same-suit run travels, but anything lands on a higher card`() { + val spades = listOf(c(Rank.TEN, Suit.SPADES), c(Rank.NINE, Suit.SPADES)) + val mixed = listOf(c(Rank.TEN, Suit.SPADES), c(Rank.NINE, Suit.HEARTS)) + assertTrue(sameSuitRun(spades)) + assertFalse("a mixed run cannot travel as one", sameSuitRun(mixed)) + // Landing is looser than travelling: any suit, one rank higher. + assertTrue(landsOn(c(Rank.NINE, Suit.HEARTS), c(Rank.TEN, Suit.SPADES))) + assertFalse(landsOn(c(Rank.NINE, Suit.HEARTS), c(Rank.JACK, Suit.SPADES))) + } + + @Test + fun `a complete run is king down to ace in one suit`() { + val run = Rank.standard.sortedByDescending { it.patienceOrder }.map { c(it, Suit.SPADES) } + assertEquals(RUN_LENGTH, run.size) + assertTrue(isCompleteRun(run)) + // One card of the wrong suit and it is not going anywhere. + val broken = run.toMutableList().also { it[5] = c(it[5].rank, Suit.HEARTS) } + assertFalse(isCompleteRun(broken)) + assertFalse(isCompleteRun(run.drop(1))) + } + + @Test + fun `a row cannot be dealt onto an empty column`() { + val state = SpiderRules.initialState( + soloConfig("spider", json.encodeToString(SpiderOptions())), + ) + assertTrue(state.canDeal) + assertTrue(SpiderRules.legalMoves(state, 0).contains(DealRow)) + + val emptied = state.copy( + tableau = state.tableau.toMutableList().also { + it[0] = org.prolibertate.games.game.spider.SpiderPile() + }, + ) + assertFalse(emptied.canDeal) + assertFalse(SpiderRules.legalMoves(emptied, 0).contains(DealRow)) + } + + @Test + fun `dealing a row puts one card on every column`() { + val state = SpiderRules.initialState( + soloConfig("spider", json.encodeToString(SpiderOptions())), + ) + val before = state.tableau.map { it.faceUp.size } + val dealt = SpiderRules.applyMove(state, 0, DealRow) + assertEquals(40, dealt.stock.size) + dealt.tableau.forEachIndexed { index, pile -> + assertEquals(before[index] + 1, pile.faceUp.size) + } + } + + // ---- Pyramid ----------------------------------------------------------- + + @Test + fun `the pyramid is twenty-eight cards in seven rows`() { + assertEquals(28, PYRAMID_SIZE) + assertEquals(0, rowOf(0)) + assertEquals(1, rowOf(1)) + assertEquals(1, rowOf(2)) + assertEquals(2, rowOf(3)) + assertEquals(6, rowOf(27)) + + val state = PyramidRules.initialState( + soloConfig("pyramid", json.encodeToString(PyramidOptions())), + ) + assertEquals(PYRAMID_SIZE, state.pyramid.size) + assertEquals(24, state.stock.size) + assertTrue(state.pyramid.all { it != null }) + } + + @Test + fun `a card is buried until both the cards on it are gone`() { + // The apex rests on the two cards of the second row. + assertEquals(listOf(1, 2), coveredBy(0)) + // The bottom row rests on nothing. + assertTrue(coveredBy(27).isEmpty()) + + val pyramid = MutableList(PYRAMID_SIZE) { c(Rank.FIVE, Suit.SPADES) } + assertFalse("the apex starts buried", isExposed(pyramid, 0)) + pyramid[1] = null + assertFalse("one of the two is not enough", isExposed(pyramid, 0)) + pyramid[2] = null + assertTrue(isExposed(pyramid, 0)) + // A place already taken is not exposed, it is gone. + pyramid[0] = null + assertFalse(isExposed(pyramid, 0)) + } + + @Test + fun `pairs make thirteen, and a king makes it alone`() { + assertEquals(1, valueOf(c(Rank.ACE, Suit.SPADES))) + assertEquals(11, valueOf(c(Rank.JACK, Suit.SPADES))) + assertEquals(TARGET, valueOf(c(Rank.KING, Suit.SPADES))) + assertEquals(TARGET, valueOf(c(Rank.ACE, Suit.SPADES)) + valueOf(c(Rank.QUEEN, Suit.HEARTS))) + assertEquals(TARGET, valueOf(c(Rank.SIX, Suit.CLUBS)) + valueOf(c(Rank.SEVEN, Suit.DIAMONDS))) + } + + @Test + fun `only the bottom row is in play at the start`() { + val state = PyramidRules.initialState( + soloConfig("pyramid", json.encodeToString(PyramidOptions())), + ) + val reachable = PyramidRules.available(state) + val fromPyramid = reachable.filter { it.first.zone == PyramidZone.PYRAMID } + assertEquals("the seven of the bottom row", 7, fromPyramid.size) + assertTrue(fromPyramid.all { rowOf(it.first.index) == 6 }) + // Nothing has been turned yet, so the waste offers nothing. + assertTrue(reachable.none { it.first.zone == PyramidZone.WASTE }) + } + + @Test + fun `drawing turns a card and the waste can be turned back`() { + var state = PyramidRules.initialState( + soloConfig("pyramid", json.encodeToString(PyramidOptions(redeals = 1))), + ) + val stock = state.stock.size + state = PyramidRules.applyMove(state, 0, org.prolibertate.games.game.pyramid.DrawCard) + assertEquals(stock - 1, state.stock.size) + assertEquals(1, state.waste.size) + + var guard = 40 + while (state.stock.isNotEmpty() && guard-- > 0) { + state = PyramidRules.applyMove(state, 0, org.prolibertate.games.game.pyramid.DrawCard) + } + assertTrue(state.canRedeal) + val turned = PyramidRules.applyMove(state, 0, org.prolibertate.games.game.pyramid.RecycleWaste) + assertTrue(turned.waste.isEmpty()) + assertEquals(1, turned.redealsUsed) + assertFalse("only the one redeal was allowed", turned.canRedeal) + } + + @Test + fun `a cleared pyramid is won`() { + val state = PyramidRules.initialState( + soloConfig("pyramid", json.encodeToString(PyramidOptions())), + ) + val cleared = state.copy(pyramid = List(PYRAMID_SIZE) { null }) + assertTrue(cleared.isWon) + assertTrue(PyramidRules.isFinished(cleared)) + assertNull(PyramidRules.currentSeat(cleared)) + assertTrue(PyramidRules.summary(cleared).contains("Cleared")) + } + + // ---- codecs ------------------------------------------------------------ + + @Test + fun `each of them survives a round trip`() { + val freecell = FreeCellRules.initialState( + soloConfig("freecell", json.encodeToString(FreeCellOptions())), + ) + assertEquals(freecell, FreeCellRules.decodeState(FreeCellRules.encodeState(freecell))) + + val spider = SpiderRules.initialState( + soloConfig("spider", json.encodeToString(SpiderOptions(suits = 4))), + ) + assertEquals(spider, SpiderRules.decodeState(SpiderRules.encodeState(spider))) + assertEquals(DealRow, SpiderRules.decodeMove(SpiderRules.encodeMove(DealRow))) + + val pyramid = PyramidRules.initialState( + soloConfig("pyramid", json.encodeToString(PyramidOptions())), + ) + assertEquals(pyramid, PyramidRules.decodeState(PyramidRules.encodeState(pyramid))) + val king = org.prolibertate.games.game.pyramid.TakeKing(PyramidSpotAt(3)) + assertEquals(king, PyramidRules.decodeMove(PyramidRules.encodeMove(king))) + } + + private fun PyramidSpotAt(index: Int) = + org.prolibertate.games.game.pyramid.PyramidSpot(PyramidZone.PYRAMID, index) + + private fun assertThrows(block: () -> Unit) { + try { + block() + throw AssertionError("expected the rules to reject that") + } catch (expected: IllegalArgumentException) { + // Rejected, which is the point. + } + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/yahtzee/YahtzeeRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/yahtzee/YahtzeeRulesTest.kt new file mode 100644 index 0000000..7ab59b6 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/yahtzee/YahtzeeRulesTest.kt @@ -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(), 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. + } + } +}