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:
Claude
2026-08-06 03:59:53 +00:00
parent 532c3ea48a
commit d989907d7e
9 changed files with 458 additions and 52 deletions
@@ -16,21 +16,43 @@ class GolfAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
enum class Difficulty( enum class Difficulty(
/** Take a discard worth this or less rather than gambling on the stock. */ /** Take a discard worth this or less rather than gambling on the stock. */
val discardAppetite: Int, val discardAppetite: Int,
/** Only close the hole out when the visible score is at or under this. */
val closeOutBelow: Int,
) { ) {
EASY(discardAppetite = 2), EASY(discardAppetite = 2, closeOutBelow = 30),
NORMAL(discardAppetite = 5), NORMAL(discardAppetite = 5, closeOutBelow = 16),
CAUTIOUS(discardAppetite = 7), CAUTIOUS(discardAppetite = 7, closeOutBelow = 12),
} }
override fun chooseMove(state: GolfState, seat: Int, legal: List<GolfMove>): GolfMove { override fun chooseMove(state: GolfState, seat: Int, legal: List<GolfMove>): GolfMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" } require(legal.isNotEmpty()) { "No legal move for seat $seat" }
return when (state.phase) { return when (state.phase) {
GolfPhase.SETUP -> chooseReveal(state, seat, legal)
GolfPhase.DRAW -> chooseDraw(state, seat, legal) GolfPhase.DRAW -> chooseDraw(state, seat, legal)
GolfPhase.PLACE -> choosePlace(state, seat, legal) GolfPhase.PLACE -> choosePlace(state, seat, legal)
else -> legal.first() 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 { private fun chooseDraw(state: GolfState, seat: Int, legal: List<GolfMove>): GolfMove {
val top = state.discard.lastOrNull() val top = state.discard.lastOrNull()
if (top != null && legal.contains(DrawFromDiscard)) { if (top != null && legal.contains(DrawFromDiscard)) {
@@ -61,6 +83,16 @@ class GolfAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
if (golfValue(drawn) < currentValue) return knownWorst 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, // Nothing worth swapping: throw it and turn something over instead,
// which at least buys information. // which at least buys information.
if (flips.isNotEmpty()) return flips.first() if (flips.isNotEmpty()) return flips.first()
@@ -9,16 +9,22 @@ import org.prolibertate.games.game.cards.Suit
@Serializable @Serializable
data class GolfOptions( data class GolfOptions(
val playerCount: Int = 2, 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 gridSize: Int = 6,
val holes: Int = 9, 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, 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 { init {
require(playerCount in 2..6) { "Golf seats two to six" } require(playerCount in 2..6) { "Golf seats two to six" }
require(gridSize == 4 || gridSize == 6 || gridSize == 9) { require(gridSize in setOf(4, 6, 8, 9)) {
"Golf grids are 4, 6 or 9 cards" "Golf grids are 4, 6, 8 or 9 cards"
} }
require(holes >= 1) { "holes must be positive" } require(holes >= 1) { "holes must be positive" }
require(startingReveals in 0..gridSize) { "cannot reveal more than the grid holds" } 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) val HIDDEN_CARD: Card = Card(Rank.TWO, Suit.SPADES)
@Serializable @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 @Serializable
data class GolfState( data class GolfState(
@@ -105,9 +118,17 @@ data class GolfState(
val holeScores: List<Int>, val holeScores: List<Int>,
val scores: List<Int>, val scores: List<Int>,
val phase: GolfPhase, 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>, val log: List<String>,
) { ) {
fun allRevealed(seat: Int): Boolean = revealed[seat].all { it } fun allRevealed(seat: Int): Boolean = revealed[seat].all { it }
fun faceDownCount(seat: Int): Int = revealed[seat].count { !it }
} }
@Serializable @Serializable
@@ -130,3 +151,16 @@ data class ReplaceCard(val index: Int) : GolfMove
@Serializable @Serializable
@SerialName("discard_flip") @SerialName("discard_flip")
data class DiscardAndFlip(val index: Int) : GolfMove 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) deck = deck.drop(options.gridSize)
} }
// The opening reveals are the leftmost cards of the top row, so every // Nothing starts face up: each player chooses which of their own cards
// player starts knowing the same shape of their own board. // to turn over, which is a real decision rather than a dealt-out one.
val revealed = List(options.playerCount) { val revealed = List(options.playerCount) { List(options.gridSize) { false } }
List(options.gridSize) { index -> index < options.startingReveals }
}
val firstDiscard = deck.first() val firstDiscard = deck.first()
return GolfState( return GolfState(
@@ -77,7 +75,8 @@ object GolfRules : GameRules<GolfState, GolfMove> {
finalTurnsLeft = 0, finalTurnsLeft = 0,
holeScores = List(options.playerCount) { 0 }, holeScores = List(options.playerCount) { 0 },
scores = scores, scores = scores,
phase = GolfPhase.DRAW, phase = if (options.startingReveals > 0) GolfPhase.SETUP else GolfPhase.DRAW,
idleDiscards = 0,
log = log, log = log,
) )
} }
@@ -95,7 +94,7 @@ object GolfRules : GameRules<GolfState, GolfMove> {
} }
override fun currentSeat(state: GolfState): Int? = when (state.phase) { 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 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> { override fun legalMoves(state: GolfState, seat: Int): List<GolfMove> {
if (currentSeat(state) != seat) return emptyList() if (currentSeat(state) != seat) return emptyList()
return when (state.phase) { return when (state.phase) {
GolfPhase.SETUP -> (0 until state.options.gridSize)
.filter { !state.revealed[seat][it] }
.map { RevealCard(it) }
GolfPhase.DRAW -> buildList { GolfPhase.DRAW -> buildList {
if (state.stock.isNotEmpty()) add(DrawFromStock) if (state.stock.isNotEmpty()) add(DrawFromStock)
if (state.discard.isNotEmpty()) add(DrawFromDiscard) if (state.discard.isNotEmpty()) add(DrawFromDiscard)
@@ -119,6 +122,11 @@ object GolfRules : GameRules<GolfState, GolfMove> {
for (index in 0 until state.options.gridSize) { for (index in 0 until state.options.gridSize) {
if (!state.revealed[seat][index]) add(DiscardAndFlip(index)) 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 ReplaceCard -> applyReplace(state, seat, move.index)
is DiscardAndFlip -> applyDiscardAndFlip(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 { private fun applyReplace(state: GolfState, seat: Int, index: Int): GolfState {
val drawn = requireNotNull(state.drawn) { "Nothing drawn" } val drawn = requireNotNull(state.drawn) { "Nothing drawn" }
val grids = state.grids.map { it.toMutableList() } 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 * Closes out a turn: notices a finished grid, counts down the last lap and
* either moves on or scores the hole. * either moves on or scores the hole.
*/ */
private fun endTurn(state: GolfState, seat: Int): GolfState { private fun endTurn(state: GolfState, seat: Int, idle: Boolean = false): GolfState {
var next = state 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 // The first player to turn everything over gives everyone else one
// more turn, and no more than 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. * next card lands on top of it.
*/ */
private val holdBeforeNextMove: (S) -> Long = { 0L }, 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 } enum class Role { HOST, CLIENT }
@@ -65,6 +71,11 @@ class MatchController<S : Any, M : Any>(
private val _notice = MutableStateFlow<String?>(null) private val _notice = MutableStateFlow<String?>(null)
val notice: StateFlow<String?> = _notice.asStateFlow() 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. */ /** Host only: the true state. Clients never see this. */
private var authoritative: S? = null private var authoritative: S? = null
private val lock = Mutex() private val lock = Mutex()
@@ -198,6 +209,12 @@ class MatchController<S : Any, M : Any>(
val seat = rules.currentSeat(current) val seat = rules.currentSeat(current)
if (seat == null) { if (seat == null) {
// Nobody on the clock: a hand has been scored, or the game ended. // 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) delay(aiThinkingMillis() * 2)
val advanced = advanceIdle(current) val advanced = advanceIdle(current)
if (advanced == null) { 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() { fun close() {
peers.values.forEach { runCatching { it.close() } } peers.values.forEach { runCatching { it.close() } }
peers.clear() peers.clear()
@@ -263,7 +263,7 @@ private fun GolfOptionsEditor(options: GolfOptions, onChange: (GolfOptions) -> U
) )
ChipRow( ChipRow(
label = "Cards each", label = "Cards each",
values = listOf(4, 6, 9), values = listOf(4, 6, 8, 9),
selected = options.gridSize, selected = options.gridSize,
display = { "$it" }, display = { "$it" },
onSelect = { size -> onSelect = { size ->
@@ -290,6 +290,13 @@ private fun GolfOptionsEditor(options: GolfOptions, onChange: (GolfOptions) -> U
display = { "$it" }, display = { "$it" },
onSelect = { onChange(options.copy(startingReveals = 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(
text = "${options.rows} rows of ${options.cols}. Matching columns cancel out" + text = "${options.rows} rows of ${options.cols}. Matching columns cancel out" +
if (options.gridSize == 9) ", and so do matching rows." else ".", if (options.gridSize == 9) ", and so do matching rows." else ".",
@@ -145,6 +145,8 @@ fun PlayScreen(
advanceIdle = { state -> advanceIdle = { state ->
if (state.phase == GolfPhase.HOLE_OVER) GolfRules.nextHole(state) else null 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) }, aiThinkingMillis = { settings.scaled(700L) },
) )
} }
@@ -15,7 +15,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.TextButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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 androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.golf.DiscardAndFlip 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.DrawFromDiscard
import org.prolibertate.games.game.golf.DrawFromStock import org.prolibertate.games.game.golf.DrawFromStock
import org.prolibertate.games.game.golf.GolfMove import org.prolibertate.games.game.golf.GolfMove
import org.prolibertate.games.game.golf.GolfPhase import org.prolibertate.games.game.golf.GolfPhase
import org.prolibertate.games.game.golf.GolfState import org.prolibertate.games.game.golf.GolfState
import org.prolibertate.games.game.golf.ReplaceCard 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.game.golf.golfValue
import org.prolibertate.games.net.MatchController import org.prolibertate.games.net.MatchController
import org.prolibertate.games.ui.PrimaryAction
import org.prolibertate.games.ui.ScreenScaffold import org.prolibertate.games.ui.ScreenScaffold
import org.prolibertate.games.ui.common.CardBackView import org.prolibertate.games.ui.common.CardBackView
import org.prolibertate.games.ui.common.PlayingCardView import org.prolibertate.games.ui.common.PlayingCardView
@@ -109,21 +113,16 @@ fun GolfScreen(
) )
} }
} }
// 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")
} }
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( OwnGrid(
@@ -135,9 +134,85 @@ fun GolfScreen(
onMove = { controller.submit(it); mode = PlaceMode.SWAP }, 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 @Composable
private fun ScoreLine(state: GolfState, localSeat: Int) { private fun ScoreLine(state: GolfState, localSeat: Int) {
@@ -226,10 +301,18 @@ private fun OwnGrid(
val options = state.options val options = state.options
val replacements = legal.filterIsInstance<ReplaceCard>().map { it.index }.toSet() val replacements = legal.filterIsInstance<ReplaceCard>().map { it.index }.toSet()
val flips = legal.filterIsInstance<DiscardAndFlip>().map { it.index }.toSet() val flips = legal.filterIsInstance<DiscardAndFlip>().map { it.index }.toSet()
val reveals = legal.filterIsInstance<RevealCard>().map { it.index }.toSet()
Column { Column {
Text( Text(
text = when { 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 -> state.phase == GolfPhase.PLACE && mode == PlaceMode.SWAP ->
"Tap a card to swap it out" "Tap a card to swap it out"
@@ -244,19 +327,24 @@ private fun OwnGrid(
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
for (col in 0 until options.cols) { for (col in 0 until options.cols) {
val index = row * options.cols + col val index = row * options.cols + col
val tappable = when (mode) { val tappable = if (state.phase == GolfPhase.SETUP) {
index in reveals
} else {
when (mode) {
PlaceMode.SWAP -> index in replacements PlaceMode.SWAP -> index in replacements
PlaceMode.THROW -> index in flips PlaceMode.THROW -> index in flips
} }
}
GridCard( GridCard(
card = state.grids[localSeat].getOrNull(index), card = state.grids[localSeat].getOrNull(index),
faceUp = state.revealed[localSeat].getOrElse(index) { false }, faceUp = state.revealed[localSeat].getOrElse(index) { false },
width = cardWidth, width = cardWidth,
tappable = tappable, tappable = tappable,
onClick = { onClick = {
when (mode) { when {
PlaceMode.SWAP -> onMove(ReplaceCard(index)) state.phase == GolfPhase.SETUP -> onMove(RevealCard(index))
PlaceMode.THROW -> onMove(DiscardAndFlip(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) 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 ------------------------------------------------------------ // -- Scoring ------------------------------------------------------------
@Test @Test
@@ -98,7 +109,9 @@ class GolfRulesTest {
@Test @Test
fun `everyone gets a grid and two cards face up`() { 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) assertEquals(3, state.grids.size)
state.grids.forEach { assertEquals(6, it.size) } state.grids.forEach { assertEquals(6, it.size) }
state.revealed.forEach { assertEquals(2, it.count { shown -> shown }) } state.revealed.forEach { assertEquals(2, it.count { shown -> shown }) }
@@ -114,11 +127,133 @@ class GolfRulesTest {
assertEquals(104, dealt + state.stock.size + state.discard.size) 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 ----------------------------------------------------- // -- Turn structure -----------------------------------------------------
@Test @Test
fun `a turn is draw then place`() { 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()) assertEquals(setOf(DrawFromStock, DrawFromDiscard), GolfRules.legalMoves(state, 0).toSet())
state = GolfRules.applyMove(state, 0, DrawFromStock) state = GolfRules.applyMove(state, 0, DrawFromStock)
@@ -132,7 +267,7 @@ class GolfRulesTest {
@Test @Test
fun `a card taken from the discard pile must be used`() { 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) state = GolfRules.applyMove(state, 0, DrawFromDiscard)
val moves = GolfRules.legalMoves(state, 0) val moves = GolfRules.legalMoves(state, 0)
assertTrue("no throwing it back", moves.none { it is DiscardAndFlip }) assertTrue("no throwing it back", moves.none { it is DiscardAndFlip })
@@ -141,7 +276,7 @@ class GolfRulesTest {
@Test @Test
fun `replacing a card turns that slot face up and discards the old one`() { 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) state = GolfRules.applyMove(state, 0, DrawFromStock)
val drawn = state.drawn!! val drawn = state.drawn!!
val replacedCard = state.grids[0][5] val replacedCard = state.grids[0][5]
@@ -156,7 +291,7 @@ class GolfRulesTest {
@Test @Test
fun `throwing the drawn card turns one of your own over`() { 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) state = GolfRules.applyMove(state, 0, DrawFromStock)
val drawn = state.drawn!! val drawn = state.drawn!!
assertFalse(state.revealed[0][4]) assertFalse(state.revealed[0][4])
@@ -171,7 +306,7 @@ class GolfRulesTest {
@Test @Test
fun `turning the last card over gives everyone else one more turn`() { fun `turning the last card over gives everyone else one more turn`() {
val options = GolfOptions(playerCount = 3, gridSize = 4, startingReveals = 3, holes = 1) 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. // Seat 0 has one card left face down; use it to close.
state = GolfRules.applyMove(state, 0, DrawFromStock) state = GolfRules.applyMove(state, 0, DrawFromStock)
state = GolfRules.applyMove(state, 0, DiscardAndFlip(3)) state = GolfRules.applyMove(state, 0, DiscardAndFlip(3))
@@ -193,7 +328,7 @@ class GolfRulesTest {
@Test @Test
fun `scores accumulate across holes`() { fun `scores accumulate across holes`() {
val options = GolfOptions(playerCount = 2, gridSize = 4, startingReveals = 4, holes = 3) 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 var guard = 0
while (state.hole == 0 && !GolfRules.isFinished(state) && guard++ < 400) { while (state.hole == 0 && !GolfRules.isFinished(state) && guard++ < 400) {
val seat = GolfRules.currentSeat(state) ?: break val seat = GolfRules.currentSeat(state) ?: break
@@ -276,7 +411,9 @@ class GolfRulesTest {
@Test @Test
fun `a view hides face-down cards including your own`() { 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) val view = GolfRules.viewFor(state, seat = 0)
for (index in 0 until 6) { for (index in 0 until 6) {
@@ -293,7 +430,9 @@ class GolfRulesTest {
fun `masking does not leak the real card`() { fun `masking does not leak the real card`() {
// A grid deliberately built with no twos of spades in it, so if the // A grid deliberately built with no twos of spades in it, so if the
// mask ever showed through it would be obvious. // 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 view = GolfRules.viewFor(state, seat = 1)
val hiddenIndices = (0 until 6).filter { !state.revealed[1][it] } val hiddenIndices = (0 until 6).filter { !state.revealed[1][it] }
hiddenIndices.forEach { index -> hiddenIndices.forEach { index ->
@@ -308,7 +447,10 @@ class GolfRulesTest {
fun `state and moves survive a round trip`() { fun `state and moves survive a round trip`() {
val state = GolfRules.initialState(config()) val state = GolfRules.initialState(config())
assertEquals(state, GolfRules.decodeState(GolfRules.encodeState(state))) 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))) assertEquals(it, GolfRules.decodeMove(GolfRules.encodeMove(it)))
} }
} }
+20 -3
View File
@@ -12,11 +12,18 @@ game setup screen. Lowest score wins, as on a golf course.
| --- | --- | | --- | --- |
| 4 | 2 rows of 2 | | 4 | 2 rows of 2 |
| 6 | 2 rows of 3 | | 6 | 2 rows of 3 |
| 8 | 2 rows of 4 |
| 9 | 3 rows of 3 | | 9 | 3 rows of 3 |
- **Seen at the start — *toggle*.** How many of your own cards you may look at - **Seen at the start — *toggle*.** How many of your own cards you turn over
before play begins; two by default. They are the leftmost cards of the top before play begins; two by default.
row, so everyone starts with the same shape of knowledge.
## 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. - 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 - 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. 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. 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 - **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. 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 ## 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 Scores accumulate over the holes — **toggle** for 3, 6 or 9 — and the lowest
total wins. 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 ## Known simplifications
- **No jokers.** Some house rules add them as −5 or wild. Adding a joker means - **No jokers.** Some house rules add them as −5 or wild. Adding a joker means