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(
/** 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(
)
}
}
// 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(
@@ -135,9 +134,85 @@ 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) {
@@ -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,19 +327,24 @@ 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) {
val tappable = if (state.phase == GolfPhase.SETUP) {
index in reveals
} else {
when (mode) {
PlaceMode.SWAP -> index in replacements
PlaceMode.THROW -> index in flips
}
}
GridCard(
card = state.grids[localSeat].getOrNull(index),
faceUp = state.revealed[localSeat].getOrElse(index) { false },
width = cardWidth,
tappable = tappable,
onClick = {
when (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
View File
@@ -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