Add President and Golf

Two more games on the existing framework — no changes were needed to the
lobby, networking, settings or update layers, which is what the shared
GameRules/GameAi contract was for.

President
- Threes low, twos high. Lead any number of one rank; follow with the
  same count of a higher rank, or pass. When everyone has passed the
  pile comes down and the last player to play leads again.
- Toggles: player count, rounds, twos clear the pile, four of a kind
  bombs, and the between-round card exchange.
- Plays are offered as whole sets rather than by selecting cards. A set
  is the unit of the game, so choosing from the legal sets is both
  easier to operate and impossible to get wrong.

Golf
- 4, 6 or 9 card grids. Draw from stock or discard, then either swap the
  card in or — only if it came off the stock — throw it and turn one of
  your own over. A card taken from the discard pile has to be used.
- Matching columns cancel to nothing; on the 3x3 board rows do too, but
  not on the shallower grids where three cards are not a line.
- Face-down cards are hidden from everyone including their owner, which
  is the point of the game: viewFor masks them with a fixed placeholder
  so the real card never reaches the device that has not turned it over.

Both engines are pure Kotlin and unit tested — 71 tests now, covering
ranking, legal moves, pile and hole resolution, scoring, redaction, wire
round-trips, and full random and AI playthroughs. The Golf test also
checks the AI actually beats random play rather than merely being legal.

