Add President and Golf
Two more games on the existing framework — no changes were needed to the lobby, networking, settings or update layers, which is what the shared GameRules/GameAi contract was for. President - Threes low, twos high. Lead any number of one rank; follow with the same count of a higher rank, or pass. When everyone has passed the pile comes down and the last player to play leads again. - Toggles: player count, rounds, twos clear the pile, four of a kind bombs, and the between-round card exchange. - Plays are offered as whole sets rather than by selecting cards. A set is the unit of the game, so choosing from the legal sets is both easier to operate and impossible to get wrong. Golf - 4, 6 or 9 card grids. Draw from stock or discard, then either swap the card in or — only if it came off the stock — throw it and turn one of your own over. A card taken from the discard pile has to be used. - Matching columns cancel to nothing; on the 3x3 board rows do too, but not on the shallower grids where three cards are not a line. - Face-down cards are hidden from everyone including their owner, which is the point of the game: viewFor masks them with a fixed placeholder so the real card never reaches the device that has not turned it over. Both engines are pure Kotlin and unit tested — 71 tests now, covering ranking, legal moves, pile and hole resolution, scoring, redaction, wire round-trips, and full random and AI playthroughs. The Golf test also checks the AI actually beats random play rather than merely being legal. Also widened the setup screen's option chips to wrap: the reveal-count row carries up to ten, and a chip off the edge of the screen is a setting that cannot be changed — the same failure as the Euchre Pass button. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
This commit is contained in:
@@ -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,93 @@
|
||||
package org.prolibertate.games.game.golf
|
||||
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
* A heuristic Golf opponent.
|
||||
*
|
||||
* It takes a card off the discard pile when it is cheap or completes a column,
|
||||
* replaces its worst known card, and turns cards over when it has nothing
|
||||
* better to do. It never peeks at a face-down card — including its own, which
|
||||
* it genuinely does not know.
|
||||
*/
|
||||
class GolfAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||
GameAi<GolfState, GolfMove> {
|
||||
|
||||
enum class Difficulty(
|
||||
/** Take a discard worth this or less rather than gambling on the stock. */
|
||||
val discardAppetite: Int,
|
||||
) {
|
||||
EASY(discardAppetite = 2),
|
||||
NORMAL(discardAppetite = 5),
|
||||
CAUTIOUS(discardAppetite = 7),
|
||||
}
|
||||
|
||||
override fun chooseMove(state: GolfState, seat: Int, legal: List<GolfMove>): GolfMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
return when (state.phase) {
|
||||
GolfPhase.DRAW -> chooseDraw(state, seat, legal)
|
||||
GolfPhase.PLACE -> choosePlace(state, seat, legal)
|
||||
else -> legal.first()
|
||||
}
|
||||
}
|
||||
|
||||
private fun chooseDraw(state: GolfState, seat: Int, legal: List<GolfMove>): GolfMove {
|
||||
val top = state.discard.lastOrNull()
|
||||
if (top != null && legal.contains(DrawFromDiscard)) {
|
||||
val worthTaking = golfValue(top) <= difficulty.discardAppetite ||
|
||||
completesColumn(state, seat, top) != null
|
||||
if (worthTaking) return DrawFromDiscard
|
||||
}
|
||||
return if (legal.contains(DrawFromStock)) DrawFromStock else legal.first()
|
||||
}
|
||||
|
||||
private fun choosePlace(state: GolfState, seat: Int, legal: List<GolfMove>): GolfMove {
|
||||
val drawn = state.drawn ?: return legal.first()
|
||||
val replacements = legal.filterIsInstance<ReplaceCard>()
|
||||
val flips = legal.filterIsInstance<DiscardAndFlip>()
|
||||
|
||||
// Completing a column wipes out both cards, which beats any swap on value.
|
||||
completesColumn(state, seat, drawn)?.let { index ->
|
||||
replacements.firstOrNull { it.index == index }?.let { return it }
|
||||
}
|
||||
|
||||
// Otherwise trade out the worst card we can actually see.
|
||||
val knownWorst = replacements
|
||||
.filter { state.revealed[seat][it.index] }
|
||||
.maxByOrNull { golfValue(state.grids[seat][it.index]) }
|
||||
|
||||
if (knownWorst != null) {
|
||||
val currentValue = golfValue(state.grids[seat][knownWorst.index])
|
||||
if (golfValue(drawn) < currentValue) return knownWorst
|
||||
}
|
||||
|
||||
// Nothing worth swapping: throw it and turn something over instead,
|
||||
// which at least buys information.
|
||||
if (flips.isNotEmpty()) return flips.first()
|
||||
|
||||
// Forced to place it — cover a face-down card rather than a good one.
|
||||
val hidden = replacements.firstOrNull { !state.revealed[seat][it.index] }
|
||||
return hidden ?: knownWorst ?: replacements.first()
|
||||
}
|
||||
|
||||
/** A grid slot where this card would complete a matching column. */
|
||||
private fun completesColumn(
|
||||
state: GolfState,
|
||||
seat: Int,
|
||||
card: org.prolibertate.games.game.cards.Card,
|
||||
): Int? {
|
||||
val options = state.options
|
||||
for (col in 0 until options.cols) {
|
||||
val line = (0 until options.rows).map { it * options.cols + col }
|
||||
// Every other cell in the column must already show this rank.
|
||||
for (target in line) {
|
||||
val others = line.filter { it != target }
|
||||
val allMatch = others.all { index ->
|
||||
state.revealed[seat][index] && state.grids[seat][index].rank == card.rank
|
||||
}
|
||||
if (allMatch && others.isNotEmpty()) return target
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package org.prolibertate.games.game.golf
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Rank
|
||||
import org.prolibertate.games.game.cards.Suit
|
||||
|
||||
@Serializable
|
||||
data class GolfOptions(
|
||||
val playerCount: Int = 2,
|
||||
/** Cards in front of each player: 4 (2x2), 6 (2x3) or 9 (3x3). */
|
||||
val gridSize: Int = 6,
|
||||
val holes: Int = 9,
|
||||
/** How many of your own cards you get to see before play starts. */
|
||||
val startingReveals: Int = 2,
|
||||
) {
|
||||
init {
|
||||
require(playerCount in 2..6) { "Golf seats two to six" }
|
||||
require(gridSize == 4 || gridSize == 6 || gridSize == 9) {
|
||||
"Golf grids are 4, 6 or 9 cards"
|
||||
}
|
||||
require(holes >= 1) { "holes must be positive" }
|
||||
require(startingReveals in 0..gridSize) { "cannot reveal more than the grid holds" }
|
||||
}
|
||||
|
||||
val rows: Int get() = if (gridSize == 9) 3 else 2
|
||||
val cols: Int get() = gridSize / rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Golf scoring: kings are free, twos are worth less than nothing, and the
|
||||
* picture cards hurt.
|
||||
*/
|
||||
fun golfValue(card: Card): Int = when (card.rank) {
|
||||
Rank.KING -> 0
|
||||
Rank.ACE -> 1
|
||||
Rank.TWO -> -2
|
||||
Rank.JACK, Rank.QUEEN -> 10
|
||||
else -> card.rank.order
|
||||
}
|
||||
|
||||
/**
|
||||
* Scores one grid.
|
||||
*
|
||||
* A column of matching ranks cancels to nothing, which is the whole tactical
|
||||
* point of the game. On the 3x3 board rows cancel too; on the shallower boards
|
||||
* a "row" of three unrelated cards is not a line at all, so only columns count.
|
||||
*/
|
||||
fun scoreGrid(cards: List<Card>, options: GolfOptions): Int {
|
||||
val rows = options.rows
|
||||
val cols = options.cols
|
||||
val cancelled = BooleanArray(cards.size)
|
||||
|
||||
for (col in 0 until cols) {
|
||||
val line = (0 until rows).map { it * cols + col }
|
||||
if (line.map { cards[it].rank }.distinct().size == 1) {
|
||||
line.forEach { cancelled[it] = true }
|
||||
}
|
||||
}
|
||||
if (rows == 3) {
|
||||
for (row in 0 until rows) {
|
||||
val line = (0 until cols).map { row * cols + it }
|
||||
if (line.map { cards[it].rank }.distinct().size == 1) {
|
||||
line.forEach { cancelled[it] = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cards.indices.sumOf { if (cancelled[it]) 0 else golfValue(cards[it]) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stands in for a face-down card in a redacted view.
|
||||
*
|
||||
* Grids always hold a real card so the host can score without null handling;
|
||||
* a view for a given seat swaps every unrevealed card for this one. It is never
|
||||
* drawn, because the UI only paints a face where `revealed` is true — and
|
||||
* substituting a fixed card rather than the real one is what stops a client
|
||||
* learning what it has not yet turned over.
|
||||
*/
|
||||
val HIDDEN_CARD: Card = Card(Rank.TWO, Suit.SPADES)
|
||||
|
||||
@Serializable
|
||||
enum class GolfPhase { DRAW, PLACE, HOLE_OVER, GAME_OVER }
|
||||
|
||||
@Serializable
|
||||
data class GolfState(
|
||||
val options: GolfOptions,
|
||||
val seed: Long,
|
||||
val hole: Int,
|
||||
val grids: List<List<Card>>,
|
||||
val revealed: List<List<Boolean>>,
|
||||
val stock: List<Card>,
|
||||
val discard: List<Card>,
|
||||
/** The card picked up this turn, waiting to be placed or thrown. */
|
||||
val drawn: Card?,
|
||||
/** Taken from the discard pile, which means it has to be used. */
|
||||
val drawnFromDiscard: Boolean,
|
||||
val turn: Int,
|
||||
/** First seat to turn its whole grid face up. */
|
||||
val closedBy: Int?,
|
||||
/** Turns left for everyone else once someone has closed. */
|
||||
val finalTurnsLeft: Int,
|
||||
val holeScores: List<Int>,
|
||||
val scores: List<Int>,
|
||||
val phase: GolfPhase,
|
||||
val log: List<String>,
|
||||
) {
|
||||
fun allRevealed(seat: Int): Boolean = revealed[seat].all { it }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed interface GolfMove
|
||||
|
||||
@Serializable
|
||||
@SerialName("draw_stock")
|
||||
data object DrawFromStock : GolfMove
|
||||
|
||||
@Serializable
|
||||
@SerialName("draw_discard")
|
||||
data object DrawFromDiscard : GolfMove
|
||||
|
||||
/** Put the drawn card into the grid, throwing out what was there. */
|
||||
@Serializable
|
||||
@SerialName("replace")
|
||||
data class ReplaceCard(val index: Int) : GolfMove
|
||||
|
||||
/** Throw the drawn card away and turn one of your own cards face up instead. */
|
||||
@Serializable
|
||||
@SerialName("discard_flip")
|
||||
data class DiscardAndFlip(val index: Int) : GolfMove
|
||||
@@ -0,0 +1,283 @@
|
||||
package org.prolibertate.games.game.golf
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Decks
|
||||
import org.prolibertate.games.game.cards.shuffledWith
|
||||
import org.prolibertate.games.game.engine.GameRules
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Golf as a pure state machine. See RULES-golf.md.
|
||||
*/
|
||||
object GolfRules : GameRules<GolfState, GolfMove> {
|
||||
|
||||
override val gameId: String = GameCatalog.GOLF
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
override fun initialState(config: TableConfig): GolfState {
|
||||
val options = json.decodeFromString<GolfOptions>(config.optionsJson)
|
||||
require(config.seats.size == options.playerCount) {
|
||||
"Seat count ${config.seats.size} does not match ${options.playerCount} players"
|
||||
}
|
||||
return deal(
|
||||
options = options,
|
||||
seed = config.seed,
|
||||
hole = 0,
|
||||
scores = List(options.playerCount) { 0 },
|
||||
log = listOf("Hole 1."),
|
||||
)
|
||||
}
|
||||
|
||||
private fun deal(
|
||||
options: GolfOptions,
|
||||
seed: Long,
|
||||
hole: Int,
|
||||
scores: List<Int>,
|
||||
log: List<String>,
|
||||
): GolfState {
|
||||
val random = Random(seed + hole * 7919L)
|
||||
// A big table needs more than one deck to keep a usable stock.
|
||||
val needed = options.playerCount * options.gridSize
|
||||
val source = if (needed > 24) Decks.double52() else Decks.standard52()
|
||||
var deck = source.shuffledWith(random)
|
||||
|
||||
val grids = mutableListOf<List<Card>>()
|
||||
repeat(options.playerCount) {
|
||||
grids += deck.take(options.gridSize)
|
||||
deck = deck.drop(options.gridSize)
|
||||
}
|
||||
|
||||
// The opening reveals are the leftmost cards of the top row, so every
|
||||
// player starts knowing the same shape of their own board.
|
||||
val revealed = List(options.playerCount) {
|
||||
List(options.gridSize) { index -> index < options.startingReveals }
|
||||
}
|
||||
|
||||
val firstDiscard = deck.first()
|
||||
return GolfState(
|
||||
options = options,
|
||||
seed = seed,
|
||||
hole = hole,
|
||||
grids = grids,
|
||||
revealed = revealed,
|
||||
stock = deck.drop(1),
|
||||
discard = listOf(firstDiscard),
|
||||
drawn = null,
|
||||
drawnFromDiscard = false,
|
||||
turn = hole % options.playerCount,
|
||||
closedBy = null,
|
||||
finalTurnsLeft = 0,
|
||||
holeScores = List(options.playerCount) { 0 },
|
||||
scores = scores,
|
||||
phase = GolfPhase.DRAW,
|
||||
log = log,
|
||||
)
|
||||
}
|
||||
|
||||
/** Advances from [GolfPhase.HOLE_OVER] to the next hole. */
|
||||
fun nextHole(state: GolfState): GolfState {
|
||||
check(state.phase == GolfPhase.HOLE_OVER) { "Hole is not over" }
|
||||
return deal(
|
||||
options = state.options,
|
||||
seed = state.seed,
|
||||
hole = state.hole + 1,
|
||||
scores = state.scores,
|
||||
log = state.log + "Hole ${state.hole + 2}.",
|
||||
)
|
||||
}
|
||||
|
||||
override fun currentSeat(state: GolfState): Int? = when (state.phase) {
|
||||
GolfPhase.DRAW, GolfPhase.PLACE -> state.turn
|
||||
GolfPhase.HOLE_OVER, GolfPhase.GAME_OVER -> null
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Legal moves
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
override fun legalMoves(state: GolfState, seat: Int): List<GolfMove> {
|
||||
if (currentSeat(state) != seat) return emptyList()
|
||||
return when (state.phase) {
|
||||
GolfPhase.DRAW -> buildList {
|
||||
if (state.stock.isNotEmpty()) add(DrawFromStock)
|
||||
if (state.discard.isNotEmpty()) add(DrawFromDiscard)
|
||||
}
|
||||
|
||||
GolfPhase.PLACE -> buildList {
|
||||
for (index in 0 until state.options.gridSize) add(ReplaceCard(index))
|
||||
// A card taken from the discard pile has to be used; only a
|
||||
// card off the stock may be thrown away again.
|
||||
if (!state.drawnFromDiscard) {
|
||||
for (index in 0 until state.options.gridSize) {
|
||||
if (!state.revealed[seat][index]) add(DiscardAndFlip(index))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GolfPhase.HOLE_OVER, GolfPhase.GAME_OVER -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Move application
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
override fun applyMove(state: GolfState, seat: Int, move: GolfMove): GolfState {
|
||||
require(currentSeat(state) == seat) { "Not seat $seat's turn" }
|
||||
require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" }
|
||||
return when (move) {
|
||||
is DrawFromStock -> state.copy(
|
||||
drawn = state.stock.first(),
|
||||
drawnFromDiscard = false,
|
||||
stock = state.stock.drop(1),
|
||||
phase = GolfPhase.PLACE,
|
||||
)
|
||||
|
||||
is DrawFromDiscard -> state.copy(
|
||||
drawn = state.discard.last(),
|
||||
drawnFromDiscard = true,
|
||||
discard = state.discard.dropLast(1),
|
||||
phase = GolfPhase.PLACE,
|
||||
)
|
||||
|
||||
is ReplaceCard -> applyReplace(state, seat, move.index)
|
||||
is DiscardAndFlip -> applyDiscardAndFlip(state, seat, move.index)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyReplace(state: GolfState, seat: Int, index: Int): GolfState {
|
||||
val drawn = requireNotNull(state.drawn) { "Nothing drawn" }
|
||||
val grids = state.grids.map { it.toMutableList() }
|
||||
val revealed = state.revealed.map { it.toMutableList() }
|
||||
|
||||
val replaced = grids[seat][index]
|
||||
grids[seat][index] = drawn
|
||||
// Whatever goes into the grid is placed face up.
|
||||
revealed[seat][index] = true
|
||||
|
||||
return endTurn(
|
||||
state.copy(
|
||||
grids = grids,
|
||||
revealed = revealed,
|
||||
discard = state.discard + replaced,
|
||||
drawn = null,
|
||||
log = state.log + "Seat $seat takes ${drawn.label}.",
|
||||
),
|
||||
seat,
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyDiscardAndFlip(state: GolfState, seat: Int, index: Int): GolfState {
|
||||
val drawn = requireNotNull(state.drawn) { "Nothing drawn" }
|
||||
val revealed = state.revealed.map { it.toMutableList() }
|
||||
revealed[seat][index] = true
|
||||
|
||||
return endTurn(
|
||||
state.copy(
|
||||
revealed = revealed,
|
||||
discard = state.discard + drawn,
|
||||
drawn = null,
|
||||
log = state.log + "Seat $seat throws ${drawn.label} and turns one over.",
|
||||
),
|
||||
seat,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes out a turn: notices a finished grid, counts down the last lap and
|
||||
* either moves on or scores the hole.
|
||||
*/
|
||||
private fun endTurn(state: GolfState, seat: Int): GolfState {
|
||||
var next = state
|
||||
|
||||
// The first player to turn everything over gives everyone else one
|
||||
// more turn, and no more than one.
|
||||
if (next.closedBy == null && next.allRevealed(seat)) {
|
||||
next = next.copy(
|
||||
closedBy = seat,
|
||||
finalTurnsLeft = next.options.playerCount - 1,
|
||||
log = next.log + "Seat $seat is out — one turn each to answer.",
|
||||
)
|
||||
} else if (next.closedBy != null) {
|
||||
next = next.copy(finalTurnsLeft = (next.finalTurnsLeft - 1).coerceAtLeast(0))
|
||||
}
|
||||
|
||||
if (next.closedBy != null && next.finalTurnsLeft <= 0) return scoreHole(next)
|
||||
|
||||
// An empty stock is refilled from the discards, keeping the top card
|
||||
// in play. If there is nothing to recycle, the hole ends where it is.
|
||||
var stock = next.stock
|
||||
var discard = next.discard
|
||||
if (stock.isEmpty()) {
|
||||
if (discard.size <= 1) return scoreHole(next)
|
||||
val top = discard.last()
|
||||
val random = Random(next.seed + next.log.size)
|
||||
stock = discard.dropLast(1).shuffledWith(random)
|
||||
discard = listOf(top)
|
||||
}
|
||||
|
||||
return next.copy(
|
||||
stock = stock,
|
||||
discard = discard,
|
||||
turn = (seat + 1) % next.options.playerCount,
|
||||
phase = GolfPhase.DRAW,
|
||||
)
|
||||
}
|
||||
|
||||
private fun scoreHole(state: GolfState): GolfState {
|
||||
val holeScores = (0 until state.options.playerCount).map { seat ->
|
||||
scoreGrid(state.grids[seat], state.options)
|
||||
}
|
||||
val scores = state.scores.mapIndexed { seat, total -> total + holeScores[seat] }
|
||||
// Everything is turned face up to be counted.
|
||||
val revealed = List(state.options.playerCount) { List(state.options.gridSize) { true } }
|
||||
|
||||
val lastHole = state.hole + 1 >= state.options.holes
|
||||
return state.copy(
|
||||
revealed = revealed,
|
||||
holeScores = holeScores,
|
||||
scores = scores,
|
||||
drawn = null,
|
||||
phase = if (lastHole) GolfPhase.GAME_OVER else GolfPhase.HOLE_OVER,
|
||||
log = state.log + "Hole ${state.hole + 1}: " +
|
||||
holeScores.mapIndexed { seat, s -> "S$seat $s" }.joinToString(", "),
|
||||
)
|
||||
}
|
||||
|
||||
override fun isFinished(state: GolfState): Boolean = state.phase == GolfPhase.GAME_OVER
|
||||
|
||||
override fun summary(state: GolfState): String = when (state.phase) {
|
||||
GolfPhase.GAME_OVER -> {
|
||||
val best = state.scores.indices.minByOrNull { state.scores[it] } ?: 0
|
||||
"Seat $best wins on ${state.scores[best]}"
|
||||
}
|
||||
|
||||
else -> state.scores.mapIndexed { seat, s -> "S$seat:$s" }.joinToString(" ")
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides every face-down card — including the viewer's own, which is the
|
||||
* whole point of the game.
|
||||
*/
|
||||
override fun viewFor(state: GolfState, seat: Int): GolfState = state.copy(
|
||||
grids = state.grids.mapIndexed { player, cards ->
|
||||
cards.mapIndexed { index, card ->
|
||||
if (state.revealed[player][index]) card else HIDDEN_CARD
|
||||
}
|
||||
},
|
||||
stock = emptyList(),
|
||||
)
|
||||
|
||||
override fun encodeState(state: GolfState): String = json.encodeToString(state)
|
||||
override fun decodeState(json: String): GolfState = this.json.decodeFromString(json)
|
||||
override fun encodeMove(move: GolfMove): String = json.encodeToString(move)
|
||||
override fun decodeMove(json: String): GolfMove = this.json.decodeFromString(json)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package org.prolibertate.games.game.president
|
||||
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
* A heuristic President opponent.
|
||||
*
|
||||
* It sheds low cards early, spends the cheapest set that takes the pile, and
|
||||
* holds twos back for when they are worth something. It does not count cards.
|
||||
*/
|
||||
class PresidentAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||
GameAi<PresidentState, PresidentMove> {
|
||||
|
||||
enum class Difficulty(
|
||||
/** Rank above which a card is hoarded rather than spent to lead. */
|
||||
val hoardAbove: Int,
|
||||
) {
|
||||
/** Plays whatever is cheapest, whenever it can. */
|
||||
EASY(hoardAbove = 99),
|
||||
|
||||
NORMAL(hoardAbove = 13),
|
||||
|
||||
/** Sits on high cards until late, then dumps them. */
|
||||
CAUTIOUS(hoardAbove = 11),
|
||||
}
|
||||
|
||||
override fun chooseMove(
|
||||
state: PresidentState,
|
||||
seat: Int,
|
||||
legal: List<PresidentMove>,
|
||||
): PresidentMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
val plays = legal.filterIsInstance<PlayCards>()
|
||||
if (plays.isEmpty()) return legal.first()
|
||||
|
||||
return if (state.setSize == 0) chooseLead(state, seat, plays) else chooseFollow(state, plays, legal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Leading is a chance to shed. Prefer the biggest set of the lowest rank,
|
||||
* which clears junk fastest without spending anything valuable.
|
||||
*/
|
||||
private fun chooseLead(
|
||||
state: PresidentState,
|
||||
seat: Int,
|
||||
plays: List<PlayCards>,
|
||||
): PresidentMove {
|
||||
val endgame = state.hands[seat].size <= 4
|
||||
val candidates = plays.filter { play ->
|
||||
endgame || presidentRank(play.cards.first()) <= difficulty.hoardAbove
|
||||
}.ifEmpty { plays }
|
||||
|
||||
return candidates
|
||||
.sortedWith(
|
||||
compareBy(
|
||||
{ presidentRank(it.cards.first()) },
|
||||
{ -it.cards.size },
|
||||
)
|
||||
)
|
||||
.first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Following costs something, so take the pile with the cheapest set that
|
||||
* does it — and don't break up a two unless nothing else works.
|
||||
*/
|
||||
private fun chooseFollow(
|
||||
state: PresidentState,
|
||||
plays: List<PlayCards>,
|
||||
legal: List<PresidentMove>,
|
||||
): PresidentMove {
|
||||
val withoutTwos = plays.filter { play -> play.cards.none { isTwo(it) } }
|
||||
val pool = withoutTwos.ifEmpty { plays }
|
||||
|
||||
val cheapest = pool.minByOrNull { presidentRank(it.cards.first()) } ?: plays.first()
|
||||
|
||||
// Spending a two to take a single low card is a waste while there is
|
||||
// still a game left to play.
|
||||
val spendingTwo = cheapest.cards.any { isTwo(it) }
|
||||
val worthIt = state.setRank >= 13 || state.pile.size >= 4
|
||||
if (spendingTwo && !worthIt && legal.contains(PassTurn)) return PassTurn
|
||||
|
||||
return cheapest
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package org.prolibertate.games.game.president
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Rank
|
||||
|
||||
/**
|
||||
* Rule toggles. President is a house-rules game more than most, so the
|
||||
* variations that change play are surfaced rather than baked in.
|
||||
*/
|
||||
@Serializable
|
||||
data class PresidentOptions(
|
||||
val playerCount: Int = 4,
|
||||
/** A set of twos beats anything and clears the pile. */
|
||||
val twosClear: Boolean = true,
|
||||
/** Four of a kind beats anything and clears the pile. */
|
||||
val fourOfAKindBomb: Boolean = false,
|
||||
/** After the first round, Scum hands their best cards to the President. */
|
||||
val cardExchange: Boolean = true,
|
||||
val roundsToPlay: Int = 5,
|
||||
) {
|
||||
init {
|
||||
require(playerCount in 3..7) { "President seats three to seven" }
|
||||
require(roundsToPlay >= 1) { "roundsToPlay must be positive" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* President ranks threes low and twos high, which is not the natural order of
|
||||
* [Rank]. Everything that compares cards goes through here.
|
||||
*/
|
||||
fun presidentRank(card: Card): Int = when (card.rank) {
|
||||
Rank.TWO -> 15
|
||||
Rank.ACE -> 14
|
||||
else -> card.rank.order
|
||||
}
|
||||
|
||||
fun isTwo(card: Card): Boolean = card.rank == Rank.TWO
|
||||
|
||||
@Serializable
|
||||
enum class PresidentPhase { PLAYING, ROUND_OVER, GAME_OVER }
|
||||
|
||||
@Serializable
|
||||
data class PresidentState(
|
||||
val options: PresidentOptions,
|
||||
val seed: Long,
|
||||
val roundNumber: Int,
|
||||
val hands: List<List<Card>>,
|
||||
val handCounts: List<Int>,
|
||||
/** Cards played to the current pile, most recent set last. */
|
||||
val pile: List<Card>,
|
||||
/** How many cards the current set is; 0 when the pile is clear. */
|
||||
val setSize: Int,
|
||||
/** Rank of the set on top of the pile. */
|
||||
val setRank: Int,
|
||||
val turn: Int,
|
||||
/** Seats that have passed since the pile was last cleared. */
|
||||
val passed: List<Boolean>,
|
||||
/** Seats in the order they went out — first is President. */
|
||||
val finishedOrder: List<Int>,
|
||||
/** Finishing position from the previous round, or -1 on the first. */
|
||||
val previousFinish: List<Int>,
|
||||
val scores: List<Int>,
|
||||
val phase: PresidentPhase,
|
||||
val log: List<String>,
|
||||
) {
|
||||
fun isOut(seat: Int): Boolean = seat in finishedOrder
|
||||
|
||||
/** Seats still holding cards and not yet passed out of this pile. */
|
||||
fun stillInPile(): List<Int> =
|
||||
(0 until options.playerCount).filter { !isOut(it) && !passed[it] }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed interface PresidentMove
|
||||
|
||||
/** A set of equal-ranked cards. */
|
||||
@Serializable
|
||||
@SerialName("play")
|
||||
data class PlayCards(val cards: List<Card>) : PresidentMove
|
||||
|
||||
@Serializable
|
||||
@SerialName("pass")
|
||||
data object PassTurn : PresidentMove
|
||||
|
||||
/** Title for a finishing position, for display. */
|
||||
fun titleFor(position: Int, playerCount: Int): String = when {
|
||||
position == 0 -> "President"
|
||||
position == 1 && playerCount > 3 -> "Vice President"
|
||||
position == playerCount - 1 -> "Scum"
|
||||
position == playerCount - 2 && playerCount > 3 -> "Vice Scum"
|
||||
else -> "Citizen"
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package org.prolibertate.games.game.president
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Decks
|
||||
import org.prolibertate.games.game.cards.shuffledWith
|
||||
import org.prolibertate.games.game.engine.GameRules
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* President as a pure state machine. See RULES-president.md.
|
||||
*/
|
||||
object PresidentRules : GameRules<PresidentState, PresidentMove> {
|
||||
|
||||
override val gameId: String = GameCatalog.PRESIDENT
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
override fun initialState(config: TableConfig): PresidentState {
|
||||
val options = json.decodeFromString<PresidentOptions>(config.optionsJson)
|
||||
require(config.seats.size == options.playerCount) {
|
||||
"Seat count ${config.seats.size} does not match ${options.playerCount} players"
|
||||
}
|
||||
return deal(
|
||||
options = options,
|
||||
seed = config.seed,
|
||||
roundNumber = 0,
|
||||
previousFinish = List(options.playerCount) { -1 },
|
||||
scores = List(options.playerCount) { 0 },
|
||||
log = listOf("Round 1."),
|
||||
)
|
||||
}
|
||||
|
||||
private fun deal(
|
||||
options: PresidentOptions,
|
||||
seed: Long,
|
||||
roundNumber: Int,
|
||||
previousFinish: List<Int>,
|
||||
scores: List<Int>,
|
||||
log: List<String>,
|
||||
): PresidentState {
|
||||
val random = Random(seed + roundNumber * 7919L)
|
||||
val deck = Decks.standard52().shuffledWith(random)
|
||||
|
||||
// Deal round-robin so hands differ by at most one card.
|
||||
val hands = MutableList(options.playerCount) { mutableListOf<Card>() }
|
||||
deck.forEachIndexed { index, card -> hands[index % options.playerCount] += card }
|
||||
val sorted = hands.map { hand -> hand.sortedBy { presidentRank(it) } }
|
||||
|
||||
val exchanged =
|
||||
if (roundNumber > 0 && options.cardExchange) {
|
||||
applyExchange(sorted, previousFinish, options)
|
||||
} else {
|
||||
sorted
|
||||
}
|
||||
|
||||
// The lowest card in the game leads the first round; afterwards the
|
||||
// Scum leads, which is the usual way of keeping the loser in the game.
|
||||
val leader = if (roundNumber == 0) {
|
||||
exchanged.indices.minByOrNull { seat ->
|
||||
exchanged[seat].minOfOrNull { presidentRank(it) } ?: Int.MAX_VALUE
|
||||
} ?: 0
|
||||
} else {
|
||||
previousFinish.indexOf(options.playerCount - 1).takeIf { it >= 0 } ?: 0
|
||||
}
|
||||
|
||||
return PresidentState(
|
||||
options = options,
|
||||
seed = seed,
|
||||
roundNumber = roundNumber,
|
||||
hands = exchanged,
|
||||
handCounts = exchanged.map { it.size },
|
||||
pile = emptyList(),
|
||||
setSize = 0,
|
||||
setRank = 0,
|
||||
turn = leader,
|
||||
passed = List(options.playerCount) { false },
|
||||
finishedOrder = emptyList(),
|
||||
previousFinish = previousFinish,
|
||||
scores = scores,
|
||||
phase = PresidentPhase.PLAYING,
|
||||
log = log,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scum hands their best cards up, the President hands their worst back.
|
||||
*
|
||||
* The President choosing which to return is the fuller rule; here it is
|
||||
* automatic so the exchange does not need a phase of its own. See
|
||||
* RULES-president.md.
|
||||
*/
|
||||
private fun applyExchange(
|
||||
hands: List<List<Card>>,
|
||||
previousFinish: List<Int>,
|
||||
options: PresidentOptions,
|
||||
): List<List<Card>> {
|
||||
val president = previousFinish.indexOf(0)
|
||||
val scum = previousFinish.indexOf(options.playerCount - 1)
|
||||
if (president < 0 || scum < 0 || president == scum) return hands
|
||||
|
||||
val working = hands.map { it.toMutableList() }
|
||||
val count = 2
|
||||
|
||||
val fromScum = working[scum].sortedByDescending { presidentRank(it) }.take(count)
|
||||
val fromPresident = working[president].sortedBy { presidentRank(it) }.take(count)
|
||||
if (fromScum.size < count || fromPresident.size < count) return hands
|
||||
|
||||
fromScum.forEach { working[scum].remove(it) }
|
||||
fromPresident.forEach { working[president].remove(it) }
|
||||
working[president] += fromScum
|
||||
working[scum] += fromPresident
|
||||
|
||||
return working.map { hand -> hand.sortedBy { presidentRank(it) } }
|
||||
}
|
||||
|
||||
/** Advances from [PresidentPhase.ROUND_OVER] into the next deal. */
|
||||
fun nextRound(state: PresidentState): PresidentState {
|
||||
check(state.phase == PresidentPhase.ROUND_OVER) { "Round is not over" }
|
||||
val finish = MutableList(state.options.playerCount) { -1 }
|
||||
state.finishedOrder.forEachIndexed { position, seat -> finish[seat] = position }
|
||||
return deal(
|
||||
options = state.options,
|
||||
seed = state.seed,
|
||||
roundNumber = state.roundNumber + 1,
|
||||
previousFinish = finish,
|
||||
scores = state.scores,
|
||||
log = state.log + "Round ${state.roundNumber + 2}.",
|
||||
)
|
||||
}
|
||||
|
||||
override fun currentSeat(state: PresidentState): Int? =
|
||||
if (state.phase == PresidentPhase.PLAYING) state.turn else null
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Legal moves
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
override fun legalMoves(state: PresidentState, seat: Int): List<PresidentMove> {
|
||||
if (currentSeat(state) != seat) return emptyList()
|
||||
val hand = state.hands[seat]
|
||||
if (hand.isEmpty()) return emptyList()
|
||||
|
||||
val byRank = hand.groupBy { presidentRank(it) }.toSortedMap()
|
||||
val moves = mutableListOf<PresidentMove>()
|
||||
|
||||
if (state.setSize == 0) {
|
||||
// Leading: any number of one rank.
|
||||
for ((_, cards) in byRank) {
|
||||
for (size in 1..cards.size) moves += PlayCards(cards.take(size))
|
||||
}
|
||||
return moves
|
||||
}
|
||||
|
||||
for ((rank, cards) in byRank) {
|
||||
if (cards.size < state.setSize) continue
|
||||
val set = cards.take(state.setSize)
|
||||
val beatsByRank = rank > state.setRank
|
||||
val clears = clearsPile(state.options, set, cards.size)
|
||||
if (beatsByRank || clears) moves += PlayCards(set)
|
||||
}
|
||||
moves += PassTurn
|
||||
return moves
|
||||
}
|
||||
|
||||
/** Sets that beat the pile outright rather than on rank. */
|
||||
private fun clearsPile(
|
||||
options: PresidentOptions,
|
||||
set: List<Card>,
|
||||
availableOfRank: Int,
|
||||
): Boolean {
|
||||
if (options.twosClear && set.all { isTwo(it) }) return true
|
||||
if (options.fourOfAKindBomb && set.size == 4 && availableOfRank >= 4) return true
|
||||
return false
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Move application
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
override fun applyMove(
|
||||
state: PresidentState,
|
||||
seat: Int,
|
||||
move: PresidentMove,
|
||||
): PresidentState {
|
||||
require(currentSeat(state) == seat) { "Not seat $seat's turn" }
|
||||
require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" }
|
||||
return when (move) {
|
||||
is PassTurn -> applyPass(state, seat)
|
||||
is PlayCards -> applyPlay(state, seat, move.cards)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyPass(state: PresidentState, seat: Int): PresidentState {
|
||||
val passed = state.passed.toMutableList()
|
||||
passed[seat] = true
|
||||
val afterPass = state.copy(passed = passed, log = state.log + "Seat $seat passes.")
|
||||
return advance(afterPass, from = seat)
|
||||
}
|
||||
|
||||
private fun applyPlay(
|
||||
state: PresidentState,
|
||||
seat: Int,
|
||||
cards: List<Card>,
|
||||
): PresidentState {
|
||||
val hand = state.hands[seat].toMutableList()
|
||||
cards.forEach { hand.remove(it) }
|
||||
val hands = state.hands.toMutableList()
|
||||
hands[seat] = hand
|
||||
|
||||
val rank = presidentRank(cards.first())
|
||||
val ranksOfHand = state.hands[seat].count { presidentRank(it) == rank }
|
||||
val clears = clearsPile(state.options, cards, ranksOfHand)
|
||||
|
||||
var next = state.copy(
|
||||
hands = hands,
|
||||
handCounts = hands.map { it.size },
|
||||
pile = state.pile + cards,
|
||||
setSize = cards.size,
|
||||
setRank = rank,
|
||||
log = state.log + "Seat $seat plays ${cards.joinToString(" ") { it.label }}.",
|
||||
)
|
||||
|
||||
// Going out is recorded before anything else, so a player who finishes
|
||||
// on a pile-clearing set does not also get to lead the next one.
|
||||
if (hand.isEmpty()) {
|
||||
val order = next.finishedOrder + seat
|
||||
next = next.copy(
|
||||
finishedOrder = order,
|
||||
log = next.log + "Seat $seat is out (${titleFor(order.size - 1, state.options.playerCount)}).",
|
||||
)
|
||||
if (roundComplete(next)) return scoreRound(next)
|
||||
}
|
||||
|
||||
if (clears) {
|
||||
// Pile cleared: the same player leads again unless they just went out.
|
||||
val leader = if (next.isOut(seat)) nextActive(next, seat) else seat
|
||||
return next.copy(
|
||||
pile = emptyList(),
|
||||
setSize = 0,
|
||||
setRank = 0,
|
||||
passed = List(state.options.playerCount) { false },
|
||||
turn = leader,
|
||||
log = next.log + "Pile cleared.",
|
||||
)
|
||||
}
|
||||
|
||||
return advance(next, from = seat)
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves to the next seat, clearing the pile when everyone else has passed.
|
||||
*/
|
||||
private fun advance(state: PresidentState, from: Int): PresidentState {
|
||||
val contenders = state.stillInPile()
|
||||
// Everyone who could still beat the pile has passed: it is taken down
|
||||
// and whoever played last leads again.
|
||||
if (state.setSize > 0 && contenders.size <= 1) {
|
||||
val leader = contenders.firstOrNull()
|
||||
?: nextActive(state, from)
|
||||
return state.copy(
|
||||
pile = emptyList(),
|
||||
setSize = 0,
|
||||
setRank = 0,
|
||||
passed = List(state.options.playerCount) { false },
|
||||
turn = leader,
|
||||
log = state.log + "Pile cleared.",
|
||||
)
|
||||
}
|
||||
return state.copy(turn = nextActive(state, from))
|
||||
}
|
||||
|
||||
/** The next seat still holding cards and not passed out of this pile. */
|
||||
private fun nextActive(state: PresidentState, from: Int): Int {
|
||||
val count = state.options.playerCount
|
||||
for (step in 1..count) {
|
||||
val seat = (from + step) % count
|
||||
if (!state.isOut(seat) && !state.passed[seat]) return seat
|
||||
}
|
||||
// Nobody is left in the pile; fall back to anyone still holding cards.
|
||||
for (step in 1..count) {
|
||||
val seat = (from + step) % count
|
||||
if (!state.isOut(seat)) return seat
|
||||
}
|
||||
return from
|
||||
}
|
||||
|
||||
private fun roundComplete(state: PresidentState): Boolean =
|
||||
state.finishedOrder.size >= state.options.playerCount - 1
|
||||
|
||||
private fun scoreRound(state: PresidentState): PresidentState {
|
||||
// The straggler takes last place.
|
||||
val remaining = (0 until state.options.playerCount).filter { !state.isOut(it) }
|
||||
val order = state.finishedOrder + remaining
|
||||
|
||||
val scores = state.scores.toMutableList()
|
||||
order.forEachIndexed { position, seat ->
|
||||
scores[seat] = scores[seat] + (state.options.playerCount - 1 - position)
|
||||
}
|
||||
|
||||
val finalRound = state.roundNumber + 1 >= state.options.roundsToPlay
|
||||
return state.copy(
|
||||
finishedOrder = order,
|
||||
scores = scores,
|
||||
phase = if (finalRound) PresidentPhase.GAME_OVER else PresidentPhase.ROUND_OVER,
|
||||
log = state.log + "Round over.",
|
||||
)
|
||||
}
|
||||
|
||||
override fun isFinished(state: PresidentState): Boolean =
|
||||
state.phase == PresidentPhase.GAME_OVER
|
||||
|
||||
override fun summary(state: PresidentState): String = when (state.phase) {
|
||||
PresidentPhase.GAME_OVER -> {
|
||||
val best = state.scores.indices.maxByOrNull { state.scores[it] } ?: 0
|
||||
"Seat $best wins with ${state.scores[best]}"
|
||||
}
|
||||
|
||||
else -> state.scores.mapIndexed { seat, s -> "S$seat:$s" }.joinToString(" ")
|
||||
}
|
||||
|
||||
override fun viewFor(state: PresidentState, seat: Int): PresidentState = state.copy(
|
||||
hands = state.hands.mapIndexed { index, hand -> if (index == seat) hand else emptyList() },
|
||||
handCounts = state.handCounts,
|
||||
)
|
||||
|
||||
override fun encodeState(state: PresidentState): String = json.encodeToString(state)
|
||||
override fun decodeState(json: String): PresidentState = this.json.decodeFromString(json)
|
||||
override fun encodeMove(move: PresidentMove): String = json.encodeToString(move)
|
||||
override fun decodeMove(json: String): PresidentMove = this.json.decodeFromString(json)
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package org.prolibertate.games.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -29,6 +31,8 @@ import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import org.prolibertate.games.game.euchre.EuchreOptions
|
||||
import org.prolibertate.games.game.golf.GolfOptions
|
||||
import org.prolibertate.games.game.president.PresidentOptions
|
||||
import org.prolibertate.games.game.sequence.SequenceOptions
|
||||
|
||||
private val setupJson = Json { encodeDefaults = true }
|
||||
@@ -49,15 +53,21 @@ fun GameSetupScreen(
|
||||
) {
|
||||
var euchre by remember { mutableStateOf(EuchreOptions()) }
|
||||
var sequence by remember { mutableStateOf(SequenceOptions()) }
|
||||
var president by remember { mutableStateOf(PresidentOptions()) }
|
||||
var golf by remember { mutableStateOf(GolfOptions()) }
|
||||
|
||||
val optionsJson = when (descriptor.id) {
|
||||
GameCatalog.EUCHRE -> setupJson.encodeToString(euchre)
|
||||
GameCatalog.SEQUENCE -> setupJson.encodeToString(sequence)
|
||||
GameCatalog.PRESIDENT -> setupJson.encodeToString(president)
|
||||
GameCatalog.GOLF -> setupJson.encodeToString(golf)
|
||||
else -> "{}"
|
||||
}
|
||||
val seatCount = when (descriptor.id) {
|
||||
GameCatalog.EUCHRE -> 4
|
||||
GameCatalog.SEQUENCE -> sequence.playerCount
|
||||
GameCatalog.PRESIDENT -> president.playerCount
|
||||
GameCatalog.GOLF -> golf.playerCount
|
||||
else -> descriptor.minPlayers
|
||||
}
|
||||
|
||||
@@ -74,6 +84,8 @@ fun GameSetupScreen(
|
||||
when (descriptor.id) {
|
||||
GameCatalog.EUCHRE -> EuchreOptionsEditor(euchre) { euchre = it }
|
||||
GameCatalog.SEQUENCE -> SequenceOptionsEditor(sequence) { sequence = it }
|
||||
GameCatalog.PRESIDENT -> PresidentOptionsEditor(president) { president = it }
|
||||
GameCatalog.GOLF -> GolfOptionsEditor(golf) { golf = it }
|
||||
else -> Text("No options yet for this game.")
|
||||
}
|
||||
|
||||
@@ -121,8 +133,9 @@ private fun offlineConfig(
|
||||
|
||||
fun teamForSeat(gameId: String, seat: Int): Int = when (gameId) {
|
||||
// Partners sit opposite each other.
|
||||
GameCatalog.EUCHRE -> seat % 2
|
||||
else -> seat % 2
|
||||
GameCatalog.EUCHRE, GameCatalog.SEQUENCE -> seat % 2
|
||||
// Everyone else plays for themselves.
|
||||
else -> seat
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -197,6 +210,95 @@ private fun SequenceOptionsEditor(options: SequenceOptions, onChange: (SequenceO
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PresidentOptionsEditor(
|
||||
options: PresidentOptions,
|
||||
onChange: (PresidentOptions) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Players",
|
||||
values = listOf(3, 4, 5, 6, 7),
|
||||
selected = options.playerCount,
|
||||
display = { "$it" },
|
||||
onSelect = { onChange(options.copy(playerCount = it)) },
|
||||
)
|
||||
ChipRow(
|
||||
label = "Rounds",
|
||||
values = listOf(3, 5, 7),
|
||||
selected = options.roundsToPlay,
|
||||
display = { "$it" },
|
||||
onSelect = { onChange(options.copy(roundsToPlay = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Twos clear the pile",
|
||||
subtitle = "A two beats anything and takes the pile down",
|
||||
checked = options.twosClear,
|
||||
onChange = { onChange(options.copy(twosClear = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Four of a kind bombs",
|
||||
subtitle = "Four matching cards beat anything, whatever the pile",
|
||||
checked = options.fourOfAKindBomb,
|
||||
onChange = { onChange(options.copy(fourOfAKindBomb = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Card exchange",
|
||||
subtitle = "After each round the Scum hands their best cards to the President",
|
||||
checked = options.cardExchange,
|
||||
onChange = { onChange(options.copy(cardExchange = it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GolfOptionsEditor(options: GolfOptions, onChange: (GolfOptions) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Players",
|
||||
values = listOf(2, 3, 4, 5, 6),
|
||||
selected = options.playerCount,
|
||||
display = { "$it" },
|
||||
onSelect = { onChange(options.copy(playerCount = it)) },
|
||||
)
|
||||
ChipRow(
|
||||
label = "Cards each",
|
||||
values = listOf(4, 6, 9),
|
||||
selected = options.gridSize,
|
||||
display = { "$it" },
|
||||
onSelect = { size ->
|
||||
// Keep the opening reveals inside the new grid.
|
||||
onChange(
|
||||
options.copy(
|
||||
gridSize = size,
|
||||
startingReveals = options.startingReveals.coerceAtMost(size),
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
ChipRow(
|
||||
label = "Holes",
|
||||
values = listOf(3, 6, 9),
|
||||
selected = options.holes,
|
||||
display = { "$it" },
|
||||
onSelect = { onChange(options.copy(holes = it)) },
|
||||
)
|
||||
ChipRow(
|
||||
label = "Seen at the start",
|
||||
values = (0..options.gridSize).toList(),
|
||||
selected = options.startingReveals,
|
||||
display = { "$it" },
|
||||
onSelect = { onChange(options.copy(startingReveals = it)) },
|
||||
)
|
||||
Text(
|
||||
text = "${options.rows} rows of ${options.cols}. Matching columns cancel out" +
|
||||
if (options.gridSize == 9) ", and so do matching rows." else ".",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun <T> ChipRow(
|
||||
label: String,
|
||||
@@ -207,7 +309,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,50 @@ 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
|
||||
},
|
||||
aiThinkingMillis = { settings.scaled(700L) },
|
||||
)
|
||||
}
|
||||
StartMatch(env, controller, route)
|
||||
GolfScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||
}
|
||||
|
||||
else -> Text("That game isn't playable yet.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.prolibertate.games.game.golf.DiscardAndFlip
|
||||
import org.prolibertate.games.game.golf.DrawFromDiscard
|
||||
import org.prolibertate.games.game.golf.DrawFromStock
|
||||
import org.prolibertate.games.game.golf.GolfMove
|
||||
import org.prolibertate.games.game.golf.GolfPhase
|
||||
import org.prolibertate.games.game.golf.GolfState
|
||||
import org.prolibertate.games.game.golf.ReplaceCard
|
||||
import org.prolibertate.games.game.golf.golfValue
|
||||
import org.prolibertate.games.net.MatchController
|
||||
import org.prolibertate.games.ui.PrimaryAction
|
||||
import org.prolibertate.games.ui.ScreenScaffold
|
||||
import org.prolibertate.games.ui.common.CardBackView
|
||||
import org.prolibertate.games.ui.common.PlayingCardView
|
||||
import org.prolibertate.games.ui.theme.FeltGreenDark
|
||||
|
||||
/** What tapping one of your own cards will do once you're holding a card. */
|
||||
private enum class PlaceMode(val label: String) {
|
||||
SWAP("Swap it in"),
|
||||
THROW("Throw it and turn one over"),
|
||||
}
|
||||
|
||||
/**
|
||||
* The Golf table.
|
||||
*
|
||||
* The grid is the game, so it gets the space: your own cards are large and
|
||||
* tappable, everyone else's are small. Face-down cards are drawn as backs —
|
||||
* including your own, which you genuinely have not seen.
|
||||
*/
|
||||
@Composable
|
||||
fun GolfScreen(
|
||||
controller: MatchController<GolfState, GolfMove>,
|
||||
localSeat: Int,
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val legal by controller.legalMoves.collectAsState()
|
||||
var mode by remember { mutableStateOf(PlaceMode.SWAP) }
|
||||
|
||||
val current = state
|
||||
if (current == null) {
|
||||
ScreenScaffold(title = "Golf", onBack = onExit) { modifier ->
|
||||
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ScreenScaffold(title = "Golf", onBack = onExit) { modifier ->
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
val shortest = minOf(maxWidth, maxHeight)
|
||||
val ownCard = (shortest * 0.16f).coerceIn(40.dp, 84.dp)
|
||||
val otherCard = (shortest * 0.07f).coerceIn(20.dp, 40.dp)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ScoreLine(current, localSeat)
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().background(FeltGreenDark).padding(6.dp),
|
||||
) {
|
||||
StockAndDiscard(current, localSeat, legal, ownCard) { controller.submit(it) }
|
||||
}
|
||||
|
||||
OpponentGrids(current, localSeat, otherCard)
|
||||
|
||||
if (current.phase == GolfPhase.PLACE && legal.isNotEmpty()) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
PlaceMode.entries.forEach { option ->
|
||||
val enabled = option == PlaceMode.SWAP ||
|
||||
legal.any { it is DiscardAndFlip }
|
||||
FilterChip(
|
||||
selected = mode == option,
|
||||
enabled = enabled,
|
||||
onClick = { mode = option },
|
||||
label = { Text(option.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (current.phase) {
|
||||
GolfPhase.HOLE_OVER -> Text(
|
||||
text = "Hole ${current.hole + 1}: " + current.holeScores
|
||||
.mapIndexed { seat, s -> "seat $seat $s" }.joinToString(", "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
GolfPhase.GAME_OVER -> {
|
||||
Text("Game over — lowest score wins.", fontWeight = FontWeight.Bold)
|
||||
PrimaryAction(text = "Back to the menu") { onExit() }
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
OwnGrid(
|
||||
state = current,
|
||||
localSeat = localSeat,
|
||||
cardWidth = ownCard,
|
||||
legal = legal,
|
||||
mode = mode,
|
||||
onMove = { controller.submit(it); mode = PlaceMode.SWAP },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScoreLine(state: GolfState, localSeat: Int) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column {
|
||||
Text("Hole ${state.hole + 1} of ${state.options.holes}", fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
text = "Your total: ${state.scores[localSeat]} (lowest wins)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
state.closedBy?.let {
|
||||
Text(
|
||||
text = if (it == localSeat) "You closed it out" else "Seat $it is out",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StockAndDiscard(
|
||||
state: GolfState,
|
||||
localSeat: Int,
|
||||
legal: List<GolfMove>,
|
||||
cardWidth: Dp,
|
||||
onMove: (GolfMove) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Stock", style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary)
|
||||
CardBackView(width = cardWidth)
|
||||
if (legal.contains(DrawFromStock)) {
|
||||
Button(onClick = { onMove(DrawFromStock) }) { Text("Draw") }
|
||||
}
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Discard", style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary)
|
||||
state.discard.lastOrNull()?.let { PlayingCardView(card = it, width = cardWidth) }
|
||||
if (legal.contains(DrawFromDiscard)) {
|
||||
Button(onClick = { onMove(DrawFromDiscard) }) { Text("Take") }
|
||||
}
|
||||
}
|
||||
|
||||
state.drawn?.let { drawn ->
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Holding", style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary)
|
||||
PlayingCardView(card = drawn, width = cardWidth, selected = true)
|
||||
Text(
|
||||
text = "worth ${golfValue(drawn)}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.phase == GolfPhase.DRAW && state.turn != localSeat) {
|
||||
Text(
|
||||
text = "Seat ${state.turn} is playing…",
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OwnGrid(
|
||||
state: GolfState,
|
||||
localSeat: Int,
|
||||
cardWidth: Dp,
|
||||
legal: List<GolfMove>,
|
||||
mode: PlaceMode,
|
||||
onMove: (GolfMove) -> Unit,
|
||||
) {
|
||||
val options = state.options
|
||||
val replacements = legal.filterIsInstance<ReplaceCard>().map { it.index }.toSet()
|
||||
val flips = legal.filterIsInstance<DiscardAndFlip>().map { it.index }.toSet()
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = when {
|
||||
state.phase == GolfPhase.PLACE && mode == PlaceMode.SWAP ->
|
||||
"Tap a card to swap it out"
|
||||
|
||||
state.phase == GolfPhase.PLACE ->
|
||||
"Tap a face-down card to turn it over"
|
||||
|
||||
else -> "Your cards"
|
||||
},
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
for (row in 0 until options.rows) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
for (col in 0 until options.cols) {
|
||||
val index = row * options.cols + col
|
||||
val tappable = when (mode) {
|
||||
PlaceMode.SWAP -> index in replacements
|
||||
PlaceMode.THROW -> index in flips
|
||||
}
|
||||
GridCard(
|
||||
card = state.grids[localSeat].getOrNull(index),
|
||||
faceUp = state.revealed[localSeat].getOrElse(index) { false },
|
||||
width = cardWidth,
|
||||
tappable = tappable,
|
||||
onClick = {
|
||||
when (mode) {
|
||||
PlaceMode.SWAP -> onMove(ReplaceCard(index))
|
||||
PlaceMode.THROW -> onMove(DiscardAndFlip(index))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GridCard(
|
||||
card: org.prolibertate.games.game.cards.Card?,
|
||||
faceUp: Boolean,
|
||||
width: Dp,
|
||||
tappable: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val shape = RoundedCornerShape(width * 0.12f)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(shape)
|
||||
.border(
|
||||
width = if (tappable) 3.dp else 0.dp,
|
||||
color = if (tappable) {
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
} else {
|
||||
androidx.compose.ui.graphics.Color.Transparent
|
||||
},
|
||||
shape = shape,
|
||||
)
|
||||
.then(if (tappable) Modifier.clickable { onClick() } else Modifier)
|
||||
.padding(2.dp),
|
||||
) {
|
||||
if (faceUp && card != null) {
|
||||
PlayingCardView(card = card, width = width)
|
||||
} else {
|
||||
CardBackView(width = width)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OpponentGrids(state: GolfState, localSeat: Int, cardWidth: Dp) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
(0 until state.options.playerCount)
|
||||
.filter { it != localSeat }
|
||||
.forEach { seat ->
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = "Seat $seat",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = if (seat == state.turn) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
for (row in 0 until state.options.rows) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
for (col in 0 until state.options.cols) {
|
||||
val index = row * state.options.cols + col
|
||||
val faceUp = state.revealed[seat].getOrElse(index) { false }
|
||||
if (faceUp) {
|
||||
PlayingCardView(
|
||||
card = state.grids[seat][index],
|
||||
width = cardWidth,
|
||||
)
|
||||
} else {
|
||||
CardBackView(width = cardWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.prolibertate.games.game.president.PassTurn
|
||||
import org.prolibertate.games.game.president.PlayCards
|
||||
import org.prolibertate.games.game.president.PresidentMove
|
||||
import org.prolibertate.games.game.president.PresidentPhase
|
||||
import org.prolibertate.games.game.president.PresidentState
|
||||
import org.prolibertate.games.game.president.titleFor
|
||||
import org.prolibertate.games.net.MatchController
|
||||
import org.prolibertate.games.ui.PrimaryAction
|
||||
import org.prolibertate.games.ui.ScreenScaffold
|
||||
import org.prolibertate.games.ui.common.CardBackView
|
||||
import org.prolibertate.games.ui.common.PlayingCardView
|
||||
import org.prolibertate.games.ui.theme.FeltGreenDark
|
||||
|
||||
/**
|
||||
* The President table.
|
||||
*
|
||||
* Plays are offered as whole sets rather than by selecting individual cards.
|
||||
* A set is the unit of the game — you never play four cards of different
|
||||
* ranks — so picking from the legal sets is both simpler to operate and
|
||||
* impossible to get wrong.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun PresidentScreen(
|
||||
controller: MatchController<PresidentState, PresidentMove>,
|
||||
localSeat: Int,
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val legal by controller.legalMoves.collectAsState()
|
||||
|
||||
val current = state
|
||||
if (current == null) {
|
||||
ScreenScaffold(title = "President", onBack = onExit) { modifier ->
|
||||
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ScreenScaffold(title = "President", onBack = onExit) { modifier ->
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
val cardWidth = (minOf(maxWidth, maxHeight) * 0.15f).coerceIn(44.dp, 88.dp)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
StandingsBar(current, localSeat)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.background(FeltGreenDark),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
PileView(current, cardWidth)
|
||||
}
|
||||
|
||||
OpponentRow(current, localSeat)
|
||||
|
||||
when (current.phase) {
|
||||
PresidentPhase.PLAYING -> PlayChoices(current, localSeat, legal) {
|
||||
controller.submit(it)
|
||||
}
|
||||
|
||||
PresidentPhase.ROUND_OVER -> Text(
|
||||
text = "Round over — " + current.finishedOrder
|
||||
.mapIndexed { position, seat ->
|
||||
"${titleFor(position, current.options.playerCount)}: seat $seat"
|
||||
}
|
||||
.joinToString(", "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
PresidentPhase.GAME_OVER -> {
|
||||
Text("Game over.", fontWeight = FontWeight.Bold)
|
||||
PrimaryAction(text = "Back to the menu") { onExit() }
|
||||
}
|
||||
}
|
||||
|
||||
HandView(current, localSeat, cardWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StandingsBar(state: PresidentState, localSeat: Int) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column {
|
||||
Text("Round ${state.roundNumber + 1} of ${state.options.roundsToPlay}",
|
||||
fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
text = "Your score: ${state.scores[localSeat]}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
val position = state.finishedOrder.indexOf(localSeat)
|
||||
Text(
|
||||
text = if (position >= 0) {
|
||||
"You are out — ${titleFor(position, state.options.playerCount)}"
|
||||
} else {
|
||||
"${state.hands[localSeat].size} cards in hand"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PileView(state: PresidentState, cardWidth: Dp) {
|
||||
if (state.setSize == 0) {
|
||||
Text(
|
||||
text = "Pile is clear — anything leads",
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
return
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = "On the pile",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
state.pile.takeLast(state.setSize).forEach { card ->
|
||||
PlayingCardView(card = card, width = cardWidth)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "Beat it with ${state.setSize} card(s)",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Everyone else, as card counts — their hands are never sent to this device. */
|
||||
@Composable
|
||||
private fun OpponentRow(state: PresidentState, localSeat: Int) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
(0 until state.options.playerCount)
|
||||
.filter { it != localSeat }
|
||||
.forEach { seat ->
|
||||
val count = state.handCounts.getOrElse(seat) { 0 }
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = "Seat $seat",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = if (seat == state.turn) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
repeat(count.coerceAtMost(6)) { CardBackView(width = 14.dp) }
|
||||
}
|
||||
Text(
|
||||
text = if (count == 0) "out" else "$count",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun PlayChoices(
|
||||
state: PresidentState,
|
||||
localSeat: Int,
|
||||
legal: List<PresidentMove>,
|
||||
onMove: (PresidentMove) -> Unit,
|
||||
) {
|
||||
if (legal.isEmpty()) {
|
||||
Text(
|
||||
text = if (state.isOut(localSeat)) "You're out — watching." else "Seat ${state.turn} to play…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val plays = legal.filterIsInstance<PlayCards>()
|
||||
val canPass = legal.contains(PassTurn)
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = if (state.setSize == 0) "Lead a set" else "Play or pass",
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
plays.forEach { play ->
|
||||
Button(onClick = { onMove(play) }) {
|
||||
Text(play.cards.joinToString(" ") { it.label })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (canPass) {
|
||||
OutlinedButton(
|
||||
onClick = { onMove(PassTurn) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Pass")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HandView(state: PresidentState, localSeat: Int, cardWidth: Dp) {
|
||||
val hand = state.hands.getOrNull(localSeat).orEmpty()
|
||||
Column {
|
||||
Text("Your hand", style = MaterialTheme.typography.labelLarge)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (hand.isEmpty()) {
|
||||
Text("—", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
hand.forEach { card ->
|
||||
PlayingCardView(card = card, width = cardWidth * 0.8f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user