Merge dev: President, Golf, and the corrected Wallace tartan

Publishes a production release carrying everything since v1.0.3.

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 04:24:52 +00:00
23 changed files with 3282 additions and 144 deletions
+19 -12
View File
@@ -5,12 +5,12 @@ or against other people over Wi-Fi (LAN) or Bluetooth.
## What works today
The app shell is complete and two games are playable end to end:
The app shell is complete and four games are playable end to end:
| | |
| --- | --- |
| **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,
@@ -59,9 +59,9 @@ them.
## Branding
The launcher icon is a single square of the Wallace tartan, and the app opens on
a tartan splash carrying the name in a Celtic uncial hand for two seconds before
fading into the menu.
The launcher icon is a single square of the Wallace tartan set on the bias, and
the app opens on the same cloth carrying the name in a Celtic uncial hand for two
seconds before fading into the menu.
The tartan is drawn, not an image: `ui/theme/Tartan.kt` lays the sett down as
warp and then as weft at half opacity, which is what produces the blended
@@ -70,12 +70,15 @@ drawable, so it stays sharp at every density and needs no PNGs.
Two honest caveats:
- **The sett is an approximation.** `WallaceSett` reproduces what the Wallace
tartan reads as — a bold red and black check divided by a narrow gold
overstripe — but the registered threadcount could not be verified, so the
numbers were chosen to look right rather than to be authoritative. They are
plain data; replacing them with the registered counts rebrands the whole app
and needs no other change.
- **The sett is the published threadcount: `K/4 R32 K32 Y/4`.** The slashes are
pivots, so `WallaceSett` holds the half-sett and reflects it out to
`K4 R32 K32 Y4 K32 R32` — 136 threads, about half black, half red, with a
yellow line worth 3%. Two details define the pattern and are easy to get
backwards: the yellow overstripe runs down the *centre of a wide black band*,
and the narrow black guard sits *between two red blocks*. The icon is
generated from the same numbers, so the icon and the splash cannot drift.
Weave scale is derived from the sett rather than fixed, so changing the
threadcount rescales both instead of silently zooming in.
- **The typeface is bundled under a licence.** Uncial Antiqua, © 2011 Brian J.
Bonislawsky DBA Astigmatic, Reserved Font Name "Uncial Antiqua", used under
the SIL Open Font License 1.1. The full licence is at
@@ -145,6 +148,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 +159,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
@@ -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,
@@ -0,0 +1,125 @@
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,
/** Only close the hole out when the visible score is at or under this. */
val closeOutBelow: Int,
) {
EASY(discardAppetite = 2, closeOutBelow = 30),
NORMAL(discardAppetite = 5, closeOutBelow = 16),
CAUTIOUS(discardAppetite = 7, closeOutBelow = 12),
}
override fun chooseMove(state: GolfState, seat: Int, legal: List<GolfMove>): GolfMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
return when (state.phase) {
GolfPhase.SETUP -> chooseReveal(state, seat, legal)
GolfPhase.DRAW -> chooseDraw(state, seat, legal)
GolfPhase.PLACE -> choosePlace(state, seat, legal)
else -> legal.first()
}
}
/**
* Opening reveals are spread across columns. Two cards in the same column
* tell you whether that column can be cancelled but nothing about the rest
* of the board; one in each column leaves more of the grid legible.
*/
private fun chooseReveal(state: GolfState, seat: Int, legal: List<GolfMove>): GolfMove {
val reveals = legal.filterIsInstance<RevealCard>()
if (reveals.isEmpty()) return legal.first()
val options = state.options
val untouchedColumn = reveals.firstOrNull { move ->
val column = move.index % options.cols
(0 until options.rows).none { row ->
state.revealed[seat][row * options.cols + column]
}
}
return untouchedColumn ?: reveals.first()
}
private fun chooseDraw(state: GolfState, seat: Int, legal: List<GolfMove>): 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
}
// On the last face-down card, turning it over ends the hole for
// everyone. Only do that from a position worth defending; otherwise
// line up the putt and wait for a better card.
if (legal.contains(DiscardOnly)) {
val showing = state.grids[seat].indices
.filter { state.revealed[seat][it] }
.sumOf { golfValue(state.grids[seat][it]) }
if (showing > difficulty.closeOutBelow) return DiscardOnly
}
// Nothing worth swapping: throw it and turn something over instead,
// which at least buys information.
if (flips.isNotEmpty()) return flips.first()
// 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,166 @@
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), 8 (2x4) or 9 (3x3). */
val gridSize: Int = 6,
val holes: Int = 9,
/** How many of your own cards you turn over before play starts. */
val startingReveals: Int = 2,
/**
* Lining up the final putt: lets you throw a drawn card away without
* turning your last face-down card over, so you are not forced to close
* the hole on a card you have not chosen.
*/
val lineUpFinalPutt: Boolean = false,
) {
init {
require(playerCount in 2..6) { "Golf seats two to six" }
require(gridSize in setOf(4, 6, 8, 9)) {
"Golf grids are 4, 6, 8 or 9 cards"
}
require(holes >= 1) { "holes must be positive" }
require(startingReveals in 0..gridSize) { "cannot reveal more than the grid holds" }
}
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 {
/** Each player choosing which of their own cards to turn over first. */
SETUP,
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,
/**
* Consecutive turns where nobody did anything but throw a card away.
* Only reachable with [GolfOptions.lineUpFinalPutt] on, and only there to
* stop a table of players all declining to close from playing forever.
*/
val idleDiscards: Int = 0,
val log: List<String>,
) {
fun allRevealed(seat: Int): Boolean = revealed[seat].all { it }
fun faceDownCount(seat: Int): Int = revealed[seat].count { !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
/**
* Throw the drawn card away and turn nothing over — lining up the final putt.
* Only offered when turning a card over would be turning over your last one.
*/
@Serializable
@SerialName("discard_only")
data object DiscardOnly : GolfMove
/** Choosing one of your own cards to see before play starts. */
@Serializable
@SerialName("reveal")
data class RevealCard(val index: Int) : GolfMove
@@ -0,0 +1,338 @@
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)
}
// Nothing starts face up: each player chooses which of their own cards
// to turn over, which is a real decision rather than a dealt-out one.
val revealed = List(options.playerCount) { List(options.gridSize) { false } }
val firstDiscard = deck.first()
return GolfState(
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 = if (options.startingReveals > 0) GolfPhase.SETUP else GolfPhase.DRAW,
idleDiscards = 0,
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.SETUP, 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.SETUP -> (0 until state.options.gridSize)
.filter { !state.revealed[seat][it] }
.map { RevealCard(it) }
GolfPhase.DRAW -> buildList {
if (state.stock.isNotEmpty()) add(DrawFromStock)
if (state.discard.isNotEmpty()) add(DrawFromDiscard)
}
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))
}
// Lining up the final putt: decline to turn over the last
// card, rather than being forced to close the hole with it.
if (state.options.lineUpFinalPutt && state.faceDownCount(seat) == 1) {
add(DiscardOnly)
}
}
}
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)
is DiscardOnly -> applyDiscardOnly(state, seat)
is RevealCard -> applyReveal(state, seat, move.index)
}
}
/**
* Turning over one of your opening cards. When a seat has seen its
* allowance the choice passes on, and once everyone has chosen the hole
* begins.
*/
private fun applyReveal(state: GolfState, seat: Int, index: Int): GolfState {
val revealed = state.revealed.map { it.toMutableList() }
revealed[seat][index] = true
val chosen = revealed[seat].count { it }
if (chosen < state.options.startingReveals) {
return state.copy(revealed = revealed)
}
val next = (seat + 1) % state.options.playerCount
val everyoneReady = revealed.all { row ->
row.count { it } >= state.options.startingReveals
}
return state.copy(
revealed = revealed,
turn = if (everyoneReady) state.hole % state.options.playerCount else next,
phase = if (everyoneReady) GolfPhase.DRAW else GolfPhase.SETUP,
)
}
private fun applyReplace(state: GolfState, seat: Int, index: Int): GolfState {
val drawn = requireNotNull(state.drawn) { "Nothing drawn" }
val grids = state.grids.map { it.toMutableList() }
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,
)
}
private fun applyDiscardOnly(state: GolfState, seat: Int): GolfState {
val drawn = requireNotNull(state.drawn) { "Nothing drawn" }
return endTurn(
state.copy(
discard = state.discard + drawn,
drawn = null,
idleDiscards = state.idleDiscards + 1,
log = state.log + "Seat $seat throws ${drawn.label} and lines up the putt.",
),
seat,
idle = true,
)
}
/**
* Closes out a turn: notices a finished grid, counts down the last lap and
* either moves on or scores the hole.
*/
private fun endTurn(state: GolfState, seat: Int, idle: Boolean = false): GolfState {
var next = if (idle) state else state.copy(idleDiscards = 0)
// Everyone declining to close, round after round, would never end. Two
// full laps of nothing but thrown cards is taken as the hole being over.
if (next.idleDiscards >= next.options.playerCount * 2) {
return scoreHole(next.copy(log = next.log + "Nobody will putt out — hole called."))
}
// The first player to turn everything over gives everyone else one
// more turn, and no more than one.
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)
}
@@ -47,6 +47,12 @@ class MatchController<S : Any, M : Any>(
* next card lands on top of it.
*/
private val holdBeforeNextMove: (S) -> Long = { 0L },
/**
* States that should not advance on their own — an end-of-round scoreboard
* the players need to read before the next deal. The controller stops and
* waits for [confirmAdvance].
*/
private val awaitsConfirmation: (S) -> Boolean = { false },
) {
enum class Role { HOST, CLIENT }
@@ -65,6 +71,11 @@ class MatchController<S : Any, M : Any>(
private val _notice = MutableStateFlow<String?>(null)
val notice: StateFlow<String?> = _notice.asStateFlow()
private val _awaitingConfirmation = MutableStateFlow(false)
/** True while a scoreboard is up and the table is waiting to be released. */
val awaitingConfirmation: StateFlow<Boolean> = _awaitingConfirmation.asStateFlow()
/** Host only: the true state. Clients never see this. */
private var authoritative: S? = null
private val lock = Mutex()
@@ -198,6 +209,12 @@ class MatchController<S : Any, M : Any>(
val seat = rules.currentSeat(current)
if (seat == null) {
// Nobody on the clock: a hand has been scored, or the game ended.
if (role == Role.HOST && awaitsConfirmation(current)) {
// Hold here until a player has read the scoreboard. Only the
// host waits; clients simply render what they were sent.
_awaitingConfirmation.value = true
return
}
delay(aiThinkingMillis() * 2)
val advanced = advanceIdle(current)
if (advanced == null) {
@@ -261,6 +278,18 @@ class MatchController<S : Any, M : Any>(
}
}
/**
* Releases a table stopped on a scoreboard. A no-op anywhere else, and on a
* client — where the host owns the decision — it does nothing but clear the
* local flag.
*/
fun confirmAdvance() {
if (!_awaitingConfirmation.value) return
_awaitingConfirmation.value = false
if (role != Role.HOST) return
scope.launch { driveIdleSeats() }
}
fun close() {
peers.values.forEach { runCatching { it.close() } }
peers.clear()
@@ -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,102 @@ 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, 8, 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)) },
)
ToggleRow(
title = "Line up the final putt",
subtitle = "Throw a drawn card away without turning your last card over, " +
"rather than being forced to close the hole",
checked = options.lineUpFinalPutt,
onChange = { onChange(options.copy(lineUpFinalPutt = it)) },
)
Text(
text = "${options.rows} rows of ${options.cols}. Matching columns cancel out" +
if (options.gridSize == 9) ", and so do matching rows." else ".",
style = MaterialTheme.typography.bodySmall,
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun <T> ChipRow(
label: String,
@@ -207,7 +316,12 @@ private fun <T> 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,
@@ -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,52 @@ fun PlayScreen(
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
},
// The score card is read before the next hole is dealt.
awaitsConfirmation = { state -> state.phase == GolfPhase.HOLE_OVER },
aiThinkingMillis = { settings.scaled(700L) },
)
}
StartMatch(env, controller, route)
GolfScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
}
else -> Text("That game isn't playable yet.")
}
}
@@ -31,6 +31,7 @@ import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import org.prolibertate.games.R
import org.prolibertate.games.ui.theme.TartanBackground
import org.prolibertate.games.ui.theme.threadWidthFor
/** How long the splash holds at full opacity before fading. */
private const val SPLASH_HOLD_MILLIS = 2_000L
@@ -75,13 +76,16 @@ fun SplashScreen(
// longer in scope.
val shortestSide = minOf(maxWidth, maxHeight).value
// Weave scale follows the screen so the cloth reads the same on a phone
// and a tablet rather than turning into a fine grid on a large display.
val threadWidth = (shortestSide * 0.012f).dp
// Weave scale is derived from the sett, not a fixed fraction, so a
// couple of full repeats are always on screen whatever the threadcount
// says and whatever the display is.
val threadWidth = threadWidthFor(across = shortestSide.dp, repeats = 2f)
val titleSize = (shortestSide * 0.13f).sp
TartanBackground(
modifier = Modifier.fillMaxSize(),
threadWidth = threadWidth,
// On the bias, matching the icon.
rotationDegrees = 45f,
)
// A scrim keeps the lettering legible over the busiest part of the sett.
@@ -0,0 +1,423 @@
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.AlertDialog
import androidx.compose.material3.FilterChip
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.TextButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.DiscardOnly
import org.prolibertate.games.game.golf.DrawFromDiscard
import org.prolibertate.games.game.golf.DrawFromStock
import org.prolibertate.games.game.golf.GolfMove
import org.prolibertate.games.game.golf.GolfPhase
import org.prolibertate.games.game.golf.GolfState
import org.prolibertate.games.game.golf.ReplaceCard
import org.prolibertate.games.game.golf.RevealCard
import org.prolibertate.games.game.golf.golfValue
import org.prolibertate.games.net.MatchController
import org.prolibertate.games.ui.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) },
)
}
}
// Only offered on your last face-down card, and only with
// the option switched on in setup.
if (legal.contains(DiscardOnly)) {
OutlinedButton(
onClick = { controller.submit(DiscardOnly) },
modifier = Modifier.fillMaxWidth(),
) {
Text("Line up the putt — throw it, keep the last card down")
}
}
}
OwnGrid(
state = current,
localSeat = localSeat,
cardWidth = ownCard,
legal = legal,
mode = mode,
onMove = { controller.submit(it); mode = PlaceMode.SWAP },
)
}
// The card is read before the next one is dealt, so the scoreboard
// is a dialog the players dismiss rather than a line that flashes by.
if (current.phase == GolfPhase.HOLE_OVER || current.phase == GolfPhase.GAME_OVER) {
ScoreCardDialog(
state = current,
localSeat = localSeat,
onContinue = { controller.confirmAdvance() },
onExit = onExit,
)
}
}
}
}
/**
* The card at the end of a hole: this hole's strokes and the running total for
* everyone, lowest first.
*/
@Composable
private fun ScoreCardDialog(
state: GolfState,
localSeat: Int,
onContinue: () -> Unit,
onExit: () -> Unit,
) {
var dismissed by remember(state.hole, state.phase) { mutableStateOf(false) }
if (dismissed) return
val finalHole = state.phase == GolfPhase.GAME_OVER
val standings = (0 until state.options.playerCount).sortedBy { state.scores[it] }
AlertDialog(
onDismissRequest = { },
title = {
Text(
if (finalHole) "Final card" else "Hole ${state.hole + 1} of ${state.options.holes}"
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(modifier = Modifier.fillMaxWidth()) {
Text("Player", modifier = Modifier.weight(1f), fontWeight = FontWeight.Bold)
Text("Hole", fontWeight = FontWeight.Bold)
Text(" Total", fontWeight = FontWeight.Bold)
}
standings.forEach { seat ->
val you = seat == localSeat
Row(modifier = Modifier.fillMaxWidth()) {
Text(
text = if (you) "You" else "Seat $seat",
modifier = Modifier.weight(1f),
fontWeight = if (you) FontWeight.Bold else FontWeight.Normal,
)
Text("${state.holeScores[seat]}")
Text(" ${state.scores[seat]}",
fontWeight = if (you) FontWeight.Bold else FontWeight.Normal)
}
}
Text(
text = if (finalHole) {
"Lowest total wins — seat ${standings.first()} takes it."
} else {
"Lowest total wins."
},
style = MaterialTheme.typography.bodySmall,
)
}
},
confirmButton = {
TextButton(onClick = {
dismissed = true
if (finalHole) onExit() else onContinue()
}) {
Text(if (finalHole) "Back to the menu" else "Deal the next hole")
}
},
)
}
@Composable
private fun ScoreLine(state: GolfState, localSeat: Int) {
Row(
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()
val reveals = legal.filterIsInstance<RevealCard>().map { it.index }.toSet()
Column {
Text(
text = when {
state.phase == GolfPhase.SETUP && reveals.isNotEmpty() -> {
val left = state.options.startingReveals - state.revealed[localSeat].count { it }
"Choose $left more card(s) to look at"
}
state.phase == GolfPhase.SETUP -> "Waiting for the others to choose…"
state.phase == GolfPhase.PLACE && mode == PlaceMode.SWAP ->
"Tap a card to swap it out"
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 = if (state.phase == GolfPhase.SETUP) {
index in reveals
} else {
when (mode) {
PlaceMode.SWAP -> index in replacements
PlaceMode.THROW -> index in flips
}
}
GridCard(
card = state.grids[localSeat].getOrNull(index),
faceUp = state.revealed[localSeat].getOrElse(index) { false },
width = cardWidth,
tappable = tappable,
onClick = {
when {
state.phase == GolfPhase.SETUP -> onMove(RevealCard(index))
mode == PlaceMode.SWAP -> onMove(ReplaceCard(index))
else -> 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)
}
}
}
}
@@ -7,57 +7,103 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/** One band of a tartan sett, measured in threads. */
data class TartanStripe(val color: Color, val threads: Int)
val WallaceRed = Color(0xFFB3121B)
val WallaceBlack = Color(0xFF111111)
val WallaceGold = Color(0xFFF2C200)
val WallaceRed = Color(0xFFE01B24)
val WallaceBlack = Color(0xFF0C0C0C)
val WallaceGold = Color(0xFFF2C518)
/**
* The Wallace sett.
* Expands a half-sett into a full repeat by reflecting it.
*
* NOTE: this is an approximation, not a certified threadcount. It reproduces
* what the Wallace tartan reads as — a bold red and black check divided by a
* narrow gold overstripe — but the exact thread counts held by the Scottish
* Register of Tartans could not be verified from here, so the numbers below
* are chosen to look right rather than to be authoritative. They are plain
* data: replacing them with the registered counts changes the whole app's
* branding and needs no other edit.
* Tartans are specified as a half-sett between two pivots and woven out to
* both sides, so `A B C` becomes `A B C B` — the pivot stripes are not
* doubled. Writing the data this way keeps it in the same form a threadcount
* is published in, instead of a flat list that has to be kept symmetric by
* hand.
*/
fun List<TartanStripe>.pivoted(): List<TartanStripe> =
if (size < 3) this else this + drop(1).dropLast(1).reversed()
/**
* The Wallace sett, from its published threadcount: **K/4 R32 K32 Y/4**.
*
* The slashes mark the pivots, so this is the half-sett and it reflects out to
* `K4 R32 K32 Y4 K32 R32` — 136 threads, roughly half black, half red, with a
* yellow line worth 3%. Two consequences that are easy to get backwards and
* that define the pattern:
*
* - the yellow overstripe runs down the **centre of a wide black band**, not
* between red and black;
* - the narrow black guard sits **between two red blocks**, splitting the red
* into pairs.
*/
val WallaceSett: List<TartanStripe> = listOf(
TartanStripe(WallaceRed, 40),
TartanStripe(WallaceBlack, 40),
TartanStripe(WallaceBlack, 4),
TartanStripe(WallaceRed, 32),
TartanStripe(WallaceBlack, 32),
TartanStripe(WallaceGold, 4),
)
).pivoted()
/** Threads in one full repeat of a sett. */
val List<TartanStripe>.threadsPerRepeat: Int get() = sumOf { it.threads }
/**
* The thread width that fits [repeats] repeats of [sett] across [across].
*
* Scale has to be derived from the sett rather than fixed, because changing a
* threadcount changes how many threads a repeat contains — a fixed width and a
* larger sett silently zooms in until the cloth is a couple of enormous blocks
* and stops reading as tartan at all.
*/
fun threadWidthFor(
sett: List<TartanStripe> = WallaceSett,
across: Dp,
repeats: Float = 2f,
): Dp = across / (sett.threadsPerRepeat * repeats)
/**
* Draws a tartan by laying the sett down vertically as warp, then across as
* weft at half opacity. The overlap is what produces the blended squares and
* the sense of a woven cloth rather than a printed grid — red warp under black
* weft comes out a darker red, exactly as it does on the loom.
*
* [rotationDegrees] turns the cloth on the bias. The stripes are drawn well
* past the edges of the canvas so the corners stay covered once it is turned.
*/
fun DrawScope.drawTartan(
sett: List<TartanStripe> = WallaceSett,
threadSize: Float,
rotationDegrees: Float = 0f,
) {
if (sett.isEmpty() || threadSize <= 0f) return
val repeat = sett.sumOf { it.threads } * threadSize
if (repeat <= 0f) return
// Turning a square by 45 degrees needs about 0.21 of its size spare on
// every edge; three quarters is generous enough for any angle.
val pad = if (rotationDegrees == 0f) 0f else size.maxDimension * 0.75f
val left = -pad
val top = -pad
val right = size.width + pad
val bottom = size.height + pad
rotate(degrees = rotationDegrees) {
// Warp: full-height bands.
var x = 0f
while (x < size.width) {
var x = left - (left.mod(repeat))
while (x < right) {
var offset = x
for (stripe in sett) {
val width = stripe.threads * threadSize
drawRect(
color = stripe.color,
topLeft = Offset(offset, 0f),
size = Size(width, size.height),
topLeft = Offset(offset, top),
size = Size(width, bottom - top),
)
offset += width
}
@@ -65,21 +111,22 @@ fun DrawScope.drawTartan(
}
// Weft: full-width bands, half opacity so the warp shows through.
var y = 0f
while (y < size.height) {
var y = top - (top.mod(repeat))
while (y < bottom) {
var offset = y
for (stripe in sett) {
val height = stripe.threads * threadSize
drawRect(
color = stripe.color.copy(alpha = 0.5f),
topLeft = Offset(0f, offset),
size = Size(size.width, height),
topLeft = Offset(left, offset),
size = Size(right - left, height),
)
offset += height
}
y += repeat
}
}
}
/**
* A tartan fill. [threadWidth] sets the weave scale, so the same cloth can be
@@ -90,8 +137,13 @@ fun TartanBackground(
modifier: Modifier = Modifier,
sett: List<TartanStripe> = WallaceSett,
threadWidth: Dp = 2.dp,
rotationDegrees: Float = 0f,
) {
Canvas(modifier = modifier) {
drawTartan(sett = sett, threadSize = threadWidth.toPx())
drawTartan(
sett = sett,
threadSize = threadWidth.toPx(),
rotationDegrees = rotationDegrees,
)
}
}
@@ -1,59 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
A single square of the Wallace tartan.
A single square of the Wallace tartan, set on the bias.
The sett is laid down as warp (opaque, vertical) over a black ground, then as
weft (half opacity, horizontal), which is what produces the blended squares
rather than a flat grid. One full repeat is 72 units wide, so the centre
72x72 safe zone of the adaptive icon shows exactly one repeat.
Generated from the published threadcount K/4 R32 K32 Y/4 — the same numbers
as WallaceSett in Tartan.kt. The slashes are pivots, so the half-sett
reflects out to K4 R32 K32 Y4 K32 R32: the yellow overstripe runs down the
centre of a wide black band, and the narrow black guard splits the red into
pairs.
Thread proportions match WallaceSett in Tartan.kt — see the note there about
these being a visual approximation rather than the registered threadcount.
Warp is opaque, weft laid over it at half opacity, which is what gives the
blended squares rather than a flat grid. Paths run well past the viewport
because the group is rotated 45 degrees; the overspill keeps the corners
covered. One full repeat fills the adaptive icon's safe zone.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Ground -->
<path
android:fillColor="#111111"
android:pathData="M0,0h108v108h-108z" />
<!-- Warp: red -->
<path
android:fillColor="#B3121B"
android:pathData="M0,0h34.29v108h-34.29z" />
<path
android:fillColor="#B3121B"
android:pathData="M72,0h34.29v108h-34.29z" />
<!-- Warp: gold overstripe -->
<path
android:fillColor="#F2C200"
android:pathData="M68.57,0h3.43v108h-3.43z" />
<!-- Weft: red -->
<path
android:fillColor="#B3121B"
android:fillAlpha="0.5"
android:pathData="M0,0h108v34.29h-108z" />
<path
android:fillColor="#B3121B"
android:fillAlpha="0.5"
android:pathData="M0,72h108v34.29h-108z" />
<!-- Weft: black -->
<path
android:fillColor="#111111"
android:fillAlpha="0.5"
android:pathData="M0,34.29h108v34.28h-108z" />
<path
android:fillColor="#111111"
android:fillAlpha="0.5"
android:pathData="M0,106.29h108v1.71h-108z" />
<!-- Weft: gold overstripe -->
<path
android:fillColor="#F2C200"
android:fillAlpha="0.5"
android:pathData="M0,68.57h108v3.43h-108z" />
<group
android:rotation="45"
android:pivotX="54.0"
android:pivotY="54.0">
<path android:fillColor="#0C0C0C" android:pathData="M-90.00,-90.00h288.00v288.00h-288.00z" />
<path android:fillColor="#E01B24" android:pathData="M-88.94,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#E01B24" android:pathData="M-69.88,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#F2C518" android:pathData="M-36.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="#E01B24" android:pathData="M-16.94,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#E01B24" android:pathData="M2.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#F2C518" android:pathData="M36.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="#E01B24" android:pathData="M55.06,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#E01B24" android:pathData="M74.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#F2C518" android:pathData="M108.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="#E01B24" android:pathData="M127.06,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#E01B24" android:pathData="M146.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#F2C518" android:pathData="M180.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,-105.88h288.00v16.94h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,-88.94h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,-72.00h288.00v2.12h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,-69.88h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,-52.94h288.00v16.94h-288.00z" />
<path android:fillColor="#F2C518" android:fillAlpha="0.5" android:pathData="M-90.00,-36.00h288.00v2.12h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,-33.88h288.00v16.94h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,-16.94h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,0.00h288.00v2.12h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,2.12h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,19.06h288.00v16.94h-288.00z" />
<path android:fillColor="#F2C518" android:fillAlpha="0.5" android:pathData="M-90.00,36.00h288.00v2.12h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,38.12h288.00v16.94h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,55.06h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,72.00h288.00v2.12h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,74.12h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,91.06h288.00v16.94h-288.00z" />
<path android:fillColor="#F2C518" android:fillAlpha="0.5" android:pathData="M-90.00,108.00h288.00v2.12h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,110.12h288.00v16.94h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,127.06h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,144.00h288.00v2.12h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,146.12h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,163.06h288.00v16.94h-288.00z" />
<path android:fillColor="#F2C518" android:fillAlpha="0.5" android:pathData="M-90.00,180.00h288.00v2.12h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,182.12h288.00v16.94h-288.00z" />
</group>
</vector>
+59 -16
View File
@@ -1,21 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Fallback launcher icon for API < 26, which has no adaptive-icon support.
Same Wallace tartan swatch as drawable/ic_launcher_background.xml, drawn
at icon scale so one full sett repeat fills the tile. -->
<!--
A single square of the Wallace tartan, set on the bias.
Generated from the published threadcount K/4 R32 K32 Y/4 — the same numbers
as WallaceSett in Tartan.kt. The slashes are pivots, so the half-sett
reflects out to K4 R32 K32 Y4 K32 R32: the yellow overstripe runs down the
centre of a wide black band, and the narrow black guard splits the red into
pairs.
Warp is opaque, weft laid over it at half opacity, which is what gives the
blended squares rather than a flat grid. Paths run well past the viewport
because the group is rotated 45 degrees; the overspill keeps the corners
covered. One full repeat fills the adaptive icon's safe zone.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="84"
android:viewportHeight="84">
<path android:fillColor="#111111" android:pathData="M0,0h84v84h-84z" />
<!-- Warp -->
<path android:fillColor="#B3121B" android:pathData="M0,0h40v84h-40z" />
<path android:fillColor="#F2C200" android:pathData="M80,0h4v84h-4z" />
<!-- Weft -->
<path android:fillColor="#B3121B" android:fillAlpha="0.5" android:pathData="M0,0h84v40h-84z" />
<path android:fillColor="#111111" android:fillAlpha="0.5" android:pathData="M0,40h84v40h-84z" />
<path android:fillColor="#F2C200" android:fillAlpha="0.5" android:pathData="M0,80h84v4h-84z" />
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:rotation="45"
android:pivotX="54.0"
android:pivotY="54.0">
<path android:fillColor="#0C0C0C" android:pathData="M-90.00,-90.00h288.00v288.00h-288.00z" />
<path android:fillColor="#E01B24" android:pathData="M-88.94,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#E01B24" android:pathData="M-69.88,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#F2C518" android:pathData="M-36.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="#E01B24" android:pathData="M-16.94,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#E01B24" android:pathData="M2.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#F2C518" android:pathData="M36.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="#E01B24" android:pathData="M55.06,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#E01B24" android:pathData="M74.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#F2C518" android:pathData="M108.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="#E01B24" android:pathData="M127.06,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#E01B24" android:pathData="M146.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#F2C518" android:pathData="M180.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,-105.88h288.00v16.94h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,-88.94h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,-72.00h288.00v2.12h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,-69.88h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,-52.94h288.00v16.94h-288.00z" />
<path android:fillColor="#F2C518" android:fillAlpha="0.5" android:pathData="M-90.00,-36.00h288.00v2.12h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,-33.88h288.00v16.94h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,-16.94h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,0.00h288.00v2.12h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,2.12h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,19.06h288.00v16.94h-288.00z" />
<path android:fillColor="#F2C518" android:fillAlpha="0.5" android:pathData="M-90.00,36.00h288.00v2.12h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,38.12h288.00v16.94h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,55.06h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,72.00h288.00v2.12h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,74.12h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,91.06h288.00v16.94h-288.00z" />
<path android:fillColor="#F2C518" android:fillAlpha="0.5" android:pathData="M-90.00,108.00h288.00v2.12h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,110.12h288.00v16.94h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,127.06h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,144.00h288.00v2.12h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,146.12h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,163.06h288.00v16.94h-288.00z" />
<path android:fillColor="#F2C518" android:fillAlpha="0.5" android:pathData="M-90.00,180.00h288.00v2.12h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,182.12h288.00v16.94h-288.00z" />
</group>
</vector>
+59 -16
View File
@@ -1,21 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Fallback launcher icon for API < 26, which has no adaptive-icon support.
Same Wallace tartan swatch as drawable/ic_launcher_background.xml, drawn
at icon scale so one full sett repeat fills the tile. -->
<!--
A single square of the Wallace tartan, set on the bias.
Generated from the published threadcount K/4 R32 K32 Y/4 — the same numbers
as WallaceSett in Tartan.kt. The slashes are pivots, so the half-sett
reflects out to K4 R32 K32 Y4 K32 R32: the yellow overstripe runs down the
centre of a wide black band, and the narrow black guard splits the red into
pairs.
Warp is opaque, weft laid over it at half opacity, which is what gives the
blended squares rather than a flat grid. Paths run well past the viewport
because the group is rotated 45 degrees; the overspill keeps the corners
covered. One full repeat fills the adaptive icon's safe zone.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="84"
android:viewportHeight="84">
<path android:fillColor="#111111" android:pathData="M0,0h84v84h-84z" />
<!-- Warp -->
<path android:fillColor="#B3121B" android:pathData="M0,0h40v84h-40z" />
<path android:fillColor="#F2C200" android:pathData="M80,0h4v84h-4z" />
<!-- Weft -->
<path android:fillColor="#B3121B" android:fillAlpha="0.5" android:pathData="M0,0h84v40h-84z" />
<path android:fillColor="#111111" android:fillAlpha="0.5" android:pathData="M0,40h84v40h-84z" />
<path android:fillColor="#F2C200" android:fillAlpha="0.5" android:pathData="M0,80h84v4h-84z" />
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:rotation="45"
android:pivotX="54.0"
android:pivotY="54.0">
<path android:fillColor="#0C0C0C" android:pathData="M-90.00,-90.00h288.00v288.00h-288.00z" />
<path android:fillColor="#E01B24" android:pathData="M-88.94,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#E01B24" android:pathData="M-69.88,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#F2C518" android:pathData="M-36.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="#E01B24" android:pathData="M-16.94,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#E01B24" android:pathData="M2.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#F2C518" android:pathData="M36.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="#E01B24" android:pathData="M55.06,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#E01B24" android:pathData="M74.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#F2C518" android:pathData="M108.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="#E01B24" android:pathData="M127.06,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#E01B24" android:pathData="M146.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="#F2C518" android:pathData="M180.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,-105.88h288.00v16.94h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,-88.94h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,-72.00h288.00v2.12h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,-69.88h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,-52.94h288.00v16.94h-288.00z" />
<path android:fillColor="#F2C518" android:fillAlpha="0.5" android:pathData="M-90.00,-36.00h288.00v2.12h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,-33.88h288.00v16.94h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,-16.94h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,0.00h288.00v2.12h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,2.12h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,19.06h288.00v16.94h-288.00z" />
<path android:fillColor="#F2C518" android:fillAlpha="0.5" android:pathData="M-90.00,36.00h288.00v2.12h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,38.12h288.00v16.94h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,55.06h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,72.00h288.00v2.12h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,74.12h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,91.06h288.00v16.94h-288.00z" />
<path android:fillColor="#F2C518" android:fillAlpha="0.5" android:pathData="M-90.00,108.00h288.00v2.12h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,110.12h288.00v16.94h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,127.06h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,144.00h288.00v2.12h-288.00z" />
<path android:fillColor="#E01B24" android:fillAlpha="0.5" android:pathData="M-90.00,146.12h288.00v16.94h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,163.06h288.00v16.94h-288.00z" />
<path android:fillColor="#F2C518" android:fillAlpha="0.5" android:pathData="M-90.00,180.00h288.00v2.12h-288.00z" />
<path android:fillColor="#0C0C0C" android:fillAlpha="0.5" android:pathData="M-90.00,182.12h288.00v16.94h-288.00z" />
</group>
</vector>
@@ -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(),
)
}
@@ -0,0 +1,457 @@
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)
/** Plays out the opening reveals so a test can start from the first draw. */
private fun afterSetup(state: GolfState): GolfState {
var s = state
var guard = 0
while (s.phase == GolfPhase.SETUP && guard++ < 200) {
val seat = GolfRules.currentSeat(s)!!
s = GolfRules.applyMove(s, seat, GolfRules.legalMoves(s, seat).first())
}
return s
}
// -- Scoring ------------------------------------------------------------
@Test
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 = afterSetup(
GolfRules.initialState(config(GolfOptions(playerCount = 3, gridSize = 6)))
)
assertEquals(3, state.grids.size)
state.grids.forEach { assertEquals(6, it.size) }
state.revealed.forEach { assertEquals(2, it.count { shown -> shown }) }
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)
}
@Test
fun `the eight card grid is two rows of four`() {
val options = GolfOptions(gridSize = 8)
assertEquals(2, options.rows)
assertEquals(4, options.cols)
// Columns are (0,4) (1,5) (2,6) (3,7); the queens cancel.
val grid = listOf(
card(Rank.QUEEN), card(Rank.THREE), card(Rank.FOUR), card(Rank.KING),
card(Rank.QUEEN, Suit.HEARTS), card(Rank.FIVE), card(Rank.SIX), card(Rank.SEVEN),
)
assertEquals(25, scoreGrid(grid, options))
}
// -- Choosing the opening cards -----------------------------------------
@Test
fun `a hole starts with everything face down and a choice to make`() {
val state = GolfRules.initialState(config(GolfOptions(playerCount = 3, gridSize = 6)))
assertEquals(GolfPhase.SETUP, state.phase)
assertTrue("nothing is dealt face up", state.revealed.all { row -> row.none { it } })
assertEquals("every card is a candidate", 6, GolfRules.legalMoves(state, state.turn).size)
}
@Test
fun `each player picks their own openers in turn`() {
var state = GolfRules.initialState(config(GolfOptions(playerCount = 2, gridSize = 6)))
val first = state.turn
state = GolfRules.applyMove(state, first, RevealCard(3))
assertTrue("the chosen card is the one turned over", state.revealed[first][3])
assertEquals("still choosing", first, state.turn)
state = GolfRules.applyMove(state, first, RevealCard(5))
assertEquals("allowance used, next player chooses", (first + 1) % 2, state.turn)
assertEquals(GolfPhase.SETUP, state.phase)
val second = state.turn
state = GolfRules.applyMove(state, second, RevealCard(0))
state = GolfRules.applyMove(state, second, RevealCard(1))
assertEquals("everyone has chosen, so play begins", GolfPhase.DRAW, state.phase)
}
@Test
fun `with no opening reveals the hole starts straight away`() {
val state = GolfRules.initialState(
config(GolfOptions(playerCount = 2, gridSize = 4, startingReveals = 0))
)
assertEquals(GolfPhase.DRAW, state.phase)
}
// -- Lining up the final putt -------------------------------------------
@Test
fun `lining up the putt is only offered on the last face-down card`() {
val options = GolfOptions(
playerCount = 2, gridSize = 4, startingReveals = 2,
holes = 1, lineUpFinalPutt = true,
)
var state = afterSetup(GolfRules.initialState(config(options)))
state = GolfRules.applyMove(state, 0, DrawFromStock)
assertEquals("two cards still face down", 2, state.faceDownCount(0))
assertFalse("not yet — this is not the final putt",
GolfRules.legalMoves(state, 0).contains(DiscardOnly))
// Turn one over, leaving exactly one down, and come back round.
state = GolfRules.applyMove(state, 0, DiscardAndFlip(2))
state = GolfRules.applyMove(state, 1, DrawFromStock)
state = GolfRules.applyMove(state, 1, ReplaceCard(0))
state = GolfRules.applyMove(state, 0, DrawFromStock)
assertEquals(1, state.faceDownCount(0))
assertTrue("now it is offered", GolfRules.legalMoves(state, 0).contains(DiscardOnly))
}
@Test
fun `lining up the putt keeps the last card down and does not close the hole`() {
val options = GolfOptions(
playerCount = 2, gridSize = 4, startingReveals = 3,
holes = 1, lineUpFinalPutt = true,
)
var state = afterSetup(GolfRules.initialState(config(options)))
state = GolfRules.applyMove(state, 0, DrawFromStock)
val drawn = state.drawn!!
state = GolfRules.applyMove(state, 0, DiscardOnly)
assertEquals("the card is thrown", drawn, state.discard.last())
assertEquals("the last card stays down", 1, state.faceDownCount(0))
assertEquals("nobody has closed", null, state.closedBy)
assertEquals("turn passes anyway", 1, state.turn)
}
@Test
fun `the option is off by default so nobody can stall`() {
val options = GolfOptions(playerCount = 2, gridSize = 4, startingReveals = 3, holes = 1)
var state = afterSetup(GolfRules.initialState(config(options)))
state = GolfRules.applyMove(state, 0, DrawFromStock)
assertFalse(GolfRules.legalMoves(state, 0).contains(DiscardOnly))
}
@Test
fun `a table that refuses to putt out still ends`() {
// Everyone declines forever; the hole is called rather than hanging.
val options = GolfOptions(
playerCount = 2, gridSize = 4, startingReveals = 3,
holes = 1, lineUpFinalPutt = true,
)
var state = afterSetup(GolfRules.initialState(config(options)))
var guard = 0
while (!GolfRules.isFinished(state) && guard++ < 200) {
val seat = GolfRules.currentSeat(state) ?: break
val legal = GolfRules.legalMoves(state, seat)
// Always draw, and always decline to close when allowed.
val move = legal.firstOrNull { it == DiscardOnly }
?: legal.firstOrNull { it == DrawFromStock }
?: legal.first()
state = GolfRules.applyMove(state, seat, move)
}
assertTrue("the stalemate guard has to end it", GolfRules.isFinished(state))
}
// -- Turn structure -----------------------------------------------------
@Test
fun `a turn is draw then place`() {
var state = afterSetup(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 = afterSetup(GolfRules.initialState(config(GolfOptions(playerCount = 2))))
state = GolfRules.applyMove(state, 0, DrawFromDiscard)
val moves = GolfRules.legalMoves(state, 0)
assertTrue("no throwing it back", moves.none { it is DiscardAndFlip })
assertTrue(moves.all { it is ReplaceCard })
}
@Test
fun `replacing a card turns that slot face up and discards the old one`() {
var state = afterSetup(GolfRules.initialState(config(GolfOptions(playerCount = 2))))
state = GolfRules.applyMove(state, 0, DrawFromStock)
val drawn = state.drawn!!
val replacedCard = state.grids[0][5]
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 = afterSetup(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 = afterSetup(GolfRules.initialState(config(options)))
// Seat 0 has one card left face down; use it to close.
state = GolfRules.applyMove(state, 0, DrawFromStock)
state = GolfRules.applyMove(state, 0, DiscardAndFlip(3))
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 = afterSetup(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 = afterSetup(
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 = afterSetup(
GolfRules.initialState(config(GolfOptions(playerCount = 2, gridSize = 6), seed = 42L))
)
val view = GolfRules.viewFor(state, seat = 1)
val hiddenIndices = (0 until 6).filter { !state.revealed[1][it] }
hiddenIndices.forEach { index ->
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),
DiscardOnly, RevealCard(1),
).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)))
}
}
}
+85
View File
@@ -0,0 +1,85 @@
# 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 |
| 8 | 2 rows of 4 |
| 9 | 3 rows of 3 |
- **Seen at the start — *toggle*.** How many of your own cards you turn over
before play begins; two by default.
- One card is turned up to start the discard pile; the rest is the stock.
- Six players on the 9-card board needs more cards than a deck holds, so two
decks are shuffled together whenever the deal would exceed 24 cards.
## Choosing your openers
Nothing is dealt face up. Each player in turn picks which of their own cards to
turn over, up to the allowance above, and play begins once everyone has chosen.
Spreading them across columns tells you more about the board than stacking two
in the same column does.
## 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.
- **Line up the final putt — *toggle*, off by default.** Throw the card away and
turn *nothing* over. Only offered when you have exactly one card left face
down, so it is the difference between closing the hole on a card you have not
chosen and waiting for something better. If every player declines for two full
laps the hole is called and scored where it stands, so a table cannot stall
forever.
## Ending a hole
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.
At the end of each hole a score card shows every player's strokes for that hole
and their running total, lowest first. The next hole is not dealt until it is
dismissed.
## Known simplifications
- **No jokers.** Some house rules add them as −5 or wild. Adding a joker means
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.