Also widened the setup screen's option chips to wrap: the reveal-count
row carries up to ten, and a chip off the edge of the screen is a
setting that cannot be changed — the same failure as the Euchre Pass
button.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
This commit is contained in:
Claude
2026-08-06 03:25:09 +00:00
parent b49bfc0fe6
commit 532c3ea48a
17 changed files with 2590 additions and 8 deletions
+6 -2
View File
@@ -9,8 +9,8 @@ The app shell is complete and two games are playable end to end:
| | | | | |
| --- | --- | | --- | --- |
| **Playable** | Euchre, Sequence | | **Playable** | Euchre, Sequence, President, Golf |
| **Listed, not yet implemented** | Kaiser, President, Golf, Wizard, Crazy 8s, Mastermind, Backgammon, Chess, Checkers | | **Listed, not yet implemented** | Kaiser, Wizard, Crazy 8s, Mastermind, Backgammon, Chess, Checkers |
Unimplemented games appear in the menu marked *Coming soon* **on dev builds 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, only** — a production release lists just the games that can actually be played,
@@ -145,6 +145,8 @@ game:
- [Euchre](docs/RULES-euchre.md) - [Euchre](docs/RULES-euchre.md)
- [Sequence](docs/RULES-sequence.md) — **note the caveat about the board layout** - [Sequence](docs/RULES-sequence.md) — **note the caveat about the board layout**
- [President](docs/RULES-president.md)
- [Golf](docs/RULES-golf.md)
## Layout ## Layout
@@ -154,6 +156,8 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested
engine/ GameRules / GameAi contracts, seats, table config engine/ GameRules / GameAi contracts, seats, table config
euchre/ Euchre model, rules, AI euchre/ Euchre model, rules, AI
sequence/ Sequence model, board, 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 net/ wire protocol, LAN and Bluetooth transports, lobby, match driver
settings/ DataStore-backed preferences settings/ DataStore-backed preferences
update/ GitHub Releases OTA updater update/ GitHub Releases OTA updater
@@ -68,7 +68,7 @@ object GameCatalog {
maxPlayers = 7, maxPlayers = 7,
teamBased = false, teamBased = false,
blurb = "Shed your hand first and rule the table. Last one out serves the drinks.", blurb = "Shed your hand first and rule the table. Last one out serves the drinks.",
available = false, available = true,
), ),
GameDescriptor( GameDescriptor(
id = GOLF, id = GOLF,
@@ -78,7 +78,7 @@ object GameCatalog {
maxPlayers = 6, maxPlayers = 6,
teamBased = false, teamBased = false,
blurb = "Lowest score wins over nine holes. Match pairs to cancel them out.", blurb = "Lowest score wins over nine holes. Match pairs to cancel them out.",
available = false, available = true,
), ),
GameDescriptor( GameDescriptor(
id = WIZARD, id = WIZARD,
@@ -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<GolfState, GolfMove> {
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>): 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>): 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>): GolfMove {
val drawn = state.drawn ?: return legal.first()
val replacements = legal.filterIsInstance<ReplaceCard>()
val flips = legal.filterIsInstance<DiscardAndFlip>()
// 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
}
}
@@ -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<Card>, 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<List<Card>>,
val revealed: List<List<Boolean>>,
val stock: List<Card>,
val discard: List<Card>,
/** 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<Int>,
val scores: List<Int>,
val phase: GolfPhase,
val log: List<String>,
) {
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
@@ -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<GolfState, GolfMove> {
override val gameId: String = GameCatalog.GOLF
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
override fun initialState(config: TableConfig): GolfState {
val options = json.decodeFromString<GolfOptions>(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<Int>,
log: List<String>,
): 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<List<Card>>()
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<GolfMove> {
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)
}
@@ -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<PresidentState, PresidentMove> {
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>,
): PresidentMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
val plays = legal.filterIsInstance<PlayCards>()
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<PlayCards>,
): 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<PlayCards>,
legal: List<PresidentMove>,
): 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
}
}
@@ -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<List<Card>>,
val handCounts: List<Int>,
/** Cards played to the current pile, most recent set last. */
val pile: List<Card>,
/** 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<Boolean>,
/** Seats in the order they went out — first is President. */
val finishedOrder: List<Int>,
/** Finishing position from the previous round, or -1 on the first. */
val previousFinish: List<Int>,
val scores: List<Int>,
val phase: PresidentPhase,
val log: List<String>,
) {
fun isOut(seat: Int): Boolean = seat in finishedOrder
/** Seats still holding cards and not yet passed out of this pile. */
fun stillInPile(): List<Int> =
(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<Card>) : 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"
}
@@ -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<PresidentState, PresidentMove> {
override val gameId: String = GameCatalog.PRESIDENT
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
override fun initialState(config: TableConfig): PresidentState {
val options = json.decodeFromString<PresidentOptions>(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<Int>,
scores: List<Int>,
log: List<String>,
): 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<Card>() }
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<List<Card>>,
previousFinish: List<Int>,
options: PresidentOptions,
): List<List<Card>> {
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<PresidentMove> {
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<PresidentMove>()
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<Card>,
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<Card>,
): 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)
}
@@ -2,6 +2,8 @@ package org.prolibertate.games.ui
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig import org.prolibertate.games.game.engine.TableConfig
import org.prolibertate.games.game.euchre.EuchreOptions import org.prolibertate.games.game.euchre.EuchreOptions
import org.prolibertate.games.game.golf.GolfOptions
import org.prolibertate.games.game.president.PresidentOptions
import org.prolibertate.games.game.sequence.SequenceOptions import org.prolibertate.games.game.sequence.SequenceOptions
private val setupJson = Json { encodeDefaults = true } private val setupJson = Json { encodeDefaults = true }
@@ -49,15 +53,21 @@ fun GameSetupScreen(
) { ) {
var euchre by remember { mutableStateOf(EuchreOptions()) } var euchre by remember { mutableStateOf(EuchreOptions()) }
var sequence by remember { mutableStateOf(SequenceOptions()) } var sequence by remember { mutableStateOf(SequenceOptions()) }
var president by remember { mutableStateOf(PresidentOptions()) }
var golf by remember { mutableStateOf(GolfOptions()) }
val optionsJson = when (descriptor.id) { val optionsJson = when (descriptor.id) {
GameCatalog.EUCHRE -> setupJson.encodeToString(euchre) GameCatalog.EUCHRE -> setupJson.encodeToString(euchre)
GameCatalog.SEQUENCE -> setupJson.encodeToString(sequence) GameCatalog.SEQUENCE -> setupJson.encodeToString(sequence)
GameCatalog.PRESIDENT -> setupJson.encodeToString(president)
GameCatalog.GOLF -> setupJson.encodeToString(golf)
else -> "{}" else -> "{}"
} }
val seatCount = when (descriptor.id) { val seatCount = when (descriptor.id) {
GameCatalog.EUCHRE -> 4 GameCatalog.EUCHRE -> 4
GameCatalog.SEQUENCE -> sequence.playerCount GameCatalog.SEQUENCE -> sequence.playerCount
GameCatalog.PRESIDENT -> president.playerCount
GameCatalog.GOLF -> golf.playerCount
else -> descriptor.minPlayers else -> descriptor.minPlayers
} }
@@ -74,6 +84,8 @@ fun GameSetupScreen(
when (descriptor.id) { when (descriptor.id) {
GameCatalog.EUCHRE -> EuchreOptionsEditor(euchre) { euchre = it } GameCatalog.EUCHRE -> EuchreOptionsEditor(euchre) { euchre = it }
GameCatalog.SEQUENCE -> SequenceOptionsEditor(sequence) { sequence = 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.") else -> Text("No options yet for this game.")
} }
@@ -121,8 +133,9 @@ private fun offlineConfig(
fun teamForSeat(gameId: String, seat: Int): Int = when (gameId) { fun teamForSeat(gameId: String, seat: Int): Int = when (gameId) {
// Partners sit opposite each other. // Partners sit opposite each other.
GameCatalog.EUCHRE -> seat % 2 GameCatalog.EUCHRE, GameCatalog.SEQUENCE -> seat % 2
else -> seat % 2 // Everyone else plays for themselves.
else -> seat
} }
@Composable @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 @Composable
private fun <T> ChipRow( private fun <T> ChipRow(
label: String, label: String,
@@ -207,7 +309,12 @@ private fun <T> ChipRow(
) { ) {
Column { Column {
Text(label, style = MaterialTheme.typography.labelLarge) 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 -> values.forEach { value ->
FilterChip( FilterChip(
selected = value == selected, selected = value == selected,
@@ -12,6 +12,16 @@ import org.prolibertate.games.game.euchre.EuchreMove
import org.prolibertate.games.game.euchre.EuchrePhase import org.prolibertate.games.game.euchre.EuchrePhase
import org.prolibertate.games.game.euchre.EuchreRules import org.prolibertate.games.game.euchre.EuchreRules
import org.prolibertate.games.game.euchre.EuchreState import org.prolibertate.games.game.euchre.EuchreState
import org.prolibertate.games.game.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.SequenceAi
import org.prolibertate.games.game.sequence.SequenceMove import org.prolibertate.games.game.sequence.SequenceMove
import org.prolibertate.games.game.sequence.SequenceRules 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.net.MatchController
import org.prolibertate.games.settings.Settings import org.prolibertate.games.settings.Settings
import org.prolibertate.games.ui.game.EuchreScreen import org.prolibertate.games.ui.game.EuchreScreen
import org.prolibertate.games.ui.game.GolfScreen
import org.prolibertate.games.ui.game.PresidentScreen
import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS
import org.prolibertate.games.ui.game.SequenceScreen import org.prolibertate.games.ui.game.SequenceScreen
@@ -96,6 +108,50 @@ fun PlayScreen(
SequenceScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) SequenceScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
} }
GameCatalog.PRESIDENT -> {
val controller = remember(route) {
MatchController<PresidentState, PresidentMove>(
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<GolfState, GolfMove>(
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.") else -> Text("That game isn't playable yet.")
} }
} }
@@ -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<GolfState, GolfMove>,
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<GolfMove>,
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<GolfMove>,
mode: PlaceMode,
onMove: (GolfMove) -> Unit,
) {
val options = state.options
val replacements = legal.filterIsInstance<ReplaceCard>().map { it.index }.toSet()
val flips = legal.filterIsInstance<DiscardAndFlip>().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)
}
}
}
}
}
}
}
}
@@ -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<PresidentState, PresidentMove>,
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<PresidentMove>,
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<PlayCards>()
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)
}
}
}
}
@@ -50,7 +50,12 @@ class CatalogTest {
@Test @Test
fun `playable games are the ones with engines wired up`() { fun `playable games are the ones with engines wired up`() {
assertEquals( assertEquals(
setOf(GameCatalog.EUCHRE, GameCatalog.SEQUENCE), setOf(
GameCatalog.EUCHRE,
GameCatalog.SEQUENCE,
GameCatalog.PRESIDENT,
GameCatalog.GOLF,
),
GameCatalog.playable.map { it.id }.toSet(), GameCatalog.playable.map { it.id }.toSet(),
) )
} }
@@ -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<ReplaceCard>().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)))
}
}
}
@@ -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<List<Card>>,
setSize: Int = 0,
setRank: Int = 0,
turn: Int = 0,
options: PresidentOptions = PresidentOptions(playerCount = 4),
pile: List<Card> = 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<Rank, Suit>) = 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<PlayCards>()
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<PlayCards>()
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)))
}
}
}
+68
View File
@@ -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.
+69
View File
@@ -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.