From 532c3ea48af011df49186fe960ad41ad03c55734 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 03:25:09 +0000 Subject: [PATCH 1/5] 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. From d989907d7e9b082e66e8dea7dcf6520e06b4a58c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 03:59:53 +0000 Subject: [PATCH 2/5] Golf: 8-card grids, chosen openers, final putt, end-of-hole score card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - An 8-card variant, laid out as two rows of four. The existing column scoring covers it without change. - Opening cards are now chosen rather than dealt face up. Nothing starts revealed; each player in turn turns over their allowance, and the hole begins once everyone has picked. This needed a new SETUP phase in the engine, since it is a real decision and has to be validated like any other move rather than being decided at deal time. - "Line up the final putt", off by default: throw a drawn card away without turning your last face-down card over, so you are not forced to close the hole on a card you did not choose. Only offered on the last face-down card. Because a whole table could otherwise decline forever, two full laps of nothing but thrown cards calls the hole and scores it where it stands. - The end of a hole now shows a score card — every player's strokes for the hole and their running total, lowest first — and the next hole is not dealt until it is dismissed. MatchController gained a general awaitsConfirmation hook for this, so any game wanting a scoreboard between rounds can stop the table the same way. Only the host waits; clients render what they are sent. Nine more Golf tests, covering the 8-card layout, the opening choice, when the putt option is and is not offered, and that a table refusing to putt out still finishes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN --- .../prolibertate/games/game/golf/GolfAi.kt | 38 +++- .../prolibertate/games/game/golf/GolfModel.kt | 44 ++++- .../prolibertate/games/game/golf/GolfRules.kt | 73 +++++++- .../prolibertate/games/net/MatchController.kt | 29 ++++ .../prolibertate/games/ui/GameSetupScreen.kt | 9 +- .../org/prolibertate/games/ui/PlayScreen.kt | 2 + .../prolibertate/games/ui/game/GolfScreen.kt | 130 +++++++++++--- .../games/game/golf/GolfRulesTest.kt | 162 ++++++++++++++++-- docs/RULES-golf.md | 23 ++- 9 files changed, 458 insertions(+), 52 deletions(-) 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 index c975297..02cf85e 100644 --- a/app/src/main/java/org/prolibertate/games/game/golf/GolfAi.kt +++ b/app/src/main/java/org/prolibertate/games/game/golf/GolfAi.kt @@ -16,21 +16,43 @@ class GolfAi(private val difficulty: Difficulty = Difficulty.NORMAL) : enum class Difficulty( /** Take a discard worth this or less rather than gambling on the stock. */ val discardAppetite: Int, + /** Only close the hole out when the visible score is at or under this. */ + val closeOutBelow: Int, ) { - EASY(discardAppetite = 2), - NORMAL(discardAppetite = 5), - CAUTIOUS(discardAppetite = 7), + EASY(discardAppetite = 2, closeOutBelow = 30), + NORMAL(discardAppetite = 5, closeOutBelow = 16), + CAUTIOUS(discardAppetite = 7, closeOutBelow = 12), } override fun chooseMove(state: GolfState, seat: Int, legal: List): GolfMove { require(legal.isNotEmpty()) { "No legal move for seat $seat" } return when (state.phase) { + GolfPhase.SETUP -> chooseReveal(state, seat, legal) GolfPhase.DRAW -> chooseDraw(state, seat, legal) GolfPhase.PLACE -> choosePlace(state, seat, legal) else -> legal.first() } } + /** + * Opening reveals are spread across columns. Two cards in the same column + * tell you whether that column can be cancelled but nothing about the rest + * of the board; one in each column leaves more of the grid legible. + */ + private fun chooseReveal(state: GolfState, seat: Int, legal: List): GolfMove { + val reveals = legal.filterIsInstance() + if (reveals.isEmpty()) return legal.first() + val options = state.options + + val untouchedColumn = reveals.firstOrNull { move -> + val column = move.index % options.cols + (0 until options.rows).none { row -> + state.revealed[seat][row * options.cols + column] + } + } + return untouchedColumn ?: reveals.first() + } + private fun chooseDraw(state: GolfState, seat: Int, legal: List): GolfMove { val top = state.discard.lastOrNull() if (top != null && legal.contains(DrawFromDiscard)) { @@ -61,6 +83,16 @@ class GolfAi(private val difficulty: Difficulty = Difficulty.NORMAL) : if (golfValue(drawn) < currentValue) return knownWorst } + // On the last face-down card, turning it over ends the hole for + // everyone. Only do that from a position worth defending; otherwise + // line up the putt and wait for a better card. + if (legal.contains(DiscardOnly)) { + val showing = state.grids[seat].indices + .filter { state.revealed[seat][it] } + .sumOf { golfValue(state.grids[seat][it]) } + if (showing > difficulty.closeOutBelow) return DiscardOnly + } + // Nothing worth swapping: throw it and turn something over instead, // which at least buys information. if (flips.isNotEmpty()) return flips.first() 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 index 867407a..a437968 100644 --- a/app/src/main/java/org/prolibertate/games/game/golf/GolfModel.kt +++ b/app/src/main/java/org/prolibertate/games/game/golf/GolfModel.kt @@ -9,16 +9,22 @@ 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). */ + /** Cards in front of each player: 4 (2x2), 6 (2x3), 8 (2x4) or 9 (3x3). */ val gridSize: Int = 6, val holes: Int = 9, - /** How many of your own cards you get to see before play starts. */ + /** How many of your own cards you turn over before play starts. */ val startingReveals: Int = 2, + /** + * Lining up the final putt: lets you throw a drawn card away without + * turning your last face-down card over, so you are not forced to close + * the hole on a card you have not chosen. + */ + val lineUpFinalPutt: Boolean = false, ) { 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(gridSize in setOf(4, 6, 8, 9)) { + "Golf grids are 4, 6, 8 or 9 cards" } require(holes >= 1) { "holes must be positive" } require(startingReveals in 0..gridSize) { "cannot reveal more than the grid holds" } @@ -82,7 +88,14 @@ fun scoreGrid(cards: List, options: GolfOptions): Int { val HIDDEN_CARD: Card = Card(Rank.TWO, Suit.SPADES) @Serializable -enum class GolfPhase { DRAW, PLACE, HOLE_OVER, GAME_OVER } +enum class GolfPhase { + /** Each player choosing which of their own cards to turn over first. */ + SETUP, + DRAW, + PLACE, + HOLE_OVER, + GAME_OVER, +} @Serializable data class GolfState( @@ -105,9 +118,17 @@ data class GolfState( val holeScores: List, val scores: List, val phase: GolfPhase, + /** + * Consecutive turns where nobody did anything but throw a card away. + * Only reachable with [GolfOptions.lineUpFinalPutt] on, and only there to + * stop a table of players all declining to close from playing forever. + */ + val idleDiscards: Int = 0, val log: List, ) { fun allRevealed(seat: Int): Boolean = revealed[seat].all { it } + + fun faceDownCount(seat: Int): Int = revealed[seat].count { !it } } @Serializable @@ -130,3 +151,16 @@ data class ReplaceCard(val index: Int) : GolfMove @Serializable @SerialName("discard_flip") data class DiscardAndFlip(val index: Int) : GolfMove + +/** + * Throw the drawn card away and turn nothing over — lining up the final putt. + * Only offered when turning a card over would be turning over your last one. + */ +@Serializable +@SerialName("discard_only") +data object DiscardOnly : GolfMove + +/** Choosing one of your own cards to see before play starts. */ +@Serializable +@SerialName("reveal") +data class RevealCard(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 index ae2a983..9358e99 100644 --- a/app/src/main/java/org/prolibertate/games/game/golf/GolfRules.kt +++ b/app/src/main/java/org/prolibertate/games/game/golf/GolfRules.kt @@ -55,11 +55,9 @@ object GolfRules : GameRules { 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 } - } + // Nothing starts face up: each player chooses which of their own cards + // to turn over, which is a real decision rather than a dealt-out one. + val revealed = List(options.playerCount) { List(options.gridSize) { false } } val firstDiscard = deck.first() return GolfState( @@ -77,7 +75,8 @@ object GolfRules : GameRules { finalTurnsLeft = 0, holeScores = List(options.playerCount) { 0 }, scores = scores, - phase = GolfPhase.DRAW, + phase = if (options.startingReveals > 0) GolfPhase.SETUP else GolfPhase.DRAW, + idleDiscards = 0, log = log, ) } @@ -95,7 +94,7 @@ object GolfRules : GameRules { } override fun currentSeat(state: GolfState): Int? = when (state.phase) { - GolfPhase.DRAW, GolfPhase.PLACE -> state.turn + GolfPhase.SETUP, GolfPhase.DRAW, GolfPhase.PLACE -> state.turn GolfPhase.HOLE_OVER, GolfPhase.GAME_OVER -> null } @@ -106,6 +105,10 @@ object GolfRules : GameRules { override fun legalMoves(state: GolfState, seat: Int): List { if (currentSeat(state) != seat) return emptyList() return when (state.phase) { + GolfPhase.SETUP -> (0 until state.options.gridSize) + .filter { !state.revealed[seat][it] } + .map { RevealCard(it) } + GolfPhase.DRAW -> buildList { if (state.stock.isNotEmpty()) add(DrawFromStock) if (state.discard.isNotEmpty()) add(DrawFromDiscard) @@ -119,6 +122,11 @@ object GolfRules : GameRules { for (index in 0 until state.options.gridSize) { if (!state.revealed[seat][index]) add(DiscardAndFlip(index)) } + // Lining up the final putt: decline to turn over the last + // card, rather than being forced to close the hole with it. + if (state.options.lineUpFinalPutt && state.faceDownCount(seat) == 1) { + add(DiscardOnly) + } } } @@ -150,9 +158,36 @@ object GolfRules : GameRules { is ReplaceCard -> applyReplace(state, seat, move.index) is DiscardAndFlip -> applyDiscardAndFlip(state, seat, move.index) + is DiscardOnly -> applyDiscardOnly(state, seat) + is RevealCard -> applyReveal(state, seat, move.index) } } + /** + * Turning over one of your opening cards. When a seat has seen its + * allowance the choice passes on, and once everyone has chosen the hole + * begins. + */ + private fun applyReveal(state: GolfState, seat: Int, index: Int): GolfState { + val revealed = state.revealed.map { it.toMutableList() } + revealed[seat][index] = true + + val chosen = revealed[seat].count { it } + if (chosen < state.options.startingReveals) { + return state.copy(revealed = revealed) + } + + val next = (seat + 1) % state.options.playerCount + val everyoneReady = revealed.all { row -> + row.count { it } >= state.options.startingReveals + } + return state.copy( + revealed = revealed, + turn = if (everyoneReady) state.hole % state.options.playerCount else next, + phase = if (everyoneReady) GolfPhase.DRAW else GolfPhase.SETUP, + ) + } + private fun applyReplace(state: GolfState, seat: Int, index: Int): GolfState { val drawn = requireNotNull(state.drawn) { "Nothing drawn" } val grids = state.grids.map { it.toMutableList() } @@ -191,12 +226,32 @@ object GolfRules : GameRules { ) } + private fun applyDiscardOnly(state: GolfState, seat: Int): GolfState { + val drawn = requireNotNull(state.drawn) { "Nothing drawn" } + return endTurn( + state.copy( + discard = state.discard + drawn, + drawn = null, + idleDiscards = state.idleDiscards + 1, + log = state.log + "Seat $seat throws ${drawn.label} and lines up the putt.", + ), + seat, + idle = true, + ) + } + /** * 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 + private fun endTurn(state: GolfState, seat: Int, idle: Boolean = false): GolfState { + var next = if (idle) state else state.copy(idleDiscards = 0) + + // Everyone declining to close, round after round, would never end. Two + // full laps of nothing but thrown cards is taken as the hole being over. + if (next.idleDiscards >= next.options.playerCount * 2) { + return scoreHole(next.copy(log = next.log + "Nobody will putt out — hole called.")) + } // The first player to turn everything over gives everyone else one // more turn, and no more than one. diff --git a/app/src/main/java/org/prolibertate/games/net/MatchController.kt b/app/src/main/java/org/prolibertate/games/net/MatchController.kt index 7a1bc86..0bd5fe9 100644 --- a/app/src/main/java/org/prolibertate/games/net/MatchController.kt +++ b/app/src/main/java/org/prolibertate/games/net/MatchController.kt @@ -47,6 +47,12 @@ class MatchController( * next card lands on top of it. */ private val holdBeforeNextMove: (S) -> Long = { 0L }, + /** + * States that should not advance on their own — an end-of-round scoreboard + * the players need to read before the next deal. The controller stops and + * waits for [confirmAdvance]. + */ + private val awaitsConfirmation: (S) -> Boolean = { false }, ) { enum class Role { HOST, CLIENT } @@ -65,6 +71,11 @@ class MatchController( private val _notice = MutableStateFlow(null) val notice: StateFlow = _notice.asStateFlow() + private val _awaitingConfirmation = MutableStateFlow(false) + + /** True while a scoreboard is up and the table is waiting to be released. */ + val awaitingConfirmation: StateFlow = _awaitingConfirmation.asStateFlow() + /** Host only: the true state. Clients never see this. */ private var authoritative: S? = null private val lock = Mutex() @@ -198,6 +209,12 @@ class MatchController( val seat = rules.currentSeat(current) if (seat == null) { // Nobody on the clock: a hand has been scored, or the game ended. + if (role == Role.HOST && awaitsConfirmation(current)) { + // Hold here until a player has read the scoreboard. Only the + // host waits; clients simply render what they were sent. + _awaitingConfirmation.value = true + return + } delay(aiThinkingMillis() * 2) val advanced = advanceIdle(current) if (advanced == null) { @@ -261,6 +278,18 @@ class MatchController( } } + /** + * Releases a table stopped on a scoreboard. A no-op anywhere else, and on a + * client — where the host owns the decision — it does nothing but clear the + * local flag. + */ + fun confirmAdvance() { + if (!_awaitingConfirmation.value) return + _awaitingConfirmation.value = false + if (role != Role.HOST) return + scope.launch { driveIdleSeats() } + } + fun close() { peers.values.forEach { runCatching { it.close() } } peers.clear() 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 76467d0..6af6d44 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -263,7 +263,7 @@ private fun GolfOptionsEditor(options: GolfOptions, onChange: (GolfOptions) -> U ) ChipRow( label = "Cards each", - values = listOf(4, 6, 9), + values = listOf(4, 6, 8, 9), selected = options.gridSize, display = { "$it" }, onSelect = { size -> @@ -290,6 +290,13 @@ private fun GolfOptionsEditor(options: GolfOptions, onChange: (GolfOptions) -> U display = { "$it" }, onSelect = { onChange(options.copy(startingReveals = it)) }, ) + ToggleRow( + title = "Line up the final putt", + subtitle = "Throw a drawn card away without turning your last card over, " + + "rather than being forced to close the hole", + checked = options.lineUpFinalPutt, + onChange = { onChange(options.copy(lineUpFinalPutt = it)) }, + ) Text( text = "${options.rows} rows of ${options.cols}. Matching columns cancel out" + if (options.gridSize == 9) ", and so do matching rows." else ".", 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 f137aa7..e2097fe 100644 --- a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt @@ -145,6 +145,8 @@ fun PlayScreen( advanceIdle = { state -> if (state.phase == GolfPhase.HOLE_OVER) GolfRules.nextHole(state) else null }, + // The score card is read before the next hole is dealt. + awaitsConfirmation = { state -> state.phase == GolfPhase.HOLE_OVER }, aiThinkingMillis = { settings.scaled(700L) }, ) } 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 index ec3d220..1a19ba6 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/GolfScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/GolfScreen.kt @@ -15,7 +15,10 @@ 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.AlertDialog import androidx.compose.material3.FilterChip +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.TextButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -31,15 +34,16 @@ 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.DiscardOnly 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.RevealCard 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 @@ -109,21 +113,16 @@ fun GolfScreen( ) } } - } - - 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() } + // Only offered on your last face-down card, and only with + // the option switched on in setup. + if (legal.contains(DiscardOnly)) { + OutlinedButton( + onClick = { controller.submit(DiscardOnly) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Line up the putt — throw it, keep the last card down") + } } - - else -> Unit } OwnGrid( @@ -135,10 +134,86 @@ fun GolfScreen( onMove = { controller.submit(it); mode = PlaceMode.SWAP }, ) } + + // The card is read before the next one is dealt, so the scoreboard + // is a dialog the players dismiss rather than a line that flashes by. + if (current.phase == GolfPhase.HOLE_OVER || current.phase == GolfPhase.GAME_OVER) { + ScoreCardDialog( + state = current, + localSeat = localSeat, + onContinue = { controller.confirmAdvance() }, + onExit = onExit, + ) + } } } } +/** + * The card at the end of a hole: this hole's strokes and the running total for + * everyone, lowest first. + */ +@Composable +private fun ScoreCardDialog( + state: GolfState, + localSeat: Int, + onContinue: () -> Unit, + onExit: () -> Unit, +) { + var dismissed by remember(state.hole, state.phase) { mutableStateOf(false) } + if (dismissed) return + + val finalHole = state.phase == GolfPhase.GAME_OVER + val standings = (0 until state.options.playerCount).sortedBy { state.scores[it] } + + AlertDialog( + onDismissRequest = { }, + title = { + Text( + if (finalHole) "Final card" else "Hole ${state.hole + 1} of ${state.options.holes}" + ) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(modifier = Modifier.fillMaxWidth()) { + Text("Player", modifier = Modifier.weight(1f), fontWeight = FontWeight.Bold) + Text("Hole", fontWeight = FontWeight.Bold) + Text(" Total", fontWeight = FontWeight.Bold) + } + standings.forEach { seat -> + val you = seat == localSeat + Row(modifier = Modifier.fillMaxWidth()) { + Text( + text = if (you) "You" else "Seat $seat", + modifier = Modifier.weight(1f), + fontWeight = if (you) FontWeight.Bold else FontWeight.Normal, + ) + Text("${state.holeScores[seat]}") + Text(" ${state.scores[seat]}", + fontWeight = if (you) FontWeight.Bold else FontWeight.Normal) + } + } + Text( + text = if (finalHole) { + "Lowest total wins — seat ${standings.first()} takes it." + } else { + "Lowest total wins." + }, + style = MaterialTheme.typography.bodySmall, + ) + } + }, + confirmButton = { + TextButton(onClick = { + dismissed = true + if (finalHole) onExit() else onContinue() + }) { + Text(if (finalHole) "Back to the menu" else "Deal the next hole") + } + }, + ) +} + @Composable private fun ScoreLine(state: GolfState, localSeat: Int) { Row( @@ -226,10 +301,18 @@ private fun OwnGrid( val options = state.options val replacements = legal.filterIsInstance().map { it.index }.toSet() val flips = legal.filterIsInstance().map { it.index }.toSet() + val reveals = legal.filterIsInstance().map { it.index }.toSet() Column { Text( text = when { + state.phase == GolfPhase.SETUP && reveals.isNotEmpty() -> { + val left = state.options.startingReveals - state.revealed[localSeat].count { it } + "Choose $left more card(s) to look at" + } + + state.phase == GolfPhase.SETUP -> "Waiting for the others to choose…" + state.phase == GolfPhase.PLACE && mode == PlaceMode.SWAP -> "Tap a card to swap it out" @@ -244,9 +327,13 @@ private fun OwnGrid( 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 + val tappable = if (state.phase == GolfPhase.SETUP) { + index in reveals + } else { + when (mode) { + PlaceMode.SWAP -> index in replacements + PlaceMode.THROW -> index in flips + } } GridCard( card = state.grids[localSeat].getOrNull(index), @@ -254,9 +341,10 @@ private fun OwnGrid( width = cardWidth, tappable = tappable, onClick = { - when (mode) { - PlaceMode.SWAP -> onMove(ReplaceCard(index)) - PlaceMode.THROW -> onMove(DiscardAndFlip(index)) + when { + state.phase == GolfPhase.SETUP -> onMove(RevealCard(index)) + mode == PlaceMode.SWAP -> onMove(ReplaceCard(index)) + else -> onMove(DiscardAndFlip(index)) } }, ) 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 index 77e9eef..a39fc80 100644 --- a/app/src/test/java/org/prolibertate/games/game/golf/GolfRulesTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/golf/GolfRulesTest.kt @@ -30,6 +30,17 @@ class GolfRulesTest { private fun card(rank: Rank, suit: Suit = Suit.CLUBS) = Card(rank, suit) + /** Plays out the opening reveals so a test can start from the first draw. */ + private fun afterSetup(state: GolfState): GolfState { + var s = state + var guard = 0 + while (s.phase == GolfPhase.SETUP && guard++ < 200) { + val seat = GolfRules.currentSeat(s)!! + s = GolfRules.applyMove(s, seat, GolfRules.legalMoves(s, seat).first()) + } + return s + } + // -- Scoring ------------------------------------------------------------ @Test @@ -98,7 +109,9 @@ class GolfRulesTest { @Test fun `everyone gets a grid and two cards face up`() { - val state = GolfRules.initialState(config(GolfOptions(playerCount = 3, gridSize = 6))) + val state = afterSetup( + 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 }) } @@ -114,11 +127,133 @@ class GolfRulesTest { assertEquals(104, dealt + state.stock.size + state.discard.size) } + @Test + fun `the eight card grid is two rows of four`() { + val options = GolfOptions(gridSize = 8) + assertEquals(2, options.rows) + assertEquals(4, options.cols) + + // Columns are (0,4) (1,5) (2,6) (3,7); the queens cancel. + val grid = listOf( + card(Rank.QUEEN), card(Rank.THREE), card(Rank.FOUR), card(Rank.KING), + card(Rank.QUEEN, Suit.HEARTS), card(Rank.FIVE), card(Rank.SIX), card(Rank.SEVEN), + ) + assertEquals(25, scoreGrid(grid, options)) + } + + // -- Choosing the opening cards ----------------------------------------- + + @Test + fun `a hole starts with everything face down and a choice to make`() { + val state = GolfRules.initialState(config(GolfOptions(playerCount = 3, gridSize = 6))) + assertEquals(GolfPhase.SETUP, state.phase) + assertTrue("nothing is dealt face up", state.revealed.all { row -> row.none { it } }) + assertEquals("every card is a candidate", 6, GolfRules.legalMoves(state, state.turn).size) + } + + @Test + fun `each player picks their own openers in turn`() { + var state = GolfRules.initialState(config(GolfOptions(playerCount = 2, gridSize = 6))) + val first = state.turn + + state = GolfRules.applyMove(state, first, RevealCard(3)) + assertTrue("the chosen card is the one turned over", state.revealed[first][3]) + assertEquals("still choosing", first, state.turn) + + state = GolfRules.applyMove(state, first, RevealCard(5)) + assertEquals("allowance used, next player chooses", (first + 1) % 2, state.turn) + assertEquals(GolfPhase.SETUP, state.phase) + + val second = state.turn + state = GolfRules.applyMove(state, second, RevealCard(0)) + state = GolfRules.applyMove(state, second, RevealCard(1)) + assertEquals("everyone has chosen, so play begins", GolfPhase.DRAW, state.phase) + } + + @Test + fun `with no opening reveals the hole starts straight away`() { + val state = GolfRules.initialState( + config(GolfOptions(playerCount = 2, gridSize = 4, startingReveals = 0)) + ) + assertEquals(GolfPhase.DRAW, state.phase) + } + + // -- Lining up the final putt ------------------------------------------- + + @Test + fun `lining up the putt is only offered on the last face-down card`() { + val options = GolfOptions( + playerCount = 2, gridSize = 4, startingReveals = 2, + holes = 1, lineUpFinalPutt = true, + ) + var state = afterSetup(GolfRules.initialState(config(options))) + state = GolfRules.applyMove(state, 0, DrawFromStock) + + assertEquals("two cards still face down", 2, state.faceDownCount(0)) + assertFalse("not yet — this is not the final putt", + GolfRules.legalMoves(state, 0).contains(DiscardOnly)) + + // Turn one over, leaving exactly one down, and come back round. + state = GolfRules.applyMove(state, 0, DiscardAndFlip(2)) + state = GolfRules.applyMove(state, 1, DrawFromStock) + state = GolfRules.applyMove(state, 1, ReplaceCard(0)) + state = GolfRules.applyMove(state, 0, DrawFromStock) + + assertEquals(1, state.faceDownCount(0)) + assertTrue("now it is offered", GolfRules.legalMoves(state, 0).contains(DiscardOnly)) + } + + @Test + fun `lining up the putt keeps the last card down and does not close the hole`() { + val options = GolfOptions( + playerCount = 2, gridSize = 4, startingReveals = 3, + holes = 1, lineUpFinalPutt = true, + ) + var state = afterSetup(GolfRules.initialState(config(options))) + state = GolfRules.applyMove(state, 0, DrawFromStock) + val drawn = state.drawn!! + + state = GolfRules.applyMove(state, 0, DiscardOnly) + assertEquals("the card is thrown", drawn, state.discard.last()) + assertEquals("the last card stays down", 1, state.faceDownCount(0)) + assertEquals("nobody has closed", null, state.closedBy) + assertEquals("turn passes anyway", 1, state.turn) + } + + @Test + fun `the option is off by default so nobody can stall`() { + val options = GolfOptions(playerCount = 2, gridSize = 4, startingReveals = 3, holes = 1) + var state = afterSetup(GolfRules.initialState(config(options))) + state = GolfRules.applyMove(state, 0, DrawFromStock) + assertFalse(GolfRules.legalMoves(state, 0).contains(DiscardOnly)) + } + + @Test + fun `a table that refuses to putt out still ends`() { + // Everyone declines forever; the hole is called rather than hanging. + val options = GolfOptions( + playerCount = 2, gridSize = 4, startingReveals = 3, + holes = 1, lineUpFinalPutt = true, + ) + var state = afterSetup(GolfRules.initialState(config(options))) + var guard = 0 + while (!GolfRules.isFinished(state) && guard++ < 200) { + val seat = GolfRules.currentSeat(state) ?: break + val legal = GolfRules.legalMoves(state, seat) + // Always draw, and always decline to close when allowed. + val move = legal.firstOrNull { it == DiscardOnly } + ?: legal.firstOrNull { it == DrawFromStock } + ?: legal.first() + state = GolfRules.applyMove(state, seat, move) + } + assertTrue("the stalemate guard has to end it", GolfRules.isFinished(state)) + } + // -- Turn structure ----------------------------------------------------- @Test fun `a turn is draw then place`() { - var state = GolfRules.initialState(config(GolfOptions(playerCount = 2))) + var state = afterSetup(GolfRules.initialState(config(GolfOptions(playerCount = 2)))) assertEquals(setOf(DrawFromStock, DrawFromDiscard), GolfRules.legalMoves(state, 0).toSet()) state = GolfRules.applyMove(state, 0, DrawFromStock) @@ -132,7 +267,7 @@ class GolfRulesTest { @Test fun `a card taken from the discard pile must be used`() { - var state = GolfRules.initialState(config(GolfOptions(playerCount = 2))) + var state = afterSetup(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 }) @@ -141,7 +276,7 @@ class GolfRulesTest { @Test fun `replacing a card turns that slot face up and discards the old one`() { - var state = GolfRules.initialState(config(GolfOptions(playerCount = 2))) + var state = afterSetup(GolfRules.initialState(config(GolfOptions(playerCount = 2)))) state = GolfRules.applyMove(state, 0, DrawFromStock) val drawn = state.drawn!! val replacedCard = state.grids[0][5] @@ -156,7 +291,7 @@ class GolfRulesTest { @Test fun `throwing the drawn card turns one of your own over`() { - var state = GolfRules.initialState(config(GolfOptions(playerCount = 2))) + var state = afterSetup(GolfRules.initialState(config(GolfOptions(playerCount = 2)))) state = GolfRules.applyMove(state, 0, DrawFromStock) val drawn = state.drawn!! assertFalse(state.revealed[0][4]) @@ -171,7 +306,7 @@ class GolfRulesTest { @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)) + var state = afterSetup(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)) @@ -193,7 +328,7 @@ class GolfRulesTest { @Test fun `scores accumulate across holes`() { val options = GolfOptions(playerCount = 2, gridSize = 4, startingReveals = 4, holes = 3) - var state = GolfRules.initialState(config(options)) + var state = afterSetup(GolfRules.initialState(config(options))) var guard = 0 while (state.hole == 0 && !GolfRules.isFinished(state) && guard++ < 400) { val seat = GolfRules.currentSeat(state) ?: break @@ -276,7 +411,9 @@ class GolfRulesTest { @Test fun `a view hides face-down cards including your own`() { - val state = GolfRules.initialState(config(GolfOptions(playerCount = 2, gridSize = 6))) + val state = afterSetup( + GolfRules.initialState(config(GolfOptions(playerCount = 2, gridSize = 6))) + ) val view = GolfRules.viewFor(state, seat = 0) for (index in 0 until 6) { @@ -293,7 +430,9 @@ class GolfRulesTest { 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 state = afterSetup( + 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 -> @@ -308,7 +447,10 @@ class GolfRulesTest { 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 { + listOf( + DrawFromStock, DrawFromDiscard, ReplaceCard(2), DiscardAndFlip(5), + DiscardOnly, RevealCard(1), + ).forEach { assertEquals(it, GolfRules.decodeMove(GolfRules.encodeMove(it))) } } diff --git a/docs/RULES-golf.md b/docs/RULES-golf.md index bb731eb..c450fa9 100644 --- a/docs/RULES-golf.md +++ b/docs/RULES-golf.md @@ -12,11 +12,18 @@ game setup screen. Lowest score wins, as on a golf course. | --- | --- | | 4 | 2 rows of 2 | | 6 | 2 rows of 3 | + | 8 | 2 rows of 4 | | 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. +- **Seen at the start — *toggle*.** How many of your own cards you turn over + before play begins; two by default. + +## Choosing your openers + +Nothing is dealt face up. Each player in turn picks which of their own cards to +turn over, up to the allowance above, and play begins once everyone has chosen. +Spreading them across columns tells you more about the board than stacking two +in the same column. - 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. @@ -31,6 +38,12 @@ Then **place** it, in one of two ways: 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. +- **Line up the final putt — *toggle*, off by default.** Throw the card away and + turn *nothing* over. Only offered when you have exactly one card left face + down, so it is the difference between closing the hole on a card you have not + chosen and waiting for something better. If every player declines for two full + laps the hole is called and scored where it stands, so a table cannot stall + forever. ## Ending a hole @@ -59,6 +72,10 @@ 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. +At the end of each hole a score card shows every player's strokes for that hole +and their running total, lowest first. The next hole is not dealt until it is +dismissed. + ## Known simplifications - **No jokers.** Some house rules add them as −5 or wild. Adding a joker means From d30ea8656f1874eacdca8667289fe24f2a60bf11 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 04:00:16 +0000 Subject: [PATCH 3/5] Fix section ordering in the Golf rules The opening-cards section had been dropped into the middle of the table setup list, stranding the discard pile and deck-count bullets after it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN --- docs/RULES-golf.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/RULES-golf.md b/docs/RULES-golf.md index c450fa9..d94cf9a 100644 --- a/docs/RULES-golf.md +++ b/docs/RULES-golf.md @@ -17,16 +17,16 @@ game setup screen. Lowest score wins, as on a golf course. - **Seen at the start — *toggle*.** How many of your own cards you turn over before play begins; two by default. +- 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. ## Choosing your openers Nothing is dealt face up. Each player in turn picks which of their own cards to turn over, up to the allowance above, and play begins once everyone has chosen. Spreading them across columns tells you more about the board than stacking two -in the same column. -- 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. +in the same column does. ## A turn From 510fd40d86a14928dff8b20a9bdcb5628be27875 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 04:04:51 +0000 Subject: [PATCH 4/5] Rework the Wallace tartan from a reference, and set it on the bias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old sett was three flat stripes in roughly equal measure, which read as a generic red-and-black check rather than as Wallace. Rebuilt against a reference swatch. - Setts are now written as a half-sett and reflected about their pivots, the way a threadcount is actually published, so the data stays in the form it is quoted in instead of a flat list kept symmetric by hand. - Wallace is gold 6, black 6, red 36, black 24, red 36, black 6: a red ground at about two thirds, black bands alternating wide and narrow, and one thin gold overstripe per repeat. The reflection puts black on both sides of the gold line, which is what the reference shows. - Colours taken from the swatch: a brighter scarlet and a stronger gold than before. - Both the cloth and the icon are turned 45 degrees. The renderer draws well past the canvas edges so the corners stay covered once rotated. The icon vector is generated from the same numbers rather than written out by hand, so the icon and the splash cannot drift apart — changing the sett rebrands both. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN --- README.md | 22 ++-- .../org/prolibertate/games/ui/SplashScreen.kt | 2 + .../org/prolibertate/games/ui/theme/Tartan.kt | 122 +++++++++++------- .../res/drawable/ic_launcher_background.xml | 102 ++++++++------- app/src/main/res/mipmap/ic_launcher.xml | 76 ++++++++--- app/src/main/res/mipmap/ic_launcher_round.xml | 76 ++++++++--- 6 files changed, 266 insertions(+), 134 deletions(-) diff --git a/README.md b/README.md index 85c053d..fa0ea67 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ or against other people over Wi-Fi (LAN) or Bluetooth. ## What works today -The app shell is complete and two games are playable end to end: +The app shell is complete and four games are playable end to end: | | | | --- | --- | @@ -59,9 +59,9 @@ them. ## Branding -The launcher icon is a single square of the Wallace tartan, and the app opens on -a tartan splash carrying the name in a Celtic uncial hand for two seconds before -fading into the menu. +The launcher icon is a single square of the Wallace tartan set on the bias, and +the app opens on the same cloth carrying the name in a Celtic uncial hand for two +seconds before fading into the menu. The tartan is drawn, not an image: `ui/theme/Tartan.kt` lays the sett down as warp and then as weft at half opacity, which is what produces the blended @@ -70,12 +70,14 @@ drawable, so it stays sharp at every density and needs no PNGs. Two honest caveats: -- **The sett is an approximation.** `WallaceSett` reproduces what the Wallace - tartan reads as — a bold red and black check divided by a narrow gold - overstripe — but the registered threadcount could not be verified, so the - numbers were chosen to look right rather than to be authoritative. They are - plain data; replacing them with the registered counts rebrands the whole app - and needs no other change. +- **The sett is matched by eye, not to a registered threadcount.** + `WallaceSett` is written as a half-sett and reflected about its pivots, the way + a threadcount is published: gold 6, black 6, red 36, black 24, red 36, black 6. + That gives a red ground at about two thirds, black bands alternating wide and + narrow, and one thin gold overstripe per repeat flanked by black — matched + against a reference swatch rather than to the Scottish Register of Tartans. + Substituting registered counts is an edit to that one list; the icon is + generated from the same numbers, so both follow. - **The typeface is bundled under a licence.** Uncial Antiqua, © 2011 Brian J. Bonislawsky DBA Astigmatic, Reserved Font Name "Uncial Antiqua", used under the SIL Open Font License 1.1. The full licence is at diff --git a/app/src/main/java/org/prolibertate/games/ui/SplashScreen.kt b/app/src/main/java/org/prolibertate/games/ui/SplashScreen.kt index 4edff5c..e3f1ce8 100644 --- a/app/src/main/java/org/prolibertate/games/ui/SplashScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/SplashScreen.kt @@ -82,6 +82,8 @@ fun SplashScreen( TartanBackground( modifier = Modifier.fillMaxSize(), threadWidth = threadWidth, + // On the bias, matching the icon. + rotationDegrees = 45f, ) // A scrim keeps the lettering legible over the busiest part of the sett. diff --git a/app/src/main/java/org/prolibertate/games/ui/theme/Tartan.kt b/app/src/main/java/org/prolibertate/games/ui/theme/Tartan.kt index a5024ad..4b64261 100644 --- a/app/src/main/java/org/prolibertate/games/ui/theme/Tartan.kt +++ b/app/src/main/java/org/prolibertate/games/ui/theme/Tartan.kt @@ -7,77 +7,106 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp /** One band of a tartan sett, measured in threads. */ data class TartanStripe(val color: Color, val threads: Int) -val WallaceRed = Color(0xFFB3121B) -val WallaceBlack = Color(0xFF111111) -val WallaceGold = Color(0xFFF2C200) +val WallaceRed = Color(0xFFE01B24) +val WallaceBlack = Color(0xFF0C0C0C) +val WallaceGold = Color(0xFFF2C518) /** - * The Wallace sett. + * Expands a half-sett into a full repeat by reflecting it. * - * NOTE: this is an approximation, not a certified threadcount. It reproduces - * what the Wallace tartan reads as — a bold red and black check divided by a - * narrow gold overstripe — but the exact thread counts held by the Scottish - * Register of Tartans could not be verified from here, so the numbers below - * are chosen to look right rather than to be authoritative. They are plain - * data: replacing them with the registered counts changes the whole app's - * branding and needs no other edit. + * Tartans are specified as a half-sett between two pivots and woven out to + * both sides, so `A B C` becomes `A B C B` — the pivot stripes are not + * doubled. Writing the data this way keeps it in the same form a threadcount + * is published in, instead of a flat list that has to be kept symmetric by + * hand. + */ +fun List.pivoted(): List = + if (size < 3) this else this + drop(1).dropLast(1).reversed() + +/** + * The Wallace sett, as a half-sett between the gold line and the centre of a + * narrow black guard. + * + * Matched by eye to a reference swatch rather than to a registered + * threadcount: red ground, black bands alternating wide and narrow, and one + * thin gold overstripe per repeat, flanked by black on both sides. The + * proportions are the point — swapping in registered counts is an edit to this + * list and nothing else. */ val WallaceSett: List = listOf( - TartanStripe(WallaceRed, 40), - TartanStripe(WallaceBlack, 40), - TartanStripe(WallaceGold, 4), -) + TartanStripe(WallaceGold, 6), + TartanStripe(WallaceBlack, 6), + TartanStripe(WallaceRed, 36), + TartanStripe(WallaceBlack, 24), + TartanStripe(WallaceRed, 36), + TartanStripe(WallaceBlack, 6), +).pivoted() /** * Draws a tartan by laying the sett down vertically as warp, then across as * weft at half opacity. The overlap is what produces the blended squares and * the sense of a woven cloth rather than a printed grid — red warp under black * weft comes out a darker red, exactly as it does on the loom. + * + * [rotationDegrees] turns the cloth on the bias. The stripes are drawn well + * past the edges of the canvas so the corners stay covered once it is turned. */ fun DrawScope.drawTartan( sett: List = WallaceSett, threadSize: Float, + rotationDegrees: Float = 0f, ) { if (sett.isEmpty() || threadSize <= 0f) return val repeat = sett.sumOf { it.threads } * threadSize if (repeat <= 0f) return - // Warp: full-height bands. - var x = 0f - while (x < size.width) { - var offset = x - for (stripe in sett) { - val width = stripe.threads * threadSize - drawRect( - color = stripe.color, - topLeft = Offset(offset, 0f), - size = Size(width, size.height), - ) - offset += width - } - x += repeat - } + // Turning a square by 45 degrees needs about 0.21 of its size spare on + // every edge; three quarters is generous enough for any angle. + val pad = if (rotationDegrees == 0f) 0f else size.maxDimension * 0.75f + val left = -pad + val top = -pad + val right = size.width + pad + val bottom = size.height + pad - // Weft: full-width bands, half opacity so the warp shows through. - var y = 0f - while (y < size.height) { - var offset = y - for (stripe in sett) { - val height = stripe.threads * threadSize - drawRect( - color = stripe.color.copy(alpha = 0.5f), - topLeft = Offset(0f, offset), - size = Size(size.width, height), - ) - offset += height + rotate(degrees = rotationDegrees) { + // Warp: full-height bands. + var x = left - (left.mod(repeat)) + while (x < right) { + var offset = x + for (stripe in sett) { + val width = stripe.threads * threadSize + drawRect( + color = stripe.color, + topLeft = Offset(offset, top), + size = Size(width, bottom - top), + ) + offset += width + } + x += repeat + } + + // Weft: full-width bands, half opacity so the warp shows through. + var y = top - (top.mod(repeat)) + while (y < bottom) { + var offset = y + for (stripe in sett) { + val height = stripe.threads * threadSize + drawRect( + color = stripe.color.copy(alpha = 0.5f), + topLeft = Offset(left, offset), + size = Size(right - left, height), + ) + offset += height + } + y += repeat } - y += repeat } } @@ -90,8 +119,13 @@ fun TartanBackground( modifier: Modifier = Modifier, sett: List = WallaceSett, threadWidth: Dp = 2.dp, + rotationDegrees: Float = 0f, ) { Canvas(modifier = modifier) { - drawTartan(sett = sett, threadSize = threadWidth.toPx()) + drawTartan( + sett = sett, + threadSize = threadWidth.toPx(), + rotationDegrees = rotationDegrees, + ) } } diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index 09262d8..fe1cad3 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -1,59 +1,65 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap/ic_launcher.xml b/app/src/main/res/mipmap/ic_launcher.xml index d1b3320..b671e0e 100644 --- a/app/src/main/res/mipmap/ic_launcher.xml +++ b/app/src/main/res/mipmap/ic_launcher.xml @@ -1,21 +1,65 @@ - + - - - - - - - - - - - + android:viewportWidth="108" + android:viewportHeight="108"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap/ic_launcher_round.xml b/app/src/main/res/mipmap/ic_launcher_round.xml index d1b3320..b671e0e 100644 --- a/app/src/main/res/mipmap/ic_launcher_round.xml +++ b/app/src/main/res/mipmap/ic_launcher_round.xml @@ -1,21 +1,65 @@ - + - - - - - - - - - - - + android:viewportWidth="108" + android:viewportHeight="108"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From f5a26261968dec3d11f54d73f3a83f32450f2955 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 04:14:08 +0000 Subject: [PATCH 5/5] Use the published Wallace threadcount, and scale the weave to the sett MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two separate faults. The pattern was wrong, and the scale was broken. Pattern: the sett is published as K/4 R32 K32 Y/4, the slashes marking pivots, which reflects out to K4 R32 K32 Y4 K32 R32 — 136 threads, about half black and half red with a yellow line worth 3%. I had the structure inverted: the yellow overstripe belongs down the centre of a wide black band, and the narrow black guard belongs between two red blocks. Mine put yellow between narrow blacks and the wide black elsewhere, which is why it read as a generic check rather than as Wallace. Scale: thread width was a fixed fraction of the screen while the sett grew from 84 threads to 216, so a repeat became 933dp on a 360dp phone and only 0.39 of one was ever visible. The cloth had zoomed into a few enormous blocks and stopped being a weave at all — that is the part that got worse, not the colours. Thread width is now derived from the sett, so a couple of full repeats are on screen whatever the threadcount says, and changing the count rescales instead of silently magnifying. The icon is generated from the same numbers at one repeat across the adaptive safe zone. Threadcount cross-checked against two independent sources rather than guessed; recorded in Tartan.kt and the README so the next change starts from the published figures. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN --- README.md | 17 ++-- .../org/prolibertate/games/ui/SplashScreen.kt | 8 +- .../org/prolibertate/games/ui/theme/Tartan.kt | 44 ++++++--- .../res/drawable/ic_launcher_background.xml | 93 +++++++++---------- app/src/main/res/mipmap/ic_launcher.xml | 93 +++++++++---------- app/src/main/res/mipmap/ic_launcher_round.xml | 93 +++++++++---------- 6 files changed, 183 insertions(+), 165 deletions(-) diff --git a/README.md b/README.md index fa0ea67..43b356b 100644 --- a/README.md +++ b/README.md @@ -70,14 +70,15 @@ drawable, so it stays sharp at every density and needs no PNGs. Two honest caveats: -- **The sett is matched by eye, not to a registered threadcount.** - `WallaceSett` is written as a half-sett and reflected about its pivots, the way - a threadcount is published: gold 6, black 6, red 36, black 24, red 36, black 6. - That gives a red ground at about two thirds, black bands alternating wide and - narrow, and one thin gold overstripe per repeat flanked by black — matched - against a reference swatch rather than to the Scottish Register of Tartans. - Substituting registered counts is an edit to that one list; the icon is - generated from the same numbers, so both follow. +- **The sett is the published threadcount: `K/4 R32 K32 Y/4`.** The slashes are + pivots, so `WallaceSett` holds the half-sett and reflects it out to + `K4 R32 K32 Y4 K32 R32` — 136 threads, about half black, half red, with a + yellow line worth 3%. Two details define the pattern and are easy to get + backwards: the yellow overstripe runs down the *centre of a wide black band*, + and the narrow black guard sits *between two red blocks*. The icon is + generated from the same numbers, so the icon and the splash cannot drift. + Weave scale is derived from the sett rather than fixed, so changing the + threadcount rescales both instead of silently zooming in. - **The typeface is bundled under a licence.** Uncial Antiqua, © 2011 Brian J. Bonislawsky DBA Astigmatic, Reserved Font Name "Uncial Antiqua", used under the SIL Open Font License 1.1. The full licence is at diff --git a/app/src/main/java/org/prolibertate/games/ui/SplashScreen.kt b/app/src/main/java/org/prolibertate/games/ui/SplashScreen.kt index e3f1ce8..fd3aff5 100644 --- a/app/src/main/java/org/prolibertate/games/ui/SplashScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/SplashScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.unit.sp import kotlinx.coroutines.delay import org.prolibertate.games.R import org.prolibertate.games.ui.theme.TartanBackground +import org.prolibertate.games.ui.theme.threadWidthFor /** How long the splash holds at full opacity before fading. */ private const val SPLASH_HOLD_MILLIS = 2_000L @@ -75,9 +76,10 @@ fun SplashScreen( // longer in scope. val shortestSide = minOf(maxWidth, maxHeight).value - // Weave scale follows the screen so the cloth reads the same on a phone - // and a tablet rather than turning into a fine grid on a large display. - val threadWidth = (shortestSide * 0.012f).dp + // Weave scale is derived from the sett, not a fixed fraction, so a + // couple of full repeats are always on screen whatever the threadcount + // says and whatever the display is. + val threadWidth = threadWidthFor(across = shortestSide.dp, repeats = 2f) val titleSize = (shortestSide * 0.13f).sp TartanBackground( modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/java/org/prolibertate/games/ui/theme/Tartan.kt b/app/src/main/java/org/prolibertate/games/ui/theme/Tartan.kt index 4b64261..af4aaa2 100644 --- a/app/src/main/java/org/prolibertate/games/ui/theme/Tartan.kt +++ b/app/src/main/java/org/prolibertate/games/ui/theme/Tartan.kt @@ -31,24 +31,42 @@ fun List.pivoted(): List = if (size < 3) this else this + drop(1).dropLast(1).reversed() /** - * The Wallace sett, as a half-sett between the gold line and the centre of a - * narrow black guard. + * The Wallace sett, from its published threadcount: **K/4 R32 K32 Y/4**. * - * Matched by eye to a reference swatch rather than to a registered - * threadcount: red ground, black bands alternating wide and narrow, and one - * thin gold overstripe per repeat, flanked by black on both sides. The - * proportions are the point — swapping in registered counts is an edit to this - * list and nothing else. + * The slashes mark the pivots, so this is the half-sett and it reflects out to + * `K4 R32 K32 Y4 K32 R32` — 136 threads, roughly half black, half red, with a + * yellow line worth 3%. Two consequences that are easy to get backwards and + * that define the pattern: + * + * - the yellow overstripe runs down the **centre of a wide black band**, not + * between red and black; + * - the narrow black guard sits **between two red blocks**, splitting the red + * into pairs. */ val WallaceSett: List = listOf( - TartanStripe(WallaceGold, 6), - TartanStripe(WallaceBlack, 6), - TartanStripe(WallaceRed, 36), - TartanStripe(WallaceBlack, 24), - TartanStripe(WallaceRed, 36), - TartanStripe(WallaceBlack, 6), + TartanStripe(WallaceBlack, 4), + TartanStripe(WallaceRed, 32), + TartanStripe(WallaceBlack, 32), + TartanStripe(WallaceGold, 4), ).pivoted() +/** Threads in one full repeat of a sett. */ +val List.threadsPerRepeat: Int get() = sumOf { it.threads } + +/** + * The thread width that fits [repeats] repeats of [sett] across [across]. + * + * Scale has to be derived from the sett rather than fixed, because changing a + * threadcount changes how many threads a repeat contains — a fixed width and a + * larger sett silently zooms in until the cloth is a couple of enormous blocks + * and stops reading as tartan at all. + */ +fun threadWidthFor( + sett: List = WallaceSett, + across: Dp, + repeats: Float = 2f, +): Dp = across / (sett.threadsPerRepeat * repeats) + /** * Draws a tartan by laying the sett down vertically as warp, then across as * weft at half opacity. The overlap is what produces the blended squares and diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index fe1cad3..0b06f99 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -2,13 +2,16 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap/ic_launcher.xml b/app/src/main/res/mipmap/ic_launcher.xml index b671e0e..22102a1 100644 --- a/app/src/main/res/mipmap/ic_launcher.xml +++ b/app/src/main/res/mipmap/ic_launcher.xml @@ -2,13 +2,16 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap/ic_launcher_round.xml b/app/src/main/res/mipmap/ic_launcher_round.xml index b671e0e..22102a1 100644 --- a/app/src/main/res/mipmap/ic_launcher_round.xml +++ b/app/src/main/res/mipmap/ic_launcher_round.xml @@ -2,13 +2,16 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +