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.