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:
@@ -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,77 +7,124 @@ 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
|
||||
|
||||
// Warp: full-height bands.
|
||||
var x = 0f
|
||||
while (x < size.width) {
|
||||
var offset = x
|
||||
for (stripe in sett) {
|
||||
val width = stripe.threads * threadSize
|
||||
drawRect(
|
||||
color = stripe.color,
|
||||
topLeft = Offset(offset, 0f),
|
||||
size = Size(width, size.height),
|
||||
)
|
||||
offset += width
|
||||
}
|
||||
x += repeat
|
||||
}
|
||||
// Turning a square by 45 degrees needs about 0.21 of its size spare on
|
||||
// every edge; three quarters is generous enough for any angle.
|
||||
val pad = if (rotationDegrees == 0f) 0f else size.maxDimension * 0.75f
|
||||
val left = -pad
|
||||
val top = -pad
|
||||
val right = size.width + pad
|
||||
val bottom = size.height + pad
|
||||
|
||||
// Weft: full-width bands, half opacity so the warp shows through.
|
||||
var y = 0f
|
||||
while (y < size.height) {
|
||||
var offset = y
|
||||
for (stripe in sett) {
|
||||
val height = stripe.threads * threadSize
|
||||
drawRect(
|
||||
color = stripe.color.copy(alpha = 0.5f),
|
||||
topLeft = Offset(0f, offset),
|
||||
size = Size(size.width, height),
|
||||
)
|
||||
offset += height
|
||||
rotate(degrees = rotationDegrees) {
|
||||
// Warp: full-height bands.
|
||||
var x = left - (left.mod(repeat))
|
||||
while (x < right) {
|
||||
var offset = x
|
||||
for (stripe in sett) {
|
||||
val width = stripe.threads * threadSize
|
||||
drawRect(
|
||||
color = stripe.color,
|
||||
topLeft = Offset(offset, top),
|
||||
size = Size(width, bottom - top),
|
||||
)
|
||||
offset += width
|
||||
}
|
||||
x += repeat
|
||||
}
|
||||
|
||||
// Weft: full-width bands, half opacity so the warp shows through.
|
||||
var y = top - (top.mod(repeat))
|
||||
while (y < bottom) {
|
||||
var offset = y
|
||||
for (stripe in sett) {
|
||||
val height = stripe.threads * threadSize
|
||||
drawRect(
|
||||
color = stripe.color.copy(alpha = 0.5f),
|
||||
topLeft = Offset(left, offset),
|
||||
size = Size(right - left, height),
|
||||
)
|
||||
offset += height
|
||||
}
|
||||
y += repeat
|
||||
}
|
||||
y += repeat
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,8 +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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user