From 532c3ea48af011df49186fe960ad41ad03c55734 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 03:25:09 +0000 Subject: [PATCH] Add President and Golf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more games on the existing framework — no changes were needed to the lobby, networking, settings or update layers, which is what the shared GameRules/GameAi contract was for. President - Threes low, twos high. Lead any number of one rank; follow with the same count of a higher rank, or pass. When everyone has passed the pile comes down and the last player to play leads again. - Toggles: player count, rounds, twos clear the pile, four of a kind bombs, and the between-round card exchange. - Plays are offered as whole sets rather than by selecting cards. A set is the unit of the game, so choosing from the legal sets is both easier to operate and impossible to get wrong. Golf - 4, 6 or 9 card grids. Draw from stock or discard, then either swap the card in or — only if it came off the stock — throw it and turn one of your own over. A card taken from the discard pile has to be used. - Matching columns cancel to nothing; on the 3x3 board rows do too, but not on the shallower grids where three cards are not a line. - Face-down cards are hidden from everyone including their owner, which is the point of the game: viewFor masks them with a fixed placeholder so the real card never reaches the device that has not turned it over. Both engines are pure Kotlin and unit tested — 71 tests now, covering ranking, legal moves, pile and hole resolution, scoring, redaction, wire round-trips, and full random and AI playthroughs. The Golf test also checks the AI actually beats random play rather than merely being legal. Also widened the setup screen's option chips to wrap: the reveal-count row carries up to ten, and a chip off the edge of the screen is a setting that cannot be changed — the same failure as the Euchre Pass button. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN --- README.md | 8 +- .../org/prolibertate/games/game/Catalog.kt | 4 +- .../prolibertate/games/game/golf/GolfAi.kt | 93 +++++ .../prolibertate/games/game/golf/GolfModel.kt | 132 +++++++ .../prolibertate/games/game/golf/GolfRules.kt | 283 +++++++++++++++ .../games/game/president/PresidentAi.kt | 85 +++++ .../games/game/president/PresidentModel.kt | 94 +++++ .../games/game/president/PresidentRules.kt | 337 ++++++++++++++++++ .../prolibertate/games/ui/GameSetupScreen.kt | 113 +++++- .../org/prolibertate/games/ui/PlayScreen.kt | 56 +++ .../prolibertate/games/ui/game/GolfScreen.kt | 335 +++++++++++++++++ .../games/ui/game/PresidentScreen.kt | 265 ++++++++++++++ .../prolibertate/games/game/CatalogTest.kt | 7 +- .../games/game/golf/GolfRulesTest.kt | 315 ++++++++++++++++ .../game/president/PresidentRulesTest.kt | 334 +++++++++++++++++ docs/RULES-golf.md | 68 ++++ docs/RULES-president.md | 69 ++++ 17 files changed, 2590 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/org/prolibertate/games/game/golf/GolfAi.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/golf/GolfModel.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/golf/GolfRules.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/president/PresidentAi.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/president/PresidentModel.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/president/PresidentRules.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/GolfScreen.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/PresidentScreen.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/golf/GolfRulesTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/president/PresidentRulesTest.kt create mode 100644 docs/RULES-golf.md create mode 100644 docs/RULES-president.md diff --git a/README.md b/README.md index 630df05..85c053d 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ The app shell is complete and two games are playable end to end: | | | | --- | --- | -| **Playable** | Euchre, Sequence | -| **Listed, not yet implemented** | Kaiser, President, Golf, Wizard, Crazy 8s, Mastermind, Backgammon, Chess, Checkers | +| **Playable** | Euchre, Sequence, President, Golf | +| **Listed, not yet implemented** | Kaiser, Wizard, Crazy 8s, Mastermind, Backgammon, Chess, Checkers | Unimplemented games appear in the menu marked *Coming soon* **on dev builds only** — a production release lists just the games that can actually be played, @@ -145,6 +145,8 @@ game: - [Euchre](docs/RULES-euchre.md) - [Sequence](docs/RULES-sequence.md) — **note the caveat about the board layout** +- [President](docs/RULES-president.md) +- [Golf](docs/RULES-golf.md) ## Layout @@ -154,6 +156,8 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested engine/ GameRules / GameAi contracts, seats, table config euchre/ Euchre model, rules, AI sequence/ Sequence model, board, rules, AI + president/ President model, rules, AI + golf/ Golf model, scoring, rules, AI net/ wire protocol, LAN and Bluetooth transports, lobby, match driver settings/ DataStore-backed preferences update/ GitHub Releases OTA updater 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 41f66c8..c65f956 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -68,7 +68,7 @@ object GameCatalog { maxPlayers = 7, teamBased = false, blurb = "Shed your hand first and rule the table. Last one out serves the drinks.", - available = false, + available = true, ), GameDescriptor( id = GOLF, @@ -78,7 +78,7 @@ object GameCatalog { maxPlayers = 6, teamBased = false, blurb = "Lowest score wins over nine holes. Match pairs to cancel them out.", - available = false, + available = true, ), GameDescriptor( id = WIZARD, diff --git a/app/src/main/java/org/prolibertate/games/game/golf/GolfAi.kt b/app/src/main/java/org/prolibertate/games/game/golf/GolfAi.kt new file mode 100644 index 0000000..c975297 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/golf/GolfAi.kt @@ -0,0 +1,93 @@ +package org.prolibertate.games.game.golf + +import org.prolibertate.games.game.engine.GameAi + +/** + * A heuristic Golf opponent. + * + * It takes a card off the discard pile when it is cheap or completes a column, + * replaces its worst known card, and turns cards over when it has nothing + * better to do. It never peeks at a face-down card — including its own, which + * it genuinely does not know. + */ +class GolfAi(private val difficulty: Difficulty = Difficulty.NORMAL) : + GameAi { + + enum class Difficulty( + /** Take a discard worth this or less rather than gambling on the stock. */ + val discardAppetite: Int, + ) { + EASY(discardAppetite = 2), + NORMAL(discardAppetite = 5), + CAUTIOUS(discardAppetite = 7), + } + + override fun chooseMove(state: GolfState, seat: Int, legal: List): GolfMove { + require(legal.isNotEmpty()) { "No legal move for seat $seat" } + return when (state.phase) { + GolfPhase.DRAW -> chooseDraw(state, seat, legal) + GolfPhase.PLACE -> choosePlace(state, seat, legal) + else -> legal.first() + } + } + + private fun chooseDraw(state: GolfState, seat: Int, legal: List): GolfMove { + val top = state.discard.lastOrNull() + if (top != null && legal.contains(DrawFromDiscard)) { + val worthTaking = golfValue(top) <= difficulty.discardAppetite || + completesColumn(state, seat, top) != null + if (worthTaking) return DrawFromDiscard + } + return if (legal.contains(DrawFromStock)) DrawFromStock else legal.first() + } + + private fun choosePlace(state: GolfState, seat: Int, legal: List): GolfMove { + val drawn = state.drawn ?: return legal.first() + val replacements = legal.filterIsInstance() + val flips = legal.filterIsInstance() + + // Completing a column wipes out both cards, which beats any swap on value. + completesColumn(state, seat, drawn)?.let { index -> + replacements.firstOrNull { it.index == index }?.let { return it } + } + + // Otherwise trade out the worst card we can actually see. + val knownWorst = replacements + .filter { state.revealed[seat][it.index] } + .maxByOrNull { golfValue(state.grids[seat][it.index]) } + + if (knownWorst != null) { + val currentValue = golfValue(state.grids[seat][knownWorst.index]) + if (golfValue(drawn) < currentValue) return knownWorst + } + + // Nothing worth swapping: throw it and turn something over instead, + // which at least buys information. + if (flips.isNotEmpty()) return flips.first() + + // Forced to place it — cover a face-down card rather than a good one. + val hidden = replacements.firstOrNull { !state.revealed[seat][it.index] } + return hidden ?: knownWorst ?: replacements.first() + } + + /** A grid slot where this card would complete a matching column. */ + private fun completesColumn( + state: GolfState, + seat: Int, + card: org.prolibertate.games.game.cards.Card, + ): Int? { + val options = state.options + for (col in 0 until options.cols) { + val line = (0 until options.rows).map { it * options.cols + col } + // Every other cell in the column must already show this rank. + for (target in line) { + val others = line.filter { it != target } + val allMatch = others.all { index -> + state.revealed[seat][index] && state.grids[seat][index].rank == card.rank + } + if (allMatch && others.isNotEmpty()) return target + } + } + return null + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/golf/GolfModel.kt b/app/src/main/java/org/prolibertate/games/game/golf/GolfModel.kt new file mode 100644 index 0000000..867407a --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/golf/GolfModel.kt @@ -0,0 +1,132 @@ +package org.prolibertate.games.game.golf + +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 GolfOptions( + val playerCount: Int = 2, + /** Cards in front of each player: 4 (2x2), 6 (2x3) or 9 (3x3). */ + val gridSize: Int = 6, + val holes: Int = 9, + /** How many of your own cards you get to see before play starts. */ + val startingReveals: Int = 2, +) { + init { + require(playerCount in 2..6) { "Golf seats two to six" } + require(gridSize == 4 || gridSize == 6 || gridSize == 9) { + "Golf grids are 4, 6 or 9 cards" + } + require(holes >= 1) { "holes must be positive" } + require(startingReveals in 0..gridSize) { "cannot reveal more than the grid holds" } + } + + val rows: Int get() = if (gridSize == 9) 3 else 2 + val cols: Int get() = gridSize / rows +} + +/** + * Golf scoring: kings are free, twos are worth less than nothing, and the + * picture cards hurt. + */ +fun golfValue(card: Card): Int = when (card.rank) { + Rank.KING -> 0 + Rank.ACE -> 1 + Rank.TWO -> -2 + Rank.JACK, Rank.QUEEN -> 10 + else -> card.rank.order +} + +/** + * Scores one grid. + * + * A column of matching ranks cancels to nothing, which is the whole tactical + * point of the game. On the 3x3 board rows cancel too; on the shallower boards + * a "row" of three unrelated cards is not a line at all, so only columns count. + */ +fun scoreGrid(cards: List, options: GolfOptions): Int { + val rows = options.rows + val cols = options.cols + val cancelled = BooleanArray(cards.size) + + for (col in 0 until cols) { + val line = (0 until rows).map { it * cols + col } + if (line.map { cards[it].rank }.distinct().size == 1) { + line.forEach { cancelled[it] = true } + } + } + if (rows == 3) { + for (row in 0 until rows) { + val line = (0 until cols).map { row * cols + it } + if (line.map { cards[it].rank }.distinct().size == 1) { + line.forEach { cancelled[it] = true } + } + } + } + + return cards.indices.sumOf { if (cancelled[it]) 0 else golfValue(cards[it]) } +} + +/** + * Stands in for a face-down card in a redacted view. + * + * Grids always hold a real card so the host can score without null handling; + * a view for a given seat swaps every unrevealed card for this one. It is never + * drawn, because the UI only paints a face where `revealed` is true — and + * substituting a fixed card rather than the real one is what stops a client + * learning what it has not yet turned over. + */ +val HIDDEN_CARD: Card = Card(Rank.TWO, Suit.SPADES) + +@Serializable +enum class GolfPhase { DRAW, PLACE, HOLE_OVER, GAME_OVER } + +@Serializable +data class GolfState( + val options: GolfOptions, + val seed: Long, + val hole: Int, + val grids: List>, + val revealed: List>, + val stock: List, + val discard: List, + /** The card picked up this turn, waiting to be placed or thrown. */ + val drawn: Card?, + /** Taken from the discard pile, which means it has to be used. */ + val drawnFromDiscard: Boolean, + val turn: Int, + /** First seat to turn its whole grid face up. */ + val closedBy: Int?, + /** Turns left for everyone else once someone has closed. */ + val finalTurnsLeft: Int, + val holeScores: List, + val scores: List, + val phase: GolfPhase, + val log: List, +) { + fun allRevealed(seat: Int): Boolean = revealed[seat].all { it } +} + +@Serializable +sealed interface GolfMove + +@Serializable +@SerialName("draw_stock") +data object DrawFromStock : GolfMove + +@Serializable +@SerialName("draw_discard") +data object DrawFromDiscard : GolfMove + +/** Put the drawn card into the grid, throwing out what was there. */ +@Serializable +@SerialName("replace") +data class ReplaceCard(val index: Int) : GolfMove + +/** Throw the drawn card away and turn one of your own cards face up instead. */ +@Serializable +@SerialName("discard_flip") +data class DiscardAndFlip(val index: Int) : GolfMove diff --git a/app/src/main/java/org/prolibertate/games/game/golf/GolfRules.kt b/app/src/main/java/org/prolibertate/games/game/golf/GolfRules.kt new file mode 100644 index 0000000..ae2a983 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/golf/GolfRules.kt @@ -0,0 +1,283 @@ +package org.prolibertate.games.game.golf + +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 + +/** + * Golf as a pure state machine. See RULES-golf.md. + */ +object GolfRules : GameRules { + + override val gameId: String = GameCatalog.GOLF + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + override fun initialState(config: TableConfig): GolfState { + 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 = options, + seed = config.seed, + hole = 0, + scores = List(options.playerCount) { 0 }, + log = listOf("Hole 1."), + ) + } + + private fun deal( + options: GolfOptions, + seed: Long, + hole: Int, + scores: List, + log: List, + ): GolfState { + val random = Random(seed + hole * 7919L) + // A big table needs more than one deck to keep a usable stock. + val needed = options.playerCount * options.gridSize + val source = if (needed > 24) Decks.double52() else Decks.standard52() + var deck = source.shuffledWith(random) + + val grids = mutableListOf>() + repeat(options.playerCount) { + grids += deck.take(options.gridSize) + deck = deck.drop(options.gridSize) + } + + // The opening reveals are the leftmost cards of the top row, so every + // player starts knowing the same shape of their own board. + val revealed = List(options.playerCount) { + List(options.gridSize) { index -> index < options.startingReveals } + } + + val firstDiscard = deck.first() + return GolfState( + options = options, + seed = seed, + hole = hole, + grids = grids, + revealed = revealed, + stock = deck.drop(1), + discard = listOf(firstDiscard), + drawn = null, + drawnFromDiscard = false, + turn = hole % options.playerCount, + closedBy = null, + finalTurnsLeft = 0, + holeScores = List(options.playerCount) { 0 }, + scores = scores, + phase = GolfPhase.DRAW, + log = log, + ) + } + + /** Advances from [GolfPhase.HOLE_OVER] to the next hole. */ + fun nextHole(state: GolfState): GolfState { + check(state.phase == GolfPhase.HOLE_OVER) { "Hole is not over" } + return deal( + options = state.options, + seed = state.seed, + hole = state.hole + 1, + scores = state.scores, + log = state.log + "Hole ${state.hole + 2}.", + ) + } + + override fun currentSeat(state: GolfState): Int? = when (state.phase) { + GolfPhase.DRAW, GolfPhase.PLACE -> state.turn + GolfPhase.HOLE_OVER, GolfPhase.GAME_OVER -> null + } + + // ----------------------------------------------------------------------- + // Legal moves + // ----------------------------------------------------------------------- + + override fun legalMoves(state: GolfState, seat: Int): List { + if (currentSeat(state) != seat) return emptyList() + return when (state.phase) { + GolfPhase.DRAW -> buildList { + if (state.stock.isNotEmpty()) add(DrawFromStock) + if (state.discard.isNotEmpty()) add(DrawFromDiscard) + } + + GolfPhase.PLACE -> buildList { + for (index in 0 until state.options.gridSize) add(ReplaceCard(index)) + // A card taken from the discard pile has to be used; only a + // card off the stock may be thrown away again. + if (!state.drawnFromDiscard) { + for (index in 0 until state.options.gridSize) { + if (!state.revealed[seat][index]) add(DiscardAndFlip(index)) + } + } + } + + GolfPhase.HOLE_OVER, GolfPhase.GAME_OVER -> emptyList() + } + } + + // ----------------------------------------------------------------------- + // Move application + // ----------------------------------------------------------------------- + + override fun applyMove(state: GolfState, seat: Int, move: GolfMove): GolfState { + require(currentSeat(state) == seat) { "Not seat $seat's turn" } + require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" } + return when (move) { + is DrawFromStock -> state.copy( + drawn = state.stock.first(), + drawnFromDiscard = false, + stock = state.stock.drop(1), + phase = GolfPhase.PLACE, + ) + + is DrawFromDiscard -> state.copy( + drawn = state.discard.last(), + drawnFromDiscard = true, + discard = state.discard.dropLast(1), + phase = GolfPhase.PLACE, + ) + + is ReplaceCard -> applyReplace(state, seat, move.index) + is DiscardAndFlip -> applyDiscardAndFlip(state, seat, move.index) + } + } + + private fun applyReplace(state: GolfState, seat: Int, index: Int): GolfState { + val drawn = requireNotNull(state.drawn) { "Nothing drawn" } + val grids = state.grids.map { it.toMutableList() } + val revealed = state.revealed.map { it.toMutableList() } + + val replaced = grids[seat][index] + grids[seat][index] = drawn + // Whatever goes into the grid is placed face up. + revealed[seat][index] = true + + return endTurn( + state.copy( + grids = grids, + revealed = revealed, + discard = state.discard + replaced, + drawn = null, + log = state.log + "Seat $seat takes ${drawn.label}.", + ), + seat, + ) + } + + private fun applyDiscardAndFlip(state: GolfState, seat: Int, index: Int): GolfState { + val drawn = requireNotNull(state.drawn) { "Nothing drawn" } + val revealed = state.revealed.map { it.toMutableList() } + revealed[seat][index] = true + + return endTurn( + state.copy( + revealed = revealed, + discard = state.discard + drawn, + drawn = null, + log = state.log + "Seat $seat throws ${drawn.label} and turns one over.", + ), + seat, + ) + } + + /** + * Closes out a turn: notices a finished grid, counts down the last lap and + * either moves on or scores the hole. + */ + private fun endTurn(state: GolfState, seat: Int): GolfState { + var next = state + + // The first player to turn everything over gives everyone else one + // more turn, and no more than one. + if (next.closedBy == null && next.allRevealed(seat)) { + next = next.copy( + closedBy = seat, + finalTurnsLeft = next.options.playerCount - 1, + log = next.log + "Seat $seat is out — one turn each to answer.", + ) + } else if (next.closedBy != null) { + next = next.copy(finalTurnsLeft = (next.finalTurnsLeft - 1).coerceAtLeast(0)) + } + + if (next.closedBy != null && next.finalTurnsLeft <= 0) return scoreHole(next) + + // An empty stock is refilled from the discards, keeping the top card + // in play. If there is nothing to recycle, the hole ends where it is. + var stock = next.stock + var discard = next.discard + if (stock.isEmpty()) { + if (discard.size <= 1) return scoreHole(next) + val top = discard.last() + val random = Random(next.seed + next.log.size) + stock = discard.dropLast(1).shuffledWith(random) + discard = listOf(top) + } + + return next.copy( + stock = stock, + discard = discard, + turn = (seat + 1) % next.options.playerCount, + phase = GolfPhase.DRAW, + ) + } + + private fun scoreHole(state: GolfState): GolfState { + val holeScores = (0 until state.options.playerCount).map { seat -> + scoreGrid(state.grids[seat], state.options) + } + val scores = state.scores.mapIndexed { seat, total -> total + holeScores[seat] } + // Everything is turned face up to be counted. + val revealed = List(state.options.playerCount) { List(state.options.gridSize) { true } } + + val lastHole = state.hole + 1 >= state.options.holes + return state.copy( + revealed = revealed, + holeScores = holeScores, + scores = scores, + drawn = null, + phase = if (lastHole) GolfPhase.GAME_OVER else GolfPhase.HOLE_OVER, + log = state.log + "Hole ${state.hole + 1}: " + + holeScores.mapIndexed { seat, s -> "S$seat $s" }.joinToString(", "), + ) + } + + override fun isFinished(state: GolfState): Boolean = state.phase == GolfPhase.GAME_OVER + + override fun summary(state: GolfState): String = when (state.phase) { + GolfPhase.GAME_OVER -> { + val best = state.scores.indices.minByOrNull { state.scores[it] } ?: 0 + "Seat $best wins on ${state.scores[best]}" + } + + else -> state.scores.mapIndexed { seat, s -> "S$seat:$s" }.joinToString(" ") + } + + /** + * Hides every face-down card — including the viewer's own, which is the + * whole point of the game. + */ + override fun viewFor(state: GolfState, seat: Int): GolfState = state.copy( + grids = state.grids.mapIndexed { player, cards -> + cards.mapIndexed { index, card -> + if (state.revealed[player][index]) card else HIDDEN_CARD + } + }, + stock = emptyList(), + ) + + override fun encodeState(state: GolfState): String = json.encodeToString(state) + override fun decodeState(json: String): GolfState = this.json.decodeFromString(json) + override fun encodeMove(move: GolfMove): String = json.encodeToString(move) + override fun decodeMove(json: String): GolfMove = this.json.decodeFromString(json) +} diff --git a/app/src/main/java/org/prolibertate/games/game/president/PresidentAi.kt b/app/src/main/java/org/prolibertate/games/game/president/PresidentAi.kt new file mode 100644 index 0000000..3a9a1a9 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/president/PresidentAi.kt @@ -0,0 +1,85 @@ +package org.prolibertate.games.game.president + +import org.prolibertate.games.game.engine.GameAi + +/** + * A heuristic President opponent. + * + * It sheds low cards early, spends the cheapest set that takes the pile, and + * holds twos back for when they are worth something. It does not count cards. + */ +class PresidentAi(private val difficulty: Difficulty = Difficulty.NORMAL) : + GameAi { + + enum class Difficulty( + /** Rank above which a card is hoarded rather than spent to lead. */ + val hoardAbove: Int, + ) { + /** Plays whatever is cheapest, whenever it can. */ + EASY(hoardAbove = 99), + + NORMAL(hoardAbove = 13), + + /** Sits on high cards until late, then dumps them. */ + CAUTIOUS(hoardAbove = 11), + } + + override fun chooseMove( + state: PresidentState, + seat: Int, + legal: List, + ): PresidentMove { + require(legal.isNotEmpty()) { "No legal move for seat $seat" } + val plays = legal.filterIsInstance() + if (plays.isEmpty()) return legal.first() + + return if (state.setSize == 0) chooseLead(state, seat, plays) else chooseFollow(state, plays, legal) + } + + /** + * Leading is a chance to shed. Prefer the biggest set of the lowest rank, + * which clears junk fastest without spending anything valuable. + */ + private fun chooseLead( + state: PresidentState, + seat: Int, + plays: List, + ): PresidentMove { + val endgame = state.hands[seat].size <= 4 + val candidates = plays.filter { play -> + endgame || presidentRank(play.cards.first()) <= difficulty.hoardAbove + }.ifEmpty { plays } + + return candidates + .sortedWith( + compareBy( + { presidentRank(it.cards.first()) }, + { -it.cards.size }, + ) + ) + .first() + } + + /** + * Following costs something, so take the pile with the cheapest set that + * does it — and don't break up a two unless nothing else works. + */ + private fun chooseFollow( + state: PresidentState, + plays: List, + legal: List, + ): PresidentMove { + val withoutTwos = plays.filter { play -> play.cards.none { isTwo(it) } } + val pool = withoutTwos.ifEmpty { plays } + + val cheapest = pool.minByOrNull { presidentRank(it.cards.first()) } ?: plays.first() + + // Spending a two to take a single low card is a waste while there is + // still a game left to play. + val spendingTwo = cheapest.cards.any { isTwo(it) } + val worthIt = state.setRank >= 13 || state.pile.size >= 4 + if (spendingTwo && !worthIt && legal.contains(PassTurn)) return PassTurn + + return cheapest + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/president/PresidentModel.kt b/app/src/main/java/org/prolibertate/games/game/president/PresidentModel.kt new file mode 100644 index 0000000..0d72dcd --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/president/PresidentModel.kt @@ -0,0 +1,94 @@ +package org.prolibertate.games.game.president + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.cards.Rank + +/** + * Rule toggles. President is a house-rules game more than most, so the + * variations that change play are surfaced rather than baked in. + */ +@Serializable +data class PresidentOptions( + val playerCount: Int = 4, + /** A set of twos beats anything and clears the pile. */ + val twosClear: Boolean = true, + /** Four of a kind beats anything and clears the pile. */ + val fourOfAKindBomb: Boolean = false, + /** After the first round, Scum hands their best cards to the President. */ + val cardExchange: Boolean = true, + val roundsToPlay: Int = 5, +) { + init { + require(playerCount in 3..7) { "President seats three to seven" } + require(roundsToPlay >= 1) { "roundsToPlay must be positive" } + } +} + +/** + * President ranks threes low and twos high, which is not the natural order of + * [Rank]. Everything that compares cards goes through here. + */ +fun presidentRank(card: Card): Int = when (card.rank) { + Rank.TWO -> 15 + Rank.ACE -> 14 + else -> card.rank.order +} + +fun isTwo(card: Card): Boolean = card.rank == Rank.TWO + +@Serializable +enum class PresidentPhase { PLAYING, ROUND_OVER, GAME_OVER } + +@Serializable +data class PresidentState( + val options: PresidentOptions, + val seed: Long, + val roundNumber: Int, + val hands: List>, + val handCounts: List, + /** Cards played to the current pile, most recent set last. */ + val pile: List, + /** How many cards the current set is; 0 when the pile is clear. */ + val setSize: Int, + /** Rank of the set on top of the pile. */ + val setRank: Int, + val turn: Int, + /** Seats that have passed since the pile was last cleared. */ + val passed: List, + /** Seats in the order they went out — first is President. */ + val finishedOrder: List, + /** Finishing position from the previous round, or -1 on the first. */ + val previousFinish: List, + val scores: List, + val phase: PresidentPhase, + val log: List, +) { + fun isOut(seat: Int): Boolean = seat in finishedOrder + + /** Seats still holding cards and not yet passed out of this pile. */ + fun stillInPile(): List = + (0 until options.playerCount).filter { !isOut(it) && !passed[it] } +} + +@Serializable +sealed interface PresidentMove + +/** A set of equal-ranked cards. */ +@Serializable +@SerialName("play") +data class PlayCards(val cards: List) : PresidentMove + +@Serializable +@SerialName("pass") +data object PassTurn : PresidentMove + +/** Title for a finishing position, for display. */ +fun titleFor(position: Int, playerCount: Int): String = when { + position == 0 -> "President" + position == 1 && playerCount > 3 -> "Vice President" + position == playerCount - 1 -> "Scum" + position == playerCount - 2 && playerCount > 3 -> "Vice Scum" + else -> "Citizen" +} diff --git a/app/src/main/java/org/prolibertate/games/game/president/PresidentRules.kt b/app/src/main/java/org/prolibertate/games/game/president/PresidentRules.kt new file mode 100644 index 0000000..da0b4b5 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/president/PresidentRules.kt @@ -0,0 +1,337 @@ +package org.prolibertate.games.game.president + +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 + +/** + * President as a pure state machine. See RULES-president.md. + */ +object PresidentRules : GameRules { + + override val gameId: String = GameCatalog.PRESIDENT + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + override fun initialState(config: TableConfig): PresidentState { + 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 = options, + seed = config.seed, + roundNumber = 0, + previousFinish = List(options.playerCount) { -1 }, + scores = List(options.playerCount) { 0 }, + log = listOf("Round 1."), + ) + } + + private fun deal( + options: PresidentOptions, + seed: Long, + roundNumber: Int, + previousFinish: List, + scores: List, + log: List, + ): PresidentState { + val random = Random(seed + roundNumber * 7919L) + val deck = Decks.standard52().shuffledWith(random) + + // Deal round-robin so hands differ by at most one card. + val hands = MutableList(options.playerCount) { mutableListOf() } + deck.forEachIndexed { index, card -> hands[index % options.playerCount] += card } + val sorted = hands.map { hand -> hand.sortedBy { presidentRank(it) } } + + val exchanged = + if (roundNumber > 0 && options.cardExchange) { + applyExchange(sorted, previousFinish, options) + } else { + sorted + } + + // The lowest card in the game leads the first round; afterwards the + // Scum leads, which is the usual way of keeping the loser in the game. + val leader = if (roundNumber == 0) { + exchanged.indices.minByOrNull { seat -> + exchanged[seat].minOfOrNull { presidentRank(it) } ?: Int.MAX_VALUE + } ?: 0 + } else { + previousFinish.indexOf(options.playerCount - 1).takeIf { it >= 0 } ?: 0 + } + + return PresidentState( + options = options, + seed = seed, + roundNumber = roundNumber, + hands = exchanged, + handCounts = exchanged.map { it.size }, + pile = emptyList(), + setSize = 0, + setRank = 0, + turn = leader, + passed = List(options.playerCount) { false }, + finishedOrder = emptyList(), + previousFinish = previousFinish, + scores = scores, + phase = PresidentPhase.PLAYING, + log = log, + ) + } + + /** + * Scum hands their best cards up, the President hands their worst back. + * + * The President choosing which to return is the fuller rule; here it is + * automatic so the exchange does not need a phase of its own. See + * RULES-president.md. + */ + private fun applyExchange( + hands: List>, + previousFinish: List, + options: PresidentOptions, + ): List> { + val president = previousFinish.indexOf(0) + val scum = previousFinish.indexOf(options.playerCount - 1) + if (president < 0 || scum < 0 || president == scum) return hands + + val working = hands.map { it.toMutableList() } + val count = 2 + + val fromScum = working[scum].sortedByDescending { presidentRank(it) }.take(count) + val fromPresident = working[president].sortedBy { presidentRank(it) }.take(count) + if (fromScum.size < count || fromPresident.size < count) return hands + + fromScum.forEach { working[scum].remove(it) } + fromPresident.forEach { working[president].remove(it) } + working[president] += fromScum + working[scum] += fromPresident + + return working.map { hand -> hand.sortedBy { presidentRank(it) } } + } + + /** Advances from [PresidentPhase.ROUND_OVER] into the next deal. */ + fun nextRound(state: PresidentState): PresidentState { + check(state.phase == PresidentPhase.ROUND_OVER) { "Round is not over" } + val finish = MutableList(state.options.playerCount) { -1 } + state.finishedOrder.forEachIndexed { position, seat -> finish[seat] = position } + return deal( + options = state.options, + seed = state.seed, + roundNumber = state.roundNumber + 1, + previousFinish = finish, + scores = state.scores, + log = state.log + "Round ${state.roundNumber + 2}.", + ) + } + + override fun currentSeat(state: PresidentState): Int? = + if (state.phase == PresidentPhase.PLAYING) state.turn else null + + // ----------------------------------------------------------------------- + // Legal moves + // ----------------------------------------------------------------------- + + override fun legalMoves(state: PresidentState, seat: Int): List { + if (currentSeat(state) != seat) return emptyList() + val hand = state.hands[seat] + if (hand.isEmpty()) return emptyList() + + val byRank = hand.groupBy { presidentRank(it) }.toSortedMap() + val moves = mutableListOf() + + if (state.setSize == 0) { + // Leading: any number of one rank. + for ((_, cards) in byRank) { + for (size in 1..cards.size) moves += PlayCards(cards.take(size)) + } + return moves + } + + for ((rank, cards) in byRank) { + if (cards.size < state.setSize) continue + val set = cards.take(state.setSize) + val beatsByRank = rank > state.setRank + val clears = clearsPile(state.options, set, cards.size) + if (beatsByRank || clears) moves += PlayCards(set) + } + moves += PassTurn + return moves + } + + /** Sets that beat the pile outright rather than on rank. */ + private fun clearsPile( + options: PresidentOptions, + set: List, + availableOfRank: Int, + ): Boolean { + if (options.twosClear && set.all { isTwo(it) }) return true + if (options.fourOfAKindBomb && set.size == 4 && availableOfRank >= 4) return true + return false + } + + // ----------------------------------------------------------------------- + // Move application + // ----------------------------------------------------------------------- + + override fun applyMove( + state: PresidentState, + seat: Int, + move: PresidentMove, + ): PresidentState { + require(currentSeat(state) == seat) { "Not seat $seat's turn" } + require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" } + return when (move) { + is PassTurn -> applyPass(state, seat) + is PlayCards -> applyPlay(state, seat, move.cards) + } + } + + private fun applyPass(state: PresidentState, seat: Int): PresidentState { + val passed = state.passed.toMutableList() + passed[seat] = true + val afterPass = state.copy(passed = passed, log = state.log + "Seat $seat passes.") + return advance(afterPass, from = seat) + } + + private fun applyPlay( + state: PresidentState, + seat: Int, + cards: List, + ): PresidentState { + val hand = state.hands[seat].toMutableList() + cards.forEach { hand.remove(it) } + val hands = state.hands.toMutableList() + hands[seat] = hand + + val rank = presidentRank(cards.first()) + val ranksOfHand = state.hands[seat].count { presidentRank(it) == rank } + val clears = clearsPile(state.options, cards, ranksOfHand) + + var next = state.copy( + hands = hands, + handCounts = hands.map { it.size }, + pile = state.pile + cards, + setSize = cards.size, + setRank = rank, + log = state.log + "Seat $seat plays ${cards.joinToString(" ") { it.label }}.", + ) + + // Going out is recorded before anything else, so a player who finishes + // on a pile-clearing set does not also get to lead the next one. + if (hand.isEmpty()) { + val order = next.finishedOrder + seat + next = next.copy( + finishedOrder = order, + log = next.log + "Seat $seat is out (${titleFor(order.size - 1, state.options.playerCount)}).", + ) + if (roundComplete(next)) return scoreRound(next) + } + + if (clears) { + // Pile cleared: the same player leads again unless they just went out. + val leader = if (next.isOut(seat)) nextActive(next, seat) else seat + return next.copy( + pile = emptyList(), + setSize = 0, + setRank = 0, + passed = List(state.options.playerCount) { false }, + turn = leader, + log = next.log + "Pile cleared.", + ) + } + + return advance(next, from = seat) + } + + /** + * Moves to the next seat, clearing the pile when everyone else has passed. + */ + private fun advance(state: PresidentState, from: Int): PresidentState { + val contenders = state.stillInPile() + // Everyone who could still beat the pile has passed: it is taken down + // and whoever played last leads again. + if (state.setSize > 0 && contenders.size <= 1) { + val leader = contenders.firstOrNull() + ?: nextActive(state, from) + return state.copy( + pile = emptyList(), + setSize = 0, + setRank = 0, + passed = List(state.options.playerCount) { false }, + turn = leader, + log = state.log + "Pile cleared.", + ) + } + return state.copy(turn = nextActive(state, from)) + } + + /** The next seat still holding cards and not passed out of this pile. */ + private fun nextActive(state: PresidentState, from: Int): Int { + val count = state.options.playerCount + for (step in 1..count) { + val seat = (from + step) % count + if (!state.isOut(seat) && !state.passed[seat]) return seat + } + // Nobody is left in the pile; fall back to anyone still holding cards. + for (step in 1..count) { + val seat = (from + step) % count + if (!state.isOut(seat)) return seat + } + return from + } + + private fun roundComplete(state: PresidentState): Boolean = + state.finishedOrder.size >= state.options.playerCount - 1 + + private fun scoreRound(state: PresidentState): PresidentState { + // The straggler takes last place. + val remaining = (0 until state.options.playerCount).filter { !state.isOut(it) } + val order = state.finishedOrder + remaining + + val scores = state.scores.toMutableList() + order.forEachIndexed { position, seat -> + scores[seat] = scores[seat] + (state.options.playerCount - 1 - position) + } + + val finalRound = state.roundNumber + 1 >= state.options.roundsToPlay + return state.copy( + finishedOrder = order, + scores = scores, + phase = if (finalRound) PresidentPhase.GAME_OVER else PresidentPhase.ROUND_OVER, + log = state.log + "Round over.", + ) + } + + override fun isFinished(state: PresidentState): Boolean = + state.phase == PresidentPhase.GAME_OVER + + override fun summary(state: PresidentState): String = when (state.phase) { + PresidentPhase.GAME_OVER -> { + val best = state.scores.indices.maxByOrNull { state.scores[it] } ?: 0 + "Seat $best wins with ${state.scores[best]}" + } + + else -> state.scores.mapIndexed { seat, s -> "S$seat:$s" }.joinToString(" ") + } + + override fun viewFor(state: PresidentState, seat: Int): PresidentState = state.copy( + hands = state.hands.mapIndexed { index, hand -> if (index == seat) hand else emptyList() }, + handCounts = state.handCounts, + ) + + override fun encodeState(state: PresidentState): String = json.encodeToString(state) + override fun decodeState(json: String): PresidentState = this.json.decodeFromString(json) + override fun encodeMove(move: PresidentMove): String = json.encodeToString(move) + override fun decodeMove(json: String): PresidentMove = this.json.decodeFromString(json) +} 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 42fd5e3..76467d0 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -2,6 +2,8 @@ package org.prolibertate.games.ui import androidx.compose.foundation.layout.Arrangement 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.layout.padding @@ -29,6 +31,8 @@ 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.euchre.EuchreOptions +import org.prolibertate.games.game.golf.GolfOptions +import org.prolibertate.games.game.president.PresidentOptions import org.prolibertate.games.game.sequence.SequenceOptions private val setupJson = Json { encodeDefaults = true } @@ -49,15 +53,21 @@ fun GameSetupScreen( ) { var euchre by remember { mutableStateOf(EuchreOptions()) } var sequence by remember { mutableStateOf(SequenceOptions()) } + var president by remember { mutableStateOf(PresidentOptions()) } + var golf by remember { mutableStateOf(GolfOptions()) } val optionsJson = when (descriptor.id) { GameCatalog.EUCHRE -> setupJson.encodeToString(euchre) GameCatalog.SEQUENCE -> setupJson.encodeToString(sequence) + GameCatalog.PRESIDENT -> setupJson.encodeToString(president) + GameCatalog.GOLF -> setupJson.encodeToString(golf) else -> "{}" } val seatCount = when (descriptor.id) { GameCatalog.EUCHRE -> 4 GameCatalog.SEQUENCE -> sequence.playerCount + GameCatalog.PRESIDENT -> president.playerCount + GameCatalog.GOLF -> golf.playerCount else -> descriptor.minPlayers } @@ -74,6 +84,8 @@ fun GameSetupScreen( when (descriptor.id) { GameCatalog.EUCHRE -> EuchreOptionsEditor(euchre) { euchre = it } GameCatalog.SEQUENCE -> SequenceOptionsEditor(sequence) { sequence = it } + GameCatalog.PRESIDENT -> PresidentOptionsEditor(president) { president = it } + GameCatalog.GOLF -> GolfOptionsEditor(golf) { golf = it } else -> Text("No options yet for this game.") } @@ -121,8 +133,9 @@ private fun offlineConfig( fun teamForSeat(gameId: String, seat: Int): Int = when (gameId) { // Partners sit opposite each other. - GameCatalog.EUCHRE -> seat % 2 - else -> seat % 2 + GameCatalog.EUCHRE, GameCatalog.SEQUENCE -> seat % 2 + // Everyone else plays for themselves. + else -> seat } @Composable @@ -197,6 +210,95 @@ private fun SequenceOptionsEditor(options: SequenceOptions, onChange: (SequenceO } } +@Composable +private fun PresidentOptionsEditor( + options: PresidentOptions, + onChange: (PresidentOptions) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ChipRow( + label = "Players", + values = listOf(3, 4, 5, 6, 7), + selected = options.playerCount, + display = { "$it" }, + onSelect = { onChange(options.copy(playerCount = it)) }, + ) + ChipRow( + label = "Rounds", + values = listOf(3, 5, 7), + selected = options.roundsToPlay, + display = { "$it" }, + onSelect = { onChange(options.copy(roundsToPlay = it)) }, + ) + ToggleRow( + title = "Twos clear the pile", + subtitle = "A two beats anything and takes the pile down", + checked = options.twosClear, + onChange = { onChange(options.copy(twosClear = it)) }, + ) + ToggleRow( + title = "Four of a kind bombs", + subtitle = "Four matching cards beat anything, whatever the pile", + checked = options.fourOfAKindBomb, + onChange = { onChange(options.copy(fourOfAKindBomb = it)) }, + ) + ToggleRow( + title = "Card exchange", + subtitle = "After each round the Scum hands their best cards to the President", + checked = options.cardExchange, + onChange = { onChange(options.copy(cardExchange = it)) }, + ) + } +} + +@Composable +private fun GolfOptionsEditor(options: GolfOptions, onChange: (GolfOptions) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ChipRow( + label = "Players", + values = listOf(2, 3, 4, 5, 6), + selected = options.playerCount, + display = { "$it" }, + onSelect = { onChange(options.copy(playerCount = it)) }, + ) + ChipRow( + label = "Cards each", + values = listOf(4, 6, 9), + selected = options.gridSize, + display = { "$it" }, + onSelect = { size -> + // Keep the opening reveals inside the new grid. + onChange( + options.copy( + gridSize = size, + startingReveals = options.startingReveals.coerceAtMost(size), + ) + ) + }, + ) + ChipRow( + label = "Holes", + values = listOf(3, 6, 9), + selected = options.holes, + display = { "$it" }, + onSelect = { onChange(options.copy(holes = it)) }, + ) + ChipRow( + label = "Seen at the start", + values = (0..options.gridSize).toList(), + selected = options.startingReveals, + display = { "$it" }, + onSelect = { onChange(options.copy(startingReveals = it)) }, + ) + Text( + text = "${options.rows} rows of ${options.cols}. Matching columns cancel out" + + if (options.gridSize == 9) ", and so do matching rows." else ".", + style = MaterialTheme.typography.bodySmall, + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) @Composable private fun ChipRow( label: String, @@ -207,7 +309,12 @@ private fun ChipRow( ) { Column { Text(label, style = MaterialTheme.typography.labelLarge) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + // Wraps rather than running off the edge: some of these rows carry ten + // chips, and a chip you cannot see is a setting you cannot change. + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { values.forEach { value -> FilterChip( selected = value == selected, 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 770ec79..f137aa7 100644 --- a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt @@ -12,6 +12,16 @@ import org.prolibertate.games.game.euchre.EuchreMove import org.prolibertate.games.game.euchre.EuchrePhase import org.prolibertate.games.game.euchre.EuchreRules import org.prolibertate.games.game.euchre.EuchreState +import org.prolibertate.games.game.golf.GolfAi +import org.prolibertate.games.game.golf.GolfMove +import org.prolibertate.games.game.golf.GolfPhase +import org.prolibertate.games.game.golf.GolfRules +import org.prolibertate.games.game.golf.GolfState +import org.prolibertate.games.game.president.PresidentAi +import org.prolibertate.games.game.president.PresidentMove +import org.prolibertate.games.game.president.PresidentPhase +import org.prolibertate.games.game.president.PresidentRules +import org.prolibertate.games.game.president.PresidentState import org.prolibertate.games.game.sequence.SequenceAi import org.prolibertate.games.game.sequence.SequenceMove import org.prolibertate.games.game.sequence.SequenceRules @@ -19,6 +29,8 @@ import org.prolibertate.games.game.sequence.SequenceState import org.prolibertate.games.net.MatchController import org.prolibertate.games.settings.Settings import org.prolibertate.games.ui.game.EuchreScreen +import org.prolibertate.games.ui.game.GolfScreen +import org.prolibertate.games.ui.game.PresidentScreen import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS import org.prolibertate.games.ui.game.SequenceScreen @@ -96,6 +108,50 @@ fun PlayScreen( SequenceScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) } + GameCatalog.PRESIDENT -> { + val controller = remember(route) { + MatchController( + rules = PresidentRules, + ai = PresidentAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + advanceIdle = { state -> + if (state.phase == PresidentPhase.ROUND_OVER) { + PresidentRules.nextRound(state) + } else { + null + } + }, + aiThinkingMillis = { settings.scaled(700L) }, + ) + } + StartMatch(env, controller, route) + PresidentScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) + } + + GameCatalog.GOLF -> { + val controller = remember(route) { + MatchController( + rules = GolfRules, + ai = GolfAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + advanceIdle = { state -> + if (state.phase == GolfPhase.HOLE_OVER) GolfRules.nextHole(state) else null + }, + aiThinkingMillis = { settings.scaled(700L) }, + ) + } + StartMatch(env, controller, route) + GolfScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) + } + else -> Text("That game isn't playable yet.") } } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/GolfScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/GolfScreen.kt new file mode 100644 index 0000000..ec3d220 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/GolfScreen.kt @@ -0,0 +1,335 @@ +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.horizontalScroll +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.prolibertate.games.game.golf.DiscardAndFlip +import org.prolibertate.games.game.golf.DrawFromDiscard +import org.prolibertate.games.game.golf.DrawFromStock +import org.prolibertate.games.game.golf.GolfMove +import org.prolibertate.games.game.golf.GolfPhase +import org.prolibertate.games.game.golf.GolfState +import org.prolibertate.games.game.golf.ReplaceCard +import org.prolibertate.games.game.golf.golfValue +import org.prolibertate.games.net.MatchController +import org.prolibertate.games.ui.PrimaryAction +import org.prolibertate.games.ui.ScreenScaffold +import org.prolibertate.games.ui.common.CardBackView +import org.prolibertate.games.ui.common.PlayingCardView +import org.prolibertate.games.ui.theme.FeltGreenDark + +/** What tapping one of your own cards will do once you're holding a card. */ +private enum class PlaceMode(val label: String) { + SWAP("Swap it in"), + THROW("Throw it and turn one over"), +} + +/** + * The Golf table. + * + * The grid is the game, so it gets the space: your own cards are large and + * tappable, everyone else's are small. Face-down cards are drawn as backs — + * including your own, which you genuinely have not seen. + */ +@Composable +fun GolfScreen( + controller: MatchController, + localSeat: Int, + onExit: () -> Unit, +) { + val state by controller.state.collectAsState() + val legal by controller.legalMoves.collectAsState() + var mode by remember { mutableStateOf(PlaceMode.SWAP) } + + val current = state + if (current == null) { + ScreenScaffold(title = "Golf", onBack = onExit) { modifier -> + Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") } + } + return + } + + ScreenScaffold(title = "Golf", onBack = onExit) { modifier -> + BoxWithConstraints(modifier = modifier) { + val shortest = minOf(maxWidth, maxHeight) + val ownCard = (shortest * 0.16f).coerceIn(40.dp, 84.dp) + val otherCard = (shortest * 0.07f).coerceIn(20.dp, 40.dp) + + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ScoreLine(current, localSeat) + + Box( + modifier = Modifier.fillMaxWidth().background(FeltGreenDark).padding(6.dp), + ) { + StockAndDiscard(current, localSeat, legal, ownCard) { controller.submit(it) } + } + + OpponentGrids(current, localSeat, otherCard) + + if (current.phase == GolfPhase.PLACE && legal.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + PlaceMode.entries.forEach { option -> + val enabled = option == PlaceMode.SWAP || + legal.any { it is DiscardAndFlip } + FilterChip( + selected = mode == option, + enabled = enabled, + onClick = { mode = option }, + label = { Text(option.label) }, + ) + } + } + } + + when (current.phase) { + GolfPhase.HOLE_OVER -> Text( + text = "Hole ${current.hole + 1}: " + current.holeScores + .mapIndexed { seat, s -> "seat $seat $s" }.joinToString(", "), + style = MaterialTheme.typography.bodySmall, + ) + + GolfPhase.GAME_OVER -> { + Text("Game over — lowest score wins.", fontWeight = FontWeight.Bold) + PrimaryAction(text = "Back to the menu") { onExit() } + } + + else -> Unit + } + + OwnGrid( + state = current, + localSeat = localSeat, + cardWidth = ownCard, + legal = legal, + mode = mode, + onMove = { controller.submit(it); mode = PlaceMode.SWAP }, + ) + } + } + } +} + +@Composable +private fun ScoreLine(state: GolfState, localSeat: Int) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Text("Hole ${state.hole + 1} of ${state.options.holes}", fontWeight = FontWeight.Bold) + Text( + text = "Your total: ${state.scores[localSeat]} (lowest wins)", + style = MaterialTheme.typography.bodySmall, + ) + } + state.closedBy?.let { + Text( + text = if (it == localSeat) "You closed it out" else "Seat $it is out", + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +@Composable +private fun StockAndDiscard( + state: GolfState, + localSeat: Int, + legal: List, + cardWidth: Dp, + onMove: (GolfMove) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Stock", style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary) + CardBackView(width = cardWidth) + if (legal.contains(DrawFromStock)) { + Button(onClick = { onMove(DrawFromStock) }) { Text("Draw") } + } + } + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Discard", style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary) + state.discard.lastOrNull()?.let { PlayingCardView(card = it, width = cardWidth) } + if (legal.contains(DrawFromDiscard)) { + Button(onClick = { onMove(DrawFromDiscard) }) { Text("Take") } + } + } + + state.drawn?.let { drawn -> + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Holding", style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary) + PlayingCardView(card = drawn, width = cardWidth, selected = true) + Text( + text = "worth ${golfValue(drawn)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary, + ) + } + } + + if (state.phase == GolfPhase.DRAW && state.turn != localSeat) { + Text( + text = "Seat ${state.turn} is playing…", + color = MaterialTheme.colorScheme.onPrimary, + ) + } + } +} + +@Composable +private fun OwnGrid( + state: GolfState, + localSeat: Int, + cardWidth: Dp, + legal: List, + mode: PlaceMode, + onMove: (GolfMove) -> Unit, +) { + val options = state.options + val replacements = legal.filterIsInstance().map { it.index }.toSet() + val flips = legal.filterIsInstance().map { it.index }.toSet() + + Column { + Text( + text = when { + state.phase == GolfPhase.PLACE && mode == PlaceMode.SWAP -> + "Tap a card to swap it out" + + state.phase == GolfPhase.PLACE -> + "Tap a face-down card to turn it over" + + else -> "Your cards" + }, + style = MaterialTheme.typography.labelLarge, + ) + for (row in 0 until options.rows) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + for (col in 0 until options.cols) { + val index = row * options.cols + col + val tappable = when (mode) { + PlaceMode.SWAP -> index in replacements + PlaceMode.THROW -> index in flips + } + GridCard( + card = state.grids[localSeat].getOrNull(index), + faceUp = state.revealed[localSeat].getOrElse(index) { false }, + width = cardWidth, + tappable = tappable, + onClick = { + when (mode) { + PlaceMode.SWAP -> onMove(ReplaceCard(index)) + PlaceMode.THROW -> onMove(DiscardAndFlip(index)) + } + }, + ) + } + } + } + } +} + +@Composable +private fun GridCard( + card: org.prolibertate.games.game.cards.Card?, + faceUp: Boolean, + width: Dp, + tappable: Boolean, + onClick: () -> Unit, +) { + val shape = RoundedCornerShape(width * 0.12f) + Box( + modifier = Modifier + .clip(shape) + .border( + width = if (tappable) 3.dp else 0.dp, + color = if (tappable) { + MaterialTheme.colorScheme.tertiary + } else { + androidx.compose.ui.graphics.Color.Transparent + }, + shape = shape, + ) + .then(if (tappable) Modifier.clickable { onClick() } else Modifier) + .padding(2.dp), + ) { + if (faceUp && card != null) { + PlayingCardView(card = card, width = width) + } else { + CardBackView(width = width) + } + } +} + +@Composable +private fun OpponentGrids(state: GolfState, localSeat: Int, cardWidth: Dp) { + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + (0 until state.options.playerCount) + .filter { it != localSeat } + .forEach { seat -> + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "Seat $seat", + style = MaterialTheme.typography.labelSmall, + fontWeight = if (seat == state.turn) FontWeight.Bold else FontWeight.Normal, + ) + for (row in 0 until state.options.rows) { + Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { + for (col in 0 until state.options.cols) { + val index = row * state.options.cols + col + val faceUp = state.revealed[seat].getOrElse(index) { false } + if (faceUp) { + PlayingCardView( + card = state.grids[seat][index], + width = cardWidth, + ) + } else { + CardBackView(width = cardWidth) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/PresidentScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/PresidentScreen.kt new file mode 100644 index 0000000..8f8d8ff --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/PresidentScreen.kt @@ -0,0 +1,265 @@ +package org.prolibertate.games.ui.game + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +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.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.president.PassTurn +import org.prolibertate.games.game.president.PlayCards +import org.prolibertate.games.game.president.PresidentMove +import org.prolibertate.games.game.president.PresidentPhase +import org.prolibertate.games.game.president.PresidentState +import org.prolibertate.games.game.president.titleFor +import org.prolibertate.games.net.MatchController +import org.prolibertate.games.ui.PrimaryAction +import org.prolibertate.games.ui.ScreenScaffold +import org.prolibertate.games.ui.common.CardBackView +import org.prolibertate.games.ui.common.PlayingCardView +import org.prolibertate.games.ui.theme.FeltGreenDark + +/** + * The President table. + * + * Plays are offered as whole sets rather than by selecting individual cards. + * A set is the unit of the game — you never play four cards of different + * ranks — so picking from the legal sets is both simpler to operate and + * impossible to get wrong. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun PresidentScreen( + controller: MatchController, + localSeat: Int, + onExit: () -> Unit, +) { + val state by controller.state.collectAsState() + val legal by controller.legalMoves.collectAsState() + + val current = state + if (current == null) { + ScreenScaffold(title = "President", onBack = onExit) { modifier -> + Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") } + } + return + } + + ScreenScaffold(title = "President", onBack = onExit) { modifier -> + BoxWithConstraints(modifier = modifier) { + val cardWidth = (minOf(maxWidth, maxHeight) * 0.15f).coerceIn(44.dp, 88.dp) + + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + StandingsBar(current, localSeat) + + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .background(FeltGreenDark), + contentAlignment = Alignment.Center, + ) { + PileView(current, cardWidth) + } + + OpponentRow(current, localSeat) + + when (current.phase) { + PresidentPhase.PLAYING -> PlayChoices(current, localSeat, legal) { + controller.submit(it) + } + + PresidentPhase.ROUND_OVER -> Text( + text = "Round over — " + current.finishedOrder + .mapIndexed { position, seat -> + "${titleFor(position, current.options.playerCount)}: seat $seat" + } + .joinToString(", "), + style = MaterialTheme.typography.bodySmall, + ) + + PresidentPhase.GAME_OVER -> { + Text("Game over.", fontWeight = FontWeight.Bold) + PrimaryAction(text = "Back to the menu") { onExit() } + } + } + + HandView(current, localSeat, cardWidth) + } + } + } +} + +@Composable +private fun StandingsBar(state: PresidentState, localSeat: Int) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Text("Round ${state.roundNumber + 1} of ${state.options.roundsToPlay}", + fontWeight = FontWeight.Bold) + Text( + text = "Your score: ${state.scores[localSeat]}", + style = MaterialTheme.typography.bodySmall, + ) + } + Column(horizontalAlignment = Alignment.End) { + val position = state.finishedOrder.indexOf(localSeat) + Text( + text = if (position >= 0) { + "You are out — ${titleFor(position, state.options.playerCount)}" + } else { + "${state.hands[localSeat].size} cards in hand" + }, + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +@Composable +private fun PileView(state: PresidentState, cardWidth: Dp) { + if (state.setSize == 0) { + Text( + text = "Pile is clear — anything leads", + color = MaterialTheme.colorScheme.onPrimary, + ) + return + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "On the pile", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary, + ) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + state.pile.takeLast(state.setSize).forEach { card -> + PlayingCardView(card = card, width = cardWidth) + } + } + Text( + text = "Beat it with ${state.setSize} card(s)", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary, + ) + } +} + +/** Everyone else, as card counts — their hands are never sent to this device. */ +@Composable +private fun OpponentRow(state: PresidentState, localSeat: Int) { + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + (0 until state.options.playerCount) + .filter { it != localSeat } + .forEach { seat -> + val count = state.handCounts.getOrElse(seat) { 0 } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "Seat $seat", + style = MaterialTheme.typography.labelSmall, + fontWeight = if (seat == state.turn) FontWeight.Bold else FontWeight.Normal, + ) + Row(horizontalArrangement = Arrangement.spacedBy(1.dp)) { + repeat(count.coerceAtMost(6)) { CardBackView(width = 14.dp) } + } + Text( + text = if (count == 0) "out" else "$count", + style = MaterialTheme.typography.labelSmall, + ) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun PlayChoices( + state: PresidentState, + localSeat: Int, + legal: List, + onMove: (PresidentMove) -> Unit, +) { + if (legal.isEmpty()) { + Text( + text = if (state.isOut(localSeat)) "You're out — watching." else "Seat ${state.turn} to play…", + style = MaterialTheme.typography.bodySmall, + ) + return + } + + val plays = legal.filterIsInstance() + val canPass = legal.contains(PassTurn) + + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = if (state.setSize == 0) "Lead a set" else "Play or pass", + fontWeight = FontWeight.Bold, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + plays.forEach { play -> + Button(onClick = { onMove(play) }) { + Text(play.cards.joinToString(" ") { it.label }) + } + } + } + if (canPass) { + OutlinedButton( + onClick = { onMove(PassTurn) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Pass") + } + } + } +} + +@Composable +private fun HandView(state: PresidentState, localSeat: Int, cardWidth: Dp) { + val hand = state.hands.getOrNull(localSeat).orEmpty() + Column { + Text("Your hand", style = MaterialTheme.typography.labelLarge) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (hand.isEmpty()) { + Text("—", style = MaterialTheme.typography.bodySmall) + } + hand.forEach { card -> + PlayingCardView(card = card, width = cardWidth * 0.8f) + } + } + } +} 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 6def631..cf09989 100644 --- a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt @@ -50,7 +50,12 @@ class CatalogTest { @Test fun `playable games are the ones with engines wired up`() { assertEquals( - setOf(GameCatalog.EUCHRE, GameCatalog.SEQUENCE), + setOf( + GameCatalog.EUCHRE, + GameCatalog.SEQUENCE, + GameCatalog.PRESIDENT, + GameCatalog.GOLF, + ), GameCatalog.playable.map { it.id }.toSet(), ) } diff --git a/app/src/test/java/org/prolibertate/games/game/golf/GolfRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/golf/GolfRulesTest.kt new file mode 100644 index 0000000..77e9eef --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/golf/GolfRulesTest.kt @@ -0,0 +1,315 @@ +package org.prolibertate.games.game.golf + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +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 kotlin.random.Random + +class GolfRulesTest { + + private val json = Json { encodeDefaults = true } + + private fun config(options: GolfOptions = GolfOptions(), seed: Long = 3L) = TableConfig( + gameId = "golf", + 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 card(rank: Rank, suit: Suit = Suit.CLUBS) = Card(rank, suit) + + // -- Scoring ------------------------------------------------------------ + + @Test + fun `card values follow the golf table`() { + assertEquals(0, golfValue(card(Rank.KING))) + assertEquals(1, golfValue(card(Rank.ACE))) + assertEquals(-2, golfValue(card(Rank.TWO))) + assertEquals(10, golfValue(card(Rank.JACK))) + assertEquals(10, golfValue(card(Rank.QUEEN))) + assertEquals(7, golfValue(card(Rank.SEVEN))) + } + + @Test + fun `a matching column cancels to nothing`() { + val options = GolfOptions(gridSize = 6) + // 2x3: columns are (0,3) (1,4) (2,5). + val grid = listOf( + card(Rank.NINE), card(Rank.FOUR), card(Rank.KING), + card(Rank.NINE, Suit.HEARTS), card(Rank.SEVEN), card(Rank.KING, Suit.HEARTS), + ) + // Nines cancel, kings are worth nothing anyway, 4 + 7 remain. + assertEquals(11, scoreGrid(grid, options)) + } + + @Test + fun `an unmatched grid scores the sum of its cards`() { + val options = GolfOptions(gridSize = 4) + val grid = listOf( + card(Rank.THREE), card(Rank.FOUR), + card(Rank.FIVE), card(Rank.SIX), + ) + assertEquals(18, scoreGrid(grid, options)) + } + + @Test + fun `twos pull a score below zero`() { + val options = GolfOptions(gridSize = 4) + val grid = listOf( + card(Rank.TWO), card(Rank.KING), + card(Rank.THREE), card(Rank.KING, Suit.HEARTS), + ) + assertEquals(1, scoreGrid(grid, options)) + } + + @Test + fun `rows cancel only on the nine card board`() { + val threeByThree = GolfOptions(gridSize = 9) + val grid = listOf( + card(Rank.EIGHT), card(Rank.EIGHT, Suit.HEARTS), card(Rank.EIGHT, Suit.SPADES), + card(Rank.THREE), card(Rank.FOUR), card(Rank.FIVE), + card(Rank.SIX), card(Rank.SEVEN), card(Rank.NINE), + ) + // The row of eights is wiped out, leaving 3+4+5+6+7+9. + assertEquals(34, scoreGrid(grid, threeByThree)) + + // The same three-in-a-line on a 2x3 board is not a line at all. + val twoByThree = GolfOptions(gridSize = 6) + val flat = listOf( + card(Rank.EIGHT), card(Rank.EIGHT, Suit.HEARTS), card(Rank.EIGHT, Suit.SPADES), + card(Rank.THREE), card(Rank.FOUR), card(Rank.FIVE), + ) + assertEquals(36, scoreGrid(flat, twoByThree)) + } + + // -- Dealing ------------------------------------------------------------ + + @Test + fun `everyone gets a grid and two cards face up`() { + val state = GolfRules.initialState(config(GolfOptions(playerCount = 3, gridSize = 6))) + assertEquals(3, state.grids.size) + state.grids.forEach { assertEquals(6, it.size) } + state.revealed.forEach { assertEquals(2, it.count { shown -> shown }) } + assertEquals(1, state.discard.size) + assertEquals(GolfPhase.DRAW, state.phase) + } + + @Test + fun `a big table uses two decks so the stock survives`() { + val state = GolfRules.initialState(config(GolfOptions(playerCount = 6, gridSize = 9))) + val dealt = 6 * 9 + assertTrue("stock must not be empty at the deal", state.stock.isNotEmpty()) + assertEquals(104, dealt + state.stock.size + state.discard.size) + } + + // -- Turn structure ----------------------------------------------------- + + @Test + fun `a turn is draw then place`() { + var state = GolfRules.initialState(config(GolfOptions(playerCount = 2))) + assertEquals(setOf(DrawFromStock, DrawFromDiscard), GolfRules.legalMoves(state, 0).toSet()) + + state = GolfRules.applyMove(state, 0, DrawFromStock) + assertEquals(GolfPhase.PLACE, state.phase) + assertTrue(state.drawn != null) + + val moves = GolfRules.legalMoves(state, 0) + assertTrue("can place it anywhere", moves.filterIsInstance().size == 6) + assertTrue("or throw it and turn one over", moves.any { it is DiscardAndFlip }) + } + + @Test + fun `a card taken from the discard pile must be used`() { + var state = GolfRules.initialState(config(GolfOptions(playerCount = 2))) + state = GolfRules.applyMove(state, 0, DrawFromDiscard) + val moves = GolfRules.legalMoves(state, 0) + assertTrue("no throwing it back", moves.none { it is DiscardAndFlip }) + assertTrue(moves.all { it is ReplaceCard }) + } + + @Test + fun `replacing a card turns that slot face up and discards the old one`() { + var state = GolfRules.initialState(config(GolfOptions(playerCount = 2))) + state = GolfRules.applyMove(state, 0, DrawFromStock) + val drawn = state.drawn!! + val replacedCard = state.grids[0][5] + + state = GolfRules.applyMove(state, 0, ReplaceCard(5)) + assertEquals(drawn, state.grids[0][5]) + assertTrue(state.revealed[0][5]) + assertEquals(replacedCard, state.discard.last()) + assertEquals("turn passes", 1, state.turn) + assertEquals(GolfPhase.DRAW, state.phase) + } + + @Test + fun `throwing the drawn card turns one of your own over`() { + var state = GolfRules.initialState(config(GolfOptions(playerCount = 2))) + state = GolfRules.applyMove(state, 0, DrawFromStock) + val drawn = state.drawn!! + assertFalse(state.revealed[0][4]) + + state = GolfRules.applyMove(state, 0, DiscardAndFlip(4)) + assertTrue(state.revealed[0][4]) + assertEquals(drawn, state.discard.last()) + } + + // -- Closing and scoring ------------------------------------------------ + + @Test + fun `turning the last card over gives everyone else one more turn`() { + val options = GolfOptions(playerCount = 3, gridSize = 4, startingReveals = 3, holes = 1) + var state = GolfRules.initialState(config(options)) + // Seat 0 has one card left face down; use it to close. + state = GolfRules.applyMove(state, 0, DrawFromStock) + state = GolfRules.applyMove(state, 0, DiscardAndFlip(3)) + + assertEquals(0, state.closedBy) + assertEquals("one turn each for the other two", 2, state.finalTurnsLeft) + assertFalse(GolfRules.isFinished(state)) + + // Those two turns play out, then the hole is scored. + repeat(2) { + val seat = GolfRules.currentSeat(state)!! + state = GolfRules.applyMove(state, seat, GolfRules.legalMoves(state, seat).first()) + state = GolfRules.applyMove(state, seat, GolfRules.legalMoves(state, seat).first()) + } + assertTrue("single-hole game is over", GolfRules.isFinished(state)) + assertTrue("everything is turned up to count", state.revealed.all { row -> row.all { it } }) + } + + @Test + fun `scores accumulate across holes`() { + val options = GolfOptions(playerCount = 2, gridSize = 4, startingReveals = 4, holes = 3) + var state = GolfRules.initialState(config(options)) + var guard = 0 + while (state.hole == 0 && !GolfRules.isFinished(state) && guard++ < 400) { + val seat = GolfRules.currentSeat(state) ?: break + state = GolfRules.applyMove(state, seat, GolfRules.legalMoves(state, seat).first()) + } + assertEquals(GolfPhase.HOLE_OVER, state.phase) + val afterFirst = state.scores.toList() + + state = GolfRules.nextHole(state) + assertEquals(1, state.hole) + assertEquals("running totals carry over", afterFirst, state.scores) + } + + // -- Full games --------------------------------------------------------- + + @Test + fun `random legal play always finishes`() { + repeat(15) { iteration -> + val random = Random(iteration.toLong()) + var state = GolfRules.initialState( + config(GolfOptions(playerCount = 3, gridSize = 6, holes = 2), seed = iteration.toLong()) + ) + var guard = 0 + while (!GolfRules.isFinished(state) && guard++ < 8000) { + val seat = GolfRules.currentSeat(state) + if (seat == null) { + state = GolfRules.nextHole(state) + continue + } + val legal = GolfRules.legalMoves(state, seat) + assertTrue("seat $seat had no legal move in ${state.phase}", legal.isNotEmpty()) + state = GolfRules.applyMove(state, seat, legal[random.nextInt(legal.size)]) + } + assertTrue("game $iteration did not finish", GolfRules.isFinished(state)) + } + } + + @Test + fun `ai play always finishes and beats random play on average`() { + val ai = GolfAi() + var aiTotal = 0 + var randomTotal = 0 + val games = 12 + + repeat(games) { iteration -> + val random = Random(9000L + iteration) + var state = GolfRules.initialState( + config(GolfOptions(playerCount = 2, gridSize = 6, holes = 3), seed = 700L + iteration) + ) + var guard = 0 + while (!GolfRules.isFinished(state) && guard++ < 8000) { + val seat = GolfRules.currentSeat(state) + if (seat == null) { + state = GolfRules.nextHole(state) + continue + } + val legal = GolfRules.legalMoves(state, seat) + // Seat 0 plays properly, seat 1 flails. + val move = if (seat == 0) { + ai.chooseMove(state, seat, legal) + } else { + legal[random.nextInt(legal.size)] + } + assertTrue("ai returned an illegal move $move", legal.contains(move)) + state = GolfRules.applyMove(state, seat, move) + } + assertTrue(GolfRules.isFinished(state)) + aiTotal += state.scores[0] + randomTotal += state.scores[1] + } + + // Golf is won by the lowest score, so the AI should be under random. + assertTrue( + "ai scored $aiTotal against random's $randomTotal — no better than flailing", + aiTotal < randomTotal, + ) + } + + // -- Redaction and wire format ----------------------------------------- + + @Test + fun `a view hides face-down cards including your own`() { + val state = GolfRules.initialState(config(GolfOptions(playerCount = 2, gridSize = 6))) + val view = GolfRules.viewFor(state, seat = 0) + + for (index in 0 until 6) { + if (state.revealed[0][index]) { + assertEquals(state.grids[0][index], view.grids[0][index]) + } else { + assertEquals("face-down cards are masked", HIDDEN_CARD, view.grids[0][index]) + } + } + assertTrue("the stock order is hidden too", view.stock.isEmpty()) + } + + @Test + fun `masking does not leak the real card`() { + // A grid deliberately built with no twos of spades in it, so if the + // mask ever showed through it would be obvious. + val state = GolfRules.initialState(config(GolfOptions(playerCount = 2, gridSize = 6), seed = 42L)) + val view = GolfRules.viewFor(state, seat = 1) + val hiddenIndices = (0 until 6).filter { !state.revealed[1][it] } + hiddenIndices.forEach { index -> + val real = state.grids[1][index] + if (real != HIDDEN_CARD) { + assertNotEquals("the real card must not survive redaction", real, view.grids[1][index]) + } + } + } + + @Test + fun `state and moves survive a round trip`() { + val state = GolfRules.initialState(config()) + assertEquals(state, GolfRules.decodeState(GolfRules.encodeState(state))) + listOf(DrawFromStock, DrawFromDiscard, ReplaceCard(2), DiscardAndFlip(5)).forEach { + assertEquals(it, GolfRules.decodeMove(GolfRules.encodeMove(it))) + } + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/president/PresidentRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/president/PresidentRulesTest.kt new file mode 100644 index 0000000..11b778d --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/president/PresidentRulesTest.kt @@ -0,0 +1,334 @@ +package org.prolibertate.games.game.president + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +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 kotlin.random.Random + +class PresidentRulesTest { + + private val json = Json { encodeDefaults = true } + + private fun config( + options: PresidentOptions = PresidentOptions(), + seed: Long = 11L, + ) = TableConfig( + gameId = "president", + seats = (0 until options.playerCount).map { + PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) + }, + optionsJson = json.encodeToString(options), + seed = seed, + ) + + // -- Ranking ------------------------------------------------------------ + + @Test + fun `threes are low and twos are high`() { + assertTrue(presidentRank(Card(Rank.THREE, Suit.CLUBS)) < presidentRank(Card(Rank.TEN, Suit.CLUBS))) + assertTrue(presidentRank(Card(Rank.KING, Suit.CLUBS)) < presidentRank(Card(Rank.ACE, Suit.CLUBS))) + assertTrue( + "a two outranks an ace", + presidentRank(Card(Rank.TWO, Suit.CLUBS)) > presidentRank(Card(Rank.ACE, Suit.CLUBS)), + ) + } + + // -- Dealing ------------------------------------------------------------ + + @Test + fun `the whole deck is dealt out`() { + val state = PresidentRules.initialState(config()) + assertEquals(52, state.hands.sumOf { it.size }) + assertEquals(52, state.hands.flatten().distinct().size) + assertTrue("hands differ by at most one card", + state.hands.maxOf { it.size } - state.hands.minOf { it.size } <= 1) + } + + @Test + fun `the lowest card leads the first round`() { + val state = PresidentRules.initialState(config()) + val lowestHolder = state.hands.indices.minByOrNull { seat -> + state.hands[seat].minOf { presidentRank(it) } + } + assertEquals(lowestHolder, state.turn) + } + + // -- Leading and following ---------------------------------------------- + + private fun playingState( + hands: List>, + setSize: Int = 0, + setRank: Int = 0, + turn: Int = 0, + options: PresidentOptions = PresidentOptions(playerCount = 4), + pile: List = emptyList(), + ) = PresidentState( + options = options, + seed = 1L, + roundNumber = 0, + hands = hands, + handCounts = hands.map { it.size }, + pile = pile, + setSize = setSize, + setRank = setRank, + turn = turn, + passed = List(options.playerCount) { false }, + finishedOrder = emptyList(), + previousFinish = List(options.playerCount) { -1 }, + scores = List(options.playerCount) { 0 }, + phase = PresidentPhase.PLAYING, + log = emptyList(), + ) + + private fun hand(vararg cards: Pair) = cards.map { Card(it.first, it.second) } + + @Test + fun `leading offers every set size of every rank held`() { + val h = hand( + Rank.FIVE to Suit.CLUBS, Rank.FIVE to Suit.HEARTS, Rank.FIVE to Suit.SPADES, + Rank.NINE to Suit.CLUBS, + ) + val state = playingState(listOf(h, emptyList(), emptyList(), emptyList())) + val plays = PresidentRules.legalMoves(state, 0).filterIsInstance() + assertEquals("singles, pairs and triples of fives, plus the nine", 4, plays.size) + assertTrue(plays.any { it.cards.size == 3 }) + assertFalse("nothing to pass on when leading", + PresidentRules.legalMoves(state, 0).contains(PassTurn)) + } + + @Test + fun `following must match the set size and beat the rank`() { + val h = hand( + Rank.FOUR to Suit.CLUBS, Rank.FOUR to Suit.HEARTS, + Rank.KING to Suit.CLUBS, Rank.KING to Suit.HEARTS, + Rank.SEVEN to Suit.SPADES, + ) + val state = playingState( + hands = listOf(h, emptyList(), emptyList(), emptyList()), + setSize = 2, + setRank = presidentRank(Card(Rank.NINE, Suit.CLUBS)), + ) + val plays = PresidentRules.legalMoves(state, 0).filterIsInstance() + assertTrue("only the kings beat a pair of nines", plays.all { it.cards.first().rank == Rank.KING }) + assertTrue(plays.all { it.cards.size == 2 }) + assertTrue("passing is allowed", PresidentRules.legalMoves(state, 0).contains(PassTurn)) + } + + @Test + fun `a two beats anything and clears the pile`() { + val h = hand(Rank.TWO to Suit.CLUBS, Rank.FOUR to Suit.HEARTS) + var state = playingState( + hands = listOf(h, hand(Rank.SIX to Suit.CLUBS), emptyList(), emptyList()), + setSize = 1, + setRank = presidentRank(Card(Rank.ACE, Suit.SPADES)), + pile = hand(Rank.ACE to Suit.SPADES), + ) + val two = PlayCards(hand(Rank.TWO to Suit.CLUBS)) + assertTrue(PresidentRules.legalMoves(state, 0).contains(two)) + + state = PresidentRules.applyMove(state, 0, two) + assertEquals("pile is cleared", 0, state.setSize) + assertTrue(state.pile.isEmpty()) + assertEquals("and the same player leads again", 0, state.turn) + } + + @Test + fun `with twos clear disabled a two is just the highest card`() { + val options = PresidentOptions(playerCount = 4, twosClear = false) + val h = hand(Rank.TWO to Suit.CLUBS) + var state = playingState( + hands = listOf(h, hand(Rank.SIX to Suit.CLUBS), emptyList(), emptyList()), + setSize = 1, + setRank = presidentRank(Card(Rank.ACE, Suit.SPADES)), + options = options, + pile = hand(Rank.ACE to Suit.SPADES), + ) + state = PresidentRules.applyMove(state, 0, PlayCards(hand(Rank.TWO to Suit.CLUBS))) + assertEquals("the pile stands", 1, state.setSize) + } + + @Test + fun `when everyone else passes the pile is taken down`() { + val hands = listOf( + hand(Rank.FIVE to Suit.CLUBS, Rank.SIX to Suit.CLUBS), + hand(Rank.FOUR to Suit.HEARTS), + hand(Rank.THREE to Suit.SPADES), + ) + val options = PresidentOptions(playerCount = 3) + var state = playingState(hands = hands, options = options) + + state = PresidentRules.applyMove(state, 0, PlayCards(hand(Rank.FIVE to Suit.CLUBS))) + assertEquals(1, state.turn) + state = PresidentRules.applyMove(state, 1, PassTurn) + state = PresidentRules.applyMove(state, 2, PassTurn) + + assertEquals("pile cleared", 0, state.setSize) + assertEquals("the last player to play leads again", 0, state.turn) + assertTrue(state.passed.none { it }) + } + + // -- Going out and scoring ---------------------------------------------- + + @Test + fun `going out records a finishing position`() { + val hands = listOf( + hand(Rank.FIVE to Suit.CLUBS), + hand(Rank.SIX to Suit.HEARTS, Rank.SEVEN to Suit.HEARTS), + hand(Rank.EIGHT to Suit.SPADES, Rank.NINE to Suit.SPADES), + ) + val options = PresidentOptions(playerCount = 3) + var state = playingState(hands = hands, options = options) + state = PresidentRules.applyMove(state, 0, PlayCards(hand(Rank.FIVE to Suit.CLUBS))) + assertEquals(listOf(0), state.finishedOrder) + assertTrue(state.isOut(0)) + } + + @Test + fun `a round ends when only one player still holds cards`() { + val options = PresidentOptions(playerCount = 3, roundsToPlay = 2) + var state = playingState( + hands = listOf( + hand(Rank.FIVE to Suit.CLUBS), + hand(Rank.SIX to Suit.HEARTS), + hand(Rank.EIGHT to Suit.SPADES, Rank.NINE to Suit.SPADES), + ), + options = options, + ) + state = PresidentRules.applyMove(state, 0, PlayCards(hand(Rank.FIVE to Suit.CLUBS))) + state = PresidentRules.applyMove(state, 1, PlayCards(hand(Rank.SIX to Suit.HEARTS))) + + assertEquals(PresidentPhase.ROUND_OVER, state.phase) + assertEquals("the straggler takes last place", listOf(0, 1, 2), state.finishedOrder) + assertEquals("President scores most", 2, state.scores[0]) + assertEquals(1, state.scores[1]) + assertEquals(0, state.scores[2]) + } + + @Test + fun `the last round ends the game`() { + val options = PresidentOptions(playerCount = 3, roundsToPlay = 1) + var state = playingState( + hands = listOf( + hand(Rank.FIVE to Suit.CLUBS), + hand(Rank.SIX to Suit.HEARTS), + hand(Rank.EIGHT to Suit.SPADES, Rank.NINE to Suit.SPADES), + ), + options = options, + ) + state = PresidentRules.applyMove(state, 0, PlayCards(hand(Rank.FIVE to Suit.CLUBS))) + state = PresidentRules.applyMove(state, 1, PlayCards(hand(Rank.SIX to Suit.HEARTS))) + assertTrue(PresidentRules.isFinished(state)) + } + + @Test + fun `titles run from President down to Scum`() { + assertEquals("President", titleFor(0, 4)) + assertEquals("Vice President", titleFor(1, 4)) + assertEquals("Vice Scum", titleFor(2, 4)) + assertEquals("Scum", titleFor(3, 4)) + assertEquals("Citizen", titleFor(2, 6)) + } + + // -- Full games --------------------------------------------------------- + + @Test + fun `random legal play always reaches a winner`() { + repeat(20) { iteration -> + val random = Random(iteration.toLong()) + var state = PresidentRules.initialState( + config(PresidentOptions(playerCount = 4, roundsToPlay = 3), seed = iteration.toLong()) + ) + var guard = 0 + while (!PresidentRules.isFinished(state) && guard++ < 6000) { + val seat = PresidentRules.currentSeat(state) + if (seat == null) { + state = PresidentRules.nextRound(state) + continue + } + val legal = PresidentRules.legalMoves(state, seat) + assertTrue("seat $seat had no legal move", legal.isNotEmpty()) + state = PresidentRules.applyMove(state, seat, legal[random.nextInt(legal.size)]) + } + assertTrue("game $iteration did not finish", PresidentRules.isFinished(state)) + } + } + + @Test + fun `ai play always reaches a winner`() { + val ai = PresidentAi() + repeat(10) { iteration -> + var state = PresidentRules.initialState( + config(PresidentOptions(playerCount = 5, roundsToPlay = 2), seed = 400L + iteration) + ) + var guard = 0 + while (!PresidentRules.isFinished(state) && guard++ < 6000) { + val seat = PresidentRules.currentSeat(state) + if (seat == null) { + state = PresidentRules.nextRound(state) + continue + } + val legal = PresidentRules.legalMoves(state, seat) + val move = ai.chooseMove(state, seat, legal) + assertTrue("ai returned an illegal move $move", legal.contains(move)) + state = PresidentRules.applyMove(state, seat, move) + } + assertTrue("ai game did not finish", PresidentRules.isFinished(state)) + } + } + + @Test + fun `card exchange moves the best cards to the president`() { + val options = PresidentOptions(playerCount = 4, roundsToPlay = 3) + var state = PresidentRules.initialState(config(options, seed = 5L)) + var guard = 0 + while (state.phase == PresidentPhase.PLAYING && guard++ < 6000) { + val seat = PresidentRules.currentSeat(state)!! + val legal = PresidentRules.legalMoves(state, seat) + state = PresidentRules.applyMove(state, seat, legal.first()) + } + assertEquals(PresidentPhase.ROUND_OVER, state.phase) + + val president = state.finishedOrder.first() + val scum = state.finishedOrder.last() + val next = PresidentRules.nextRound(state) + + assertEquals("still a full deck", 52, next.hands.sumOf { it.size }) + assertEquals("the scum leads the new round", scum, next.turn) + assertTrue( + "the president holds a high card after the swap", + next.hands[president].maxOf { presidentRank(it) } >= 13, + ) + } + + // -- Redaction and wire format ----------------------------------------- + + @Test + fun `a seat view hides every other hand`() { + val state = PresidentRules.initialState(config()) + val view = PresidentRules.viewFor(state, seat = 1) + assertEquals(state.hands[1], view.hands[1]) + assertTrue(view.hands.filterIndexed { i, _ -> i != 1 }.all { it.isEmpty() }) + assertEquals(state.handCounts, view.handCounts) + } + + @Test + fun `state and moves survive a round trip`() { + val state = PresidentRules.initialState(config()) + assertEquals(state, PresidentRules.decodeState(PresidentRules.encodeState(state))) + listOf( + PassTurn, + PlayCards(hand(Rank.FIVE to Suit.CLUBS, Rank.FIVE to Suit.HEARTS)), + ).forEach { + assertEquals(it, PresidentRules.decodeMove(PresidentRules.encodeMove(it))) + } + } +} diff --git a/docs/RULES-golf.md b/docs/RULES-golf.md new file mode 100644 index 0000000..bb731eb --- /dev/null +++ b/docs/RULES-golf.md @@ -0,0 +1,68 @@ +# Golf — implemented rules + +This is the ruleset the engine enforces. Anything marked **toggle** is in the +game setup screen. Lowest score wins, as on a golf course. + +## Table + +- 2 to 6 players — **toggle**. +- Each player gets a grid of face-down cards — **toggle**: + + | Cards | Layout | + | --- | --- | + | 4 | 2 rows of 2 | + | 6 | 2 rows of 3 | + | 9 | 3 rows of 3 | + +- **Seen at the start — *toggle*.** How many of your own cards you may look at + before play begins; two by default. They are the leftmost cards of the top + row, so everyone starts with the same shape of knowledge. +- One card is turned up to start the discard pile; the rest is the stock. +- Six players on the 9-card board needs more cards than a deck holds, so two + decks are shuffled together whenever the deal would exceed 24 cards. + +## A turn + +**Draw** one card, from either the stock or the top of the discard pile. + +Then **place** it, in one of two ways: + +- **Swap it in.** Put it into any grid slot, face up, and throw out whatever was + there. That slot is now face up for the rest of the hole. +- **Throw it away and turn one of your own over.** Only available for a card + taken from the *stock* — a card picked up off the discard pile has to be used. + +## Ending a hole + +The first player to have their whole grid face up closes the hole, and everyone +else gets exactly one more turn. Then every card is turned up and scored. + +If the stock runs out it is refilled by shuffling the discard pile, keeping its +top card in play. If there is nothing left to recycle, the hole ends there. + +## Scoring + +| Card | Value | +| --- | --- | +| King | 0 | +| Ace | 1 | +| Two | −2 | +| Jack, Queen | 10 | +| 3 to 10 | face value | + +**A column of matching ranks cancels to nothing.** That is the whole tactical +point: a pair of jacks in a column costs nothing instead of twenty. + +On the 3×3 board, matching **rows** cancel as well. On the shallower boards they +do not — a "row" of three unrelated cards is not a line in a two-row grid. + +Scores accumulate over the holes — **toggle** for 3, 6 or 9 — and the lowest +total wins. + +## Known simplifications + +- **No jokers.** Some house rules add them as −5 or wild. Adding a joker means + adding a rank to the shared card model, which every other game would then have + to account for, so it is left out rather than bolted on. +- No knocking, and no separate penalty for a failed knock. +- Nothing is peeked at mid-hole; the only way to learn a card is to turn it over. diff --git a/docs/RULES-president.md b/docs/RULES-president.md new file mode 100644 index 0000000..281f479 --- /dev/null +++ b/docs/RULES-president.md @@ -0,0 +1,69 @@ +# President — implemented rules + +Also called Scum, Landlord, and a good deal worse. This is the ruleset the +engine enforces. Anything marked **toggle** is in the game setup screen. + +## Table + +- 3 to 7 players — **toggle**. Everyone plays for themselves. +- The whole 52-card deck is dealt out round-robin, so hands differ by at most + one card. There is no kitty and nothing is held back. + +## Ranking + +Threes are low and twos are high: + +``` +3 4 5 6 7 8 9 10 J Q K A 2 +``` + +Suits are irrelevant. A card only ever competes on rank. + +## Play + +The player holding the lowest card leads the first round; afterwards the +previous round's Scum leads. + +- **Leading.** Play any number of cards of the same rank — a single, a pair, a + triple, four of a kind. That count sets the size of the set for this pile. +- **Following.** Play the same number of cards, all of one rank, of a strictly + higher rank. Or pass. +- Passing does not put you out of the round, only out of this pile. +- When everyone still holding cards has passed, the pile is taken down and + whoever played last leads a fresh one. + +**Twos clear the pile — *toggle*, on by default.** A set of twos beats anything +regardless of rank, takes the pile down, and the player who threw it leads +again. + +**Four of a kind bombs — *toggle*, off by default.** Four matching cards beat +anything, whatever is on the pile, and clear it the same way. + +## Going out + +Shed your last card and you are out, taking the next free finishing position: +President first, then Vice President, down through the Citizens to Vice Scum and +Scum. The round ends when only one player still holds cards — they are the Scum. + +Going out on a pile-clearing set does not let you lead the next pile; the lead +passes to the next player still in. + +## Scoring + +Finishing position scores `playerCount - 1 - position`, so in a four-hand game +the President takes 3, the Vice President 2, Vice Scum 1 and the Scum nothing. +Highest total after the last round wins — **toggle** for 3, 5 or 7 rounds. + +## Card exchange — *toggle*, on by default + +Between rounds the Scum hands their two best cards to the President, and the +President hands two of their worst back. + +**Simplification:** the President's return is automatic — their two lowest +cards. The fuller rule lets the President choose which two to give back, which +would need a phase of its own; that is not implemented. + +## Known simplifications + +- No eights-stop, no revolution, no jokers, no suit-based tie-breaks. +- The exchange is always two cards, rather than scaling with position.