Golf: 8-card grids, chosen openers, final putt, end-of-hole score card
- An 8-card variant, laid out as two rows of four. The existing column scoring covers it without change. - Opening cards are now chosen rather than dealt face up. Nothing starts revealed; each player in turn turns over their allowance, and the hole begins once everyone has picked. This needed a new SETUP phase in the engine, since it is a real decision and has to be validated like any other move rather than being decided at deal time. - "Line up the final putt", off by default: 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 did not choose. Only offered on the last face-down card. Because a whole table could otherwise decline forever, two full laps of nothing but thrown cards calls the hole and scores it where it stands. - The end of a hole now shows a score card — every player's strokes for the hole and their running total, lowest first — and the next hole is not dealt until it is dismissed. MatchController gained a general awaitsConfirmation hook for this, so any game wanting a scoreboard between rounds can stop the table the same way. Only the host waits; clients render what they are sent. Nine more Golf tests, covering the 8-card layout, the opening choice, when the putt option is and is not offered, and that a table refusing to putt out still finishes. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
This commit is contained in:
@@ -16,21 +16,43 @@ class GolfAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||
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),
|
||||
NORMAL(discardAppetite = 5),
|
||||
CAUTIOUS(discardAppetite = 7),
|
||||
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)) {
|
||||
@@ -61,6 +83,16 @@ class GolfAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||
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()
|
||||
|
||||
@@ -9,16 +9,22 @@ 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). */
|
||||
/** 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 get to see before play starts. */
|
||||
/** 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 == 4 || gridSize == 6 || gridSize == 9) {
|
||||
"Golf grids are 4, 6 or 9 cards"
|
||||
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" }
|
||||
@@ -82,7 +88,14 @@ fun scoreGrid(cards: List<Card>, options: GolfOptions): Int {
|
||||
val HIDDEN_CARD: Card = Card(Rank.TWO, Suit.SPADES)
|
||||
|
||||
@Serializable
|
||||
enum class GolfPhase { DRAW, PLACE, HOLE_OVER, GAME_OVER }
|
||||
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(
|
||||
@@ -105,9 +118,17 @@ data class GolfState(
|
||||
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
|
||||
@@ -130,3 +151,16 @@ data class ReplaceCard(val index: Int) : GolfMove
|
||||
@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
|
||||
|
||||
@@ -55,11 +55,9 @@ object GolfRules : GameRules<GolfState, GolfMove> {
|
||||
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 }
|
||||
}
|
||||
// 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(
|
||||
@@ -77,7 +75,8 @@ object GolfRules : GameRules<GolfState, GolfMove> {
|
||||
finalTurnsLeft = 0,
|
||||
holeScores = List(options.playerCount) { 0 },
|
||||
scores = scores,
|
||||
phase = GolfPhase.DRAW,
|
||||
phase = if (options.startingReveals > 0) GolfPhase.SETUP else GolfPhase.DRAW,
|
||||
idleDiscards = 0,
|
||||
log = log,
|
||||
)
|
||||
}
|
||||
@@ -95,7 +94,7 @@ object GolfRules : GameRules<GolfState, GolfMove> {
|
||||
}
|
||||
|
||||
override fun currentSeat(state: GolfState): Int? = when (state.phase) {
|
||||
GolfPhase.DRAW, GolfPhase.PLACE -> state.turn
|
||||
GolfPhase.SETUP, GolfPhase.DRAW, GolfPhase.PLACE -> state.turn
|
||||
GolfPhase.HOLE_OVER, GolfPhase.GAME_OVER -> null
|
||||
}
|
||||
|
||||
@@ -106,6 +105,10 @@ object GolfRules : GameRules<GolfState, GolfMove> {
|
||||
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)
|
||||
@@ -119,6 +122,11 @@ object GolfRules : GameRules<GolfState, GolfMove> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,9 +158,36 @@ object GolfRules : GameRules<GolfState, GolfMove> {
|
||||
|
||||
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() }
|
||||
@@ -191,12 +226,32 @@ object GolfRules : GameRules<GolfState, GolfMove> {
|
||||
)
|
||||
}
|
||||
|
||||
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): GolfState {
|
||||
var next = state
|
||||
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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -263,7 +263,7 @@ private fun GolfOptionsEditor(options: GolfOptions, onChange: (GolfOptions) -> U
|
||||
)
|
||||
ChipRow(
|
||||
label = "Cards each",
|
||||
values = listOf(4, 6, 9),
|
||||
values = listOf(4, 6, 8, 9),
|
||||
selected = options.gridSize,
|
||||
display = { "$it" },
|
||||
onSelect = { size ->
|
||||
@@ -290,6 +290,13 @@ private fun GolfOptionsEditor(options: GolfOptions, onChange: (GolfOptions) -> U
|
||||
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 ".",
|
||||
|
||||
@@ -145,6 +145,8 @@ fun PlayScreen(
|
||||
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) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@ 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
|
||||
@@ -31,15 +34,16 @@ 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.PrimaryAction
|
||||
import org.prolibertate.games.ui.ScreenScaffold
|
||||
import org.prolibertate.games.ui.common.CardBackView
|
||||
import org.prolibertate.games.ui.common.PlayingCardView
|
||||
@@ -109,21 +113,16 @@ fun GolfScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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() }
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
OwnGrid(
|
||||
@@ -135,10 +134,86 @@ fun GolfScreen(
|
||||
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(
|
||||
@@ -226,10 +301,18 @@ private fun OwnGrid(
|
||||
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"
|
||||
|
||||
@@ -244,9 +327,13 @@ private fun OwnGrid(
|
||||
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
|
||||
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),
|
||||
@@ -254,9 +341,10 @@ private fun OwnGrid(
|
||||
width = cardWidth,
|
||||
tappable = tappable,
|
||||
onClick = {
|
||||
when (mode) {
|
||||
PlaceMode.SWAP -> onMove(ReplaceCard(index))
|
||||
PlaceMode.THROW -> onMove(DiscardAndFlip(index))
|
||||
when {
|
||||
state.phase == GolfPhase.SETUP -> onMove(RevealCard(index))
|
||||
mode == PlaceMode.SWAP -> onMove(ReplaceCard(index))
|
||||
else -> onMove(DiscardAndFlip(index))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -30,6 +30,17 @@ class GolfRulesTest {
|
||||
|
||||
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
|
||||
@@ -98,7 +109,9 @@ class GolfRulesTest {
|
||||
|
||||
@Test
|
||||
fun `everyone gets a grid and two cards face up`() {
|
||||
val state = GolfRules.initialState(config(GolfOptions(playerCount = 3, gridSize = 6)))
|
||||
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 }) }
|
||||
@@ -114,11 +127,133 @@ class GolfRulesTest {
|
||||
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 = GolfRules.initialState(config(GolfOptions(playerCount = 2)))
|
||||
var state = afterSetup(GolfRules.initialState(config(GolfOptions(playerCount = 2))))
|
||||
assertEquals(setOf(DrawFromStock, DrawFromDiscard), GolfRules.legalMoves(state, 0).toSet())
|
||||
|
||||
state = GolfRules.applyMove(state, 0, DrawFromStock)
|
||||
@@ -132,7 +267,7 @@ class GolfRulesTest {
|
||||
|
||||
@Test
|
||||
fun `a card taken from the discard pile must be used`() {
|
||||
var state = GolfRules.initialState(config(GolfOptions(playerCount = 2)))
|
||||
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 })
|
||||
@@ -141,7 +276,7 @@ class GolfRulesTest {
|
||||
|
||||
@Test
|
||||
fun `replacing a card turns that slot face up and discards the old one`() {
|
||||
var state = GolfRules.initialState(config(GolfOptions(playerCount = 2)))
|
||||
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]
|
||||
@@ -156,7 +291,7 @@ class GolfRulesTest {
|
||||
|
||||
@Test
|
||||
fun `throwing the drawn card turns one of your own over`() {
|
||||
var state = GolfRules.initialState(config(GolfOptions(playerCount = 2)))
|
||||
var state = afterSetup(GolfRules.initialState(config(GolfOptions(playerCount = 2))))
|
||||
state = GolfRules.applyMove(state, 0, DrawFromStock)
|
||||
val drawn = state.drawn!!
|
||||
assertFalse(state.revealed[0][4])
|
||||
@@ -171,7 +306,7 @@ class GolfRulesTest {
|
||||
@Test
|
||||
fun `turning the last card over gives everyone else one more turn`() {
|
||||
val options = GolfOptions(playerCount = 3, gridSize = 4, startingReveals = 3, holes = 1)
|
||||
var state = GolfRules.initialState(config(options))
|
||||
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))
|
||||
@@ -193,7 +328,7 @@ class GolfRulesTest {
|
||||
@Test
|
||||
fun `scores accumulate across holes`() {
|
||||
val options = GolfOptions(playerCount = 2, gridSize = 4, startingReveals = 4, holes = 3)
|
||||
var state = GolfRules.initialState(config(options))
|
||||
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
|
||||
@@ -276,7 +411,9 @@ class GolfRulesTest {
|
||||
|
||||
@Test
|
||||
fun `a view hides face-down cards including your own`() {
|
||||
val state = GolfRules.initialState(config(GolfOptions(playerCount = 2, gridSize = 6)))
|
||||
val state = afterSetup(
|
||||
GolfRules.initialState(config(GolfOptions(playerCount = 2, gridSize = 6)))
|
||||
)
|
||||
val view = GolfRules.viewFor(state, seat = 0)
|
||||
|
||||
for (index in 0 until 6) {
|
||||
@@ -293,7 +430,9 @@ class GolfRulesTest {
|
||||
fun `masking does not leak the real card`() {
|
||||
// A grid deliberately built with no twos of spades in it, so if the
|
||||
// mask ever showed through it would be obvious.
|
||||
val state = GolfRules.initialState(config(GolfOptions(playerCount = 2, gridSize = 6), seed = 42L))
|
||||
val 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 ->
|
||||
@@ -308,7 +447,10 @@ class GolfRulesTest {
|
||||
fun `state and moves survive a round trip`() {
|
||||
val state = GolfRules.initialState(config())
|
||||
assertEquals(state, GolfRules.decodeState(GolfRules.encodeState(state)))
|
||||
listOf(DrawFromStock, DrawFromDiscard, ReplaceCard(2), DiscardAndFlip(5)).forEach {
|
||||
listOf(
|
||||
DrawFromStock, DrawFromDiscard, ReplaceCard(2), DiscardAndFlip(5),
|
||||
DiscardOnly, RevealCard(1),
|
||||
).forEach {
|
||||
assertEquals(it, GolfRules.decodeMove(GolfRules.encodeMove(it)))
|
||||
}
|
||||
}
|
||||
|
||||
+20
-3
@@ -12,11 +12,18 @@ game setup screen. Lowest score wins, as on a golf course.
|
||||
| --- | --- |
|
||||
| 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 may look at
|
||||
before play begins; two by default. They are the leftmost cards of the top
|
||||
row, so everyone starts with the same shape of knowledge.
|
||||
- **Seen at the start — *toggle*.** How many of your own cards you turn over
|
||||
before play begins; two by default.
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
@@ -31,6 +38,12 @@ Then **place** it, in one of two ways:
|
||||
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
|
||||
|
||||
@@ -59,6 +72,10 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user