From d989907d7e9b082e66e8dea7dcf6520e06b4a58c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 03:59:53 +0000 Subject: [PATCH] Golf: 8-card grids, chosen openers, final putt, end-of-hole score card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN --- .../prolibertate/games/game/golf/GolfAi.kt | 38 +++- .../prolibertate/games/game/golf/GolfModel.kt | 44 ++++- .../prolibertate/games/game/golf/GolfRules.kt | 73 +++++++- .../prolibertate/games/net/MatchController.kt | 29 ++++ .../prolibertate/games/ui/GameSetupScreen.kt | 9 +- .../org/prolibertate/games/ui/PlayScreen.kt | 2 + .../prolibertate/games/ui/game/GolfScreen.kt | 130 +++++++++++--- .../games/game/golf/GolfRulesTest.kt | 162 ++++++++++++++++-- docs/RULES-golf.md | 23 ++- 9 files changed, 458 insertions(+), 52 deletions(-) diff --git a/app/src/main/java/org/prolibertate/games/game/golf/GolfAi.kt b/app/src/main/java/org/prolibertate/games/game/golf/GolfAi.kt index c975297..02cf85e 100644 --- a/app/src/main/java/org/prolibertate/games/game/golf/GolfAi.kt +++ b/app/src/main/java/org/prolibertate/games/game/golf/GolfAi.kt @@ -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 { 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 { + val reveals = legal.filterIsInstance() + 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 { 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() diff --git a/app/src/main/java/org/prolibertate/games/game/golf/GolfModel.kt b/app/src/main/java/org/prolibertate/games/game/golf/GolfModel.kt index 867407a..a437968 100644 --- a/app/src/main/java/org/prolibertate/games/game/golf/GolfModel.kt +++ b/app/src/main/java/org/prolibertate/games/game/golf/GolfModel.kt @@ -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, 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, val scores: List, 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, ) { 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 diff --git a/app/src/main/java/org/prolibertate/games/game/golf/GolfRules.kt b/app/src/main/java/org/prolibertate/games/game/golf/GolfRules.kt index ae2a983..9358e99 100644 --- a/app/src/main/java/org/prolibertate/games/game/golf/GolfRules.kt +++ b/app/src/main/java/org/prolibertate/games/game/golf/GolfRules.kt @@ -55,11 +55,9 @@ object GolfRules : GameRules { 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 { 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 { } 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 { override fun legalMoves(state: GolfState, seat: Int): List { 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 { 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 { 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 { ) } + 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. diff --git a/app/src/main/java/org/prolibertate/games/net/MatchController.kt b/app/src/main/java/org/prolibertate/games/net/MatchController.kt index 7a1bc86..0bd5fe9 100644 --- a/app/src/main/java/org/prolibertate/games/net/MatchController.kt +++ b/app/src/main/java/org/prolibertate/games/net/MatchController.kt @@ -47,6 +47,12 @@ class MatchController( * 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( private val _notice = MutableStateFlow(null) val notice: StateFlow = _notice.asStateFlow() + private val _awaitingConfirmation = MutableStateFlow(false) + + /** True while a scoreboard is up and the table is waiting to be released. */ + val awaitingConfirmation: StateFlow = _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( 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( } } + /** + * 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() diff --git a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt index 76467d0..6af6d44 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -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 ".", diff --git a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt index f137aa7..e2097fe 100644 --- a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt @@ -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) }, ) } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/GolfScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/GolfScreen.kt index ec3d220..1a19ba6 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/GolfScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/GolfScreen.kt @@ -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().map { it.index }.toSet() val flips = legal.filterIsInstance().map { it.index }.toSet() + val reveals = legal.filterIsInstance().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)) } }, ) diff --git a/app/src/test/java/org/prolibertate/games/game/golf/GolfRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/golf/GolfRulesTest.kt index 77e9eef..a39fc80 100644 --- a/app/src/test/java/org/prolibertate/games/game/golf/GolfRulesTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/golf/GolfRulesTest.kt @@ -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))) } } diff --git a/docs/RULES-golf.md b/docs/RULES-golf.md index bb731eb..c450fa9 100644 --- a/docs/RULES-golf.md +++ b/docs/RULES-golf.md @@ -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