From 831b2fc92515bf807efaa16d1d7b85db8e55f94f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 15:39:04 +0000 Subject: [PATCH] Screens for Yahtzee and Klondike, and both into the catalogue Two engines that had been sitting behind ids now have screens, so they are tappable and playable. The Yahtzee card shows what the dice on the table would score in every box still open. That comparison is the whole of the decision, and doing the arithmetic by eye is not what anybody wants from a phone. A turn is two choices and the screen shows them together: which dice to keep before throwing again, and which box to write in when the throwing stops. Klondike plays tap-to-take then tap-to-put rather than by dragging. A drag across seven columns of overlapping cards on a phone is mostly a test of aim. Tapping a face-up card takes it and everything resting on it, so a run travels the way it would on a table. Which taps do anything is read off the legal moves the rules offer, so the screen never has to know why a move is allowed. Klondike deals again by rebuilding the controller, as peg solitaire does: a blocked deal is genuinely finished, so driveIdleSeats has already stopped and advanceIdle would never run. Yahtzee seats one to six, so it needed a case in seatCountFor. Without one the seat count falls through to the catalogue minimum, which is one now that solo play exists -- a two-handed game would have been dealt a single seat and the rules would have refused the table. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC --- .../org/prolibertate/games/game/Catalog.kt | 32 +- .../prolibertate/games/ui/GameSetupScreen.kt | 66 ++++ .../org/prolibertate/games/ui/PlayScreen.kt | 54 ++++ .../games/ui/game/KlondikeScreen.kt | 257 +++++++++++++++ .../games/ui/game/YahtzeeScreen.kt | 304 ++++++++++++++++++ .../prolibertate/games/game/CatalogTest.kt | 2 + 6 files changed, 705 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/KlondikeScreen.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/YahtzeeScreen.kt diff --git a/app/src/main/java/org/prolibertate/games/game/Catalog.kt b/app/src/main/java/org/prolibertate/games/game/Catalog.kt index 14dc42c..f96b203 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -35,19 +35,9 @@ object GameCatalog { const val CRAZY_EIGHTS = "crazy8s" const val CRIBBAGE = "cribbage" - /** - * Hearts has a rules engine and an AI but no screen yet, so it is an id - * without a [GameDescriptor]: nothing to tap, and nothing to crash. It joins - * [all] when its screen lands. - */ const val HEARTS = "hearts" const val PEG_SOLITAIRE = "pegsolitaire" - /** - * An id ahead of its screen: the engine is written and tested, but a - * catalogue entry with nothing behind it would crash on the first tap, so it - * joins [all] when its screen lands. - */ const val YAHTZEE = "yahtzee" const val KLONDIKE = "klondike" const val SEQUENCE = "sequence" @@ -143,6 +133,17 @@ object GameCatalog { "of spades costs thirteen, and taking all of them costs everybody else.", available = true, ), + GameDescriptor( + id = KLONDIKE, + title = "Klondike", + category = GameCategory.CARD, + minPlayers = 1, + maxPlayers = 1, + teamBased = false, + blurb = "Patience, as everyone means it. Seven columns down to the aces, " + + "and a pack that decides most of it before you start.", + available = true, + ), GameDescriptor( id = SEQUENCE, title = "Sequence", @@ -217,6 +218,17 @@ object GameCatalog { "take the one you passed, and try to finish with one standing.", available = true, ), + GameDescriptor( + id = YAHTZEE, + title = "Yahtzee", + category = GameCategory.BOARD, + minPlayers = 1, + maxPlayers = 6, + teamBased = false, + blurb = "Five dice, three throws, thirteen boxes to fill. Every box can be " + + "written in once, so the hard part is what to give up on.", + available = true, + ), GameDescriptor( id = PIRATES, title = "Pirates and Bulgars", 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 1fe2edf..58ee816 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -41,6 +41,8 @@ import org.prolibertate.games.game.cribbage.CribbageOptions import org.prolibertate.games.game.euchre.EuchreOptions import org.prolibertate.games.game.golf.GolfOptions import org.prolibertate.games.game.hearts.HeartsOptions +import org.prolibertate.games.game.klondike.KlondikeOptions +import org.prolibertate.games.game.yahtzee.YahtzeeOptions import org.prolibertate.games.game.pegsolitaire.PegBoard import org.prolibertate.games.game.pegsolitaire.PegGoal import org.prolibertate.games.game.pegsolitaire.PegSolitaireOptions @@ -94,6 +96,8 @@ fun GameSetupScreen( var pirates by remember { mutableStateOf(PiratesOptions()) } var hearts by remember { mutableStateOf(HeartsOptions()) } var pegs by remember { mutableStateOf(PegSolitaireOptions()) } + var yahtzee by remember { mutableStateOf(YahtzeeOptions()) } + var klondike by remember { mutableStateOf(KlondikeOptions()) } // Not an engine option: which seat the local player takes. Seat 0 is // always White, so choosing Black means sitting in seat 1. var playWhite by remember { mutableStateOf(true) } @@ -119,6 +123,8 @@ fun GameSetupScreen( GameCatalog.PIRATES -> setupJson.encodeToString(pirates) GameCatalog.HEARTS -> setupJson.encodeToString(hearts) GameCatalog.PEG_SOLITAIRE -> setupJson.encodeToString(pegs) + GameCatalog.YAHTZEE -> setupJson.encodeToString(yahtzee) + GameCatalog.KLONDIKE -> setupJson.encodeToString(klondike) else -> "{}" } val seatCount = seatCountFor(descriptor.id, optionsJson) @@ -174,6 +180,10 @@ fun GameSetupScreen( GameCatalog.PEG_SOLITAIRE -> PegSolitaireOptionsEditor(pegs) { pegs = it } + GameCatalog.YAHTZEE -> YahtzeeOptionsEditor(yahtzee) { yahtzee = it } + + GameCatalog.KLONDIKE -> KlondikeOptionsEditor(klondike) { klondike = it } + else -> Text("No options yet for this game.") } @@ -272,6 +282,9 @@ fun seatCountFor(gameId: String, optionsJson: String): Int { GameCatalog.CHESS, GameCatalog.MORRIS, GameCatalog.CHECKERS, GameCatalog.BACKGAMMON, GameCatalog.MASTERMIND, GameCatalog.PIRATES -> 2 + GameCatalog.YAHTZEE -> + setupJson.decodeFromString(optionsJson).playerCount + GameCatalog.TAYU -> setupJson.decodeFromString(optionsJson).playerCount @@ -1012,3 +1025,56 @@ private fun PegSolitaireOptionsEditor( ) } } + +@Composable +private fun YahtzeeOptionsEditor(options: YahtzeeOptions, onChange: (YahtzeeOptions) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ChipRow( + label = "Players", + values = (1..6).toList(), + selected = options.playerCount, + display = { if (it == 1) "On your own" else "$it" }, + onSelect = { onChange(options.copy(playerCount = it)) }, + ) + ToggleRow( + title = "Second Yahtzee scores a hundred", + subtitle = "Only once the Yahtzee box holds the fifty. A zero written there " + + "earlier forfeits it, as the printed rule has it.", + checked = options.yahtzeeBonus, + onChange = { onChange(options.copy(yahtzeeBonus = it)) }, + ) + } +} + +@Composable +private fun KlondikeOptionsEditor(options: KlondikeOptions, onChange: (KlondikeOptions) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ChipRow( + label = "Turn from the stock", + values = listOf(1, 3), + selected = options.drawCount, + display = { "$it at a time" }, + onSelect = { onChange(options.copy(drawCount = it)) }, + ) + ChipRow( + label = "Turning the waste back over", + values = listOf(null, 0, 1, 2), + selected = options.redealLimit, + display = { + when (it) { + null -> "As often as you like" + 0 -> "Never — one pass" + else -> "$it times" + } + }, + onSelect = { onChange(options.copy(redealLimit = it)) }, + ) + ToggleRow( + title = "Only a king fills a space", + subtitle = "Turning this off lets any card into an empty column, which makes " + + "far more deals winnable.", + checked = options.kingsOnlyInSpaces, + onChange = { onChange(options.copy(kingsOnlyInSpaces = it)) }, + ) + } +} 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 7c7b05c..4b93b30 100644 --- a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt @@ -29,6 +29,14 @@ import org.prolibertate.games.game.euchre.EuchreMove import org.prolibertate.games.game.euchre.EuchrePhase import org.prolibertate.games.game.euchre.EuchreRules import org.prolibertate.games.game.euchre.EuchreState +import org.prolibertate.games.game.klondike.KlondikeAi +import org.prolibertate.games.game.klondike.KlondikeMove +import org.prolibertate.games.game.klondike.KlondikeRules +import org.prolibertate.games.game.klondike.KlondikeState +import org.prolibertate.games.game.yahtzee.YahtzeeAi +import org.prolibertate.games.game.yahtzee.YahtzeeMove +import org.prolibertate.games.game.yahtzee.YahtzeeRules +import org.prolibertate.games.game.yahtzee.YahtzeeState import org.prolibertate.games.game.hearts.HeartsAi import org.prolibertate.games.game.hearts.HeartsMove import org.prolibertate.games.game.hearts.HeartsPhase @@ -94,12 +102,14 @@ import org.prolibertate.games.ui.game.ChessScreen import org.prolibertate.games.ui.game.CrazyEightsScreen import org.prolibertate.games.ui.game.CribbageScreen import org.prolibertate.games.ui.game.HeartsScreen +import org.prolibertate.games.ui.game.KlondikeScreen import org.prolibertate.games.ui.game.EuchreScreen import org.prolibertate.games.ui.game.GolfScreen import org.prolibertate.games.ui.game.KaiserScreen import org.prolibertate.games.ui.game.MastermindScreen import org.prolibertate.games.ui.game.MorrisScreen import org.prolibertate.games.ui.game.PegSolitaireScreen +import org.prolibertate.games.ui.game.YahtzeeScreen import org.prolibertate.games.ui.game.PiratesScreen import org.prolibertate.games.ui.game.PresidentScreen import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS @@ -505,6 +515,50 @@ fun PlayScreen( ) } + GameCatalog.YAHTZEE -> { + val controller = remember(route) { + MatchController( + rules = YahtzeeRules, + ai = YahtzeeAi, + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + // Long enough to watch the dice land, short enough that a + // six-handed game is not an evening of waiting. + aiThinkingMillis = { settings.scaled(500L) }, + ) + } + StartMatch(env, controller, route) + YahtzeeScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) + } + + GameCatalog.KLONDIKE -> { + // Dealing again rebuilds the controller, for the same reason peg + // solitaire does: a blocked deal is genuinely finished, so + // driveIdleSeats has already stopped and advanceIdle never runs. + var generation by remember(route) { mutableIntStateOf(0) } + val controller = remember(route, generation) { + MatchController( + rules = KlondikeRules, + // Only ever asked for a hint; the seat is the player's. + ai = KlondikeAi, + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + ) + } + StartMatch(env, controller, route) + KlondikeScreen( + controller = controller, + onRestart = { generation++ }, + onExit = onExit, + ) + } + GameCatalog.MORRIS -> { val controller = remember(route) { MatchController( diff --git a/app/src/main/java/org/prolibertate/games/ui/game/KlondikeScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/KlondikeScreen.kt new file mode 100644 index 0000000..1bd3ec3 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/KlondikeScreen.kt @@ -0,0 +1,257 @@ +package org.prolibertate.games.ui.game + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.prolibertate.games.game.klondike.Draw +import org.prolibertate.games.game.klondike.KlondikeMove +import org.prolibertate.games.game.klondike.KlondikeState +import org.prolibertate.games.game.klondike.MoveCards +import org.prolibertate.games.game.klondike.Redeal +import org.prolibertate.games.game.klondike.Spot +import org.prolibertate.games.game.klondike.foundationSuit +import org.prolibertate.games.net.MatchController +import org.prolibertate.games.ui.ScreenScaffold +import org.prolibertate.games.ui.common.CardBackView +import org.prolibertate.games.ui.common.PlayingCardView +import org.prolibertate.games.ui.theme.FeltGreenDark +import org.prolibertate.games.ui.theme.Parchment +import org.prolibertate.games.ui.theme.WallaceGold + +private const val TITLE = "Klondike" +private val CARD_WIDTH: Dp = 40.dp + +/** + * The patience table. + * + * Play is tap-to-take then tap-to-put, rather than dragging: on a phone a drag + * over seven columns of overlapping cards is mostly a test of aim. Tapping a + * face-up card takes it and everything sitting on it, so a run travels the way + * it would on a table. + * + * Which taps do anything is read straight off the legal moves the rules offer, + * so the screen never has to know why a move is allowed — only that it is. + */ +@Composable +fun KlondikeScreen( + controller: MatchController, + onRestart: () -> Unit, + onExit: () -> Unit, +) { + val state by controller.state.collectAsState() + val legal by controller.legalMoves.collectAsState() + val abandoned by controller.abandoned.collectAsState() + + var held by remember { mutableStateOf?>(null) } + var confirmingEnd by remember { mutableStateOf(false) } + + val endGameAction: @Composable () -> Unit = { + TextButton(onClick = { confirmingEnd = true }) { Text("End game") } + } + + val current = state + if (current == null) { + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") } + abandoned?.let { LeftTableDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) EndTableDialog(onExit) { confirmingEnd = false } + } + return + } + + val moves = legal.filterIsInstance() + val canDraw = legal.contains(Draw) + val canRedeal = legal.contains(Redeal) + val finished = legal.isEmpty() + + // Where the cards in hand could go, and which spots could be picked up. + val targets = held?.let { (from, count) -> + moves.filter { it.from == from && it.count == count }.map { it.to }.toSet() + }.orEmpty() + val sources = moves.map { it.from to it.count }.toSet() + + fun take(spot: Spot, count: Int) { + val inHand = held + held = when { + inHand != null && targets.contains(spot) -> { + controller.submit(MoveCards(inHand.first, spot, inHand.second)) + null + } + + inHand?.first == spot -> null + sources.contains(spot to count) -> spot to count + else -> null + } + } + + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = when { + current.isWon -> "Out, in ${current.moves} moves." + finished -> "Blocked — nothing left to try." + held != null -> "Tap where it goes." + else -> "${current.foundations.sumOf { it.size }} of 52 home." + }, + fontWeight = FontWeight.Bold, + ) + + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + // Stock, waste, and the four homes across the top. + Slot( + highlighted = false, + onClick = { if (canDraw) controller.submit(Draw) else if (canRedeal) controller.submit(Redeal) }, + ) { + if (current.stock.isNotEmpty()) { + CardBackView(width = CARD_WIDTH) + } else { + Empty(label = if (canRedeal) "↻" else "") + } + } + Slot( + highlighted = held?.first == Spot.waste, + onClick = { take(Spot.waste, 1) }, + ) { + current.waste.lastOrNull() + ?.let { PlayingCardView(card = it, width = CARD_WIDTH) } + ?: Empty() + } + current.foundations.forEachIndexed { index, pile -> + Slot( + highlighted = targets.contains(Spot.foundation(index)), + onClick = { take(Spot.foundation(index), 1) }, + ) { + pile.lastOrNull() + ?.let { PlayingCardView(card = it, width = CARD_WIDTH) } + ?: Empty(label = foundationSuit(index).symbol) + } + } + } + + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + current.tableau.forEachIndexed { index, pile -> + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + if (pile.isEmpty) { + Slot( + highlighted = targets.contains(Spot.tableau(index)), + onClick = { take(Spot.tableau(index), 1) }, + ) { Empty() } + } + pile.faceDown.forEach { CardBackView(width = CARD_WIDTH) } + pile.faceUp.forEachIndexed { depth, card -> + // Taking a card takes everything resting on it, so + // the count is however many are below it in the run. + val count = pile.faceUp.size - depth + Slot( + highlighted = held == (Spot.tableau(index) to count) || + (depth == pile.faceUp.lastIndex && targets.contains(Spot.tableau(index))), + onClick = { take(Spot.tableau(index), count) }, + ) { PlayingCardView(card = card, width = CARD_WIDTH) } + } + } + } + } + + if (finished) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onRestart) { Text("Deal again") } + TextButton(onClick = onExit) { Text("Leave") } + } + } + + current.log.takeLast(2).forEach { + Text(it, style = MaterialTheme.typography.bodySmall) + } + } + + abandoned?.let { LeftTableDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) EndTableDialog(onExit) { confirmingEnd = false } + } +} + +@Composable +private fun Slot(highlighted: Boolean, onClick: () -> Unit, content: @Composable () -> Unit) { + Box( + modifier = Modifier + .width(CARD_WIDTH) + .border( + border = BorderStroke(if (highlighted) 3.dp else 0.dp, WallaceGold), + shape = RoundedCornerShape(4.dp), + ) + .clickable { onClick() }, + ) { + content() + } +} + +/** An empty place on the table, drawn so it can still be tapped into. */ +@Composable +private fun Empty(label: String = "") { + Box( + modifier = Modifier + .width(CARD_WIDTH) + .aspectRatio(0.7f) + .background(FeltGreenDark, RoundedCornerShape(4.dp)), + contentAlignment = Alignment.Center, + ) { + if (label.isNotEmpty()) { + Text(label, color = Parchment.copy(alpha = 0.5f)) + } + } +} + +@Composable +private fun EndTableDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) { + AlertDialog( + onDismissRequest = onKeepPlaying, + title = { Text("End this game?") }, + text = { Text("The deal is left as it stands.") }, + confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } }, + dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } }, + ) +} + +@Composable +private fun LeftTableDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) { + AlertDialog( + onDismissRequest = onStay, + title = { Text("The game has ended") }, + text = { Text(notice) }, + confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } }, + dismissButton = { TextButton(onClick = onStay) { Text("Look at the table") } }, + ) +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/YahtzeeScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/YahtzeeScreen.kt new file mode 100644 index 0000000..abf9903 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/YahtzeeScreen.kt @@ -0,0 +1,304 @@ +package org.prolibertate.games.ui.game + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Divider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.prolibertate.games.game.yahtzee.DICE_COUNT +import org.prolibertate.games.game.yahtzee.RollDice +import org.prolibertate.games.game.yahtzee.ScoreIn +import org.prolibertate.games.game.yahtzee.YahtzeeCategory +import org.prolibertate.games.game.yahtzee.YahtzeeMove +import org.prolibertate.games.game.yahtzee.YahtzeeSection +import org.prolibertate.games.game.yahtzee.YahtzeeState +import org.prolibertate.games.game.yahtzee.scoreOf +import org.prolibertate.games.net.MatchController +import org.prolibertate.games.ui.ScreenScaffold +import org.prolibertate.games.ui.theme.Parchment +import org.prolibertate.games.ui.theme.WallaceGold +import org.prolibertate.games.ui.theme.WallaceRed + +private const val TITLE = "Yahtzee" + +/** + * The Yahtzee card. + * + * A turn is two decisions and the screen shows them together: which dice to keep + * before throwing again, and which box to write in when the throwing stops. Each + * open box shows what the dice on the table would score in it, because that + * comparison is the whole of the choice — and doing the arithmetic by eye is not + * what anyone wants from a phone. + */ +@Composable +fun YahtzeeScreen( + controller: MatchController, + localSeat: Int, + onExit: () -> Unit, +) { + val state by controller.state.collectAsState() + val legal by controller.legalMoves.collectAsState() + val abandoned by controller.abandoned.collectAsState() + + val kept = remember { mutableStateListOf() } + var confirmingEnd by remember { mutableStateOf(false) } + + val endGameAction: @Composable () -> Unit = { + TextButton(onClick = { confirmingEnd = true }) { Text("End game") } + } + + val current = state + if (current == null) { + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") } + abandoned?.let { LeftCardDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) EndCardDialog(onExit) { confirmingEnd = false } + } + return + } + + val yours = current.turn == localSeat && !current.isOver + val canRoll = legal.any { it is RollDice } + val writable = legal.filterIsInstance().map { it.category }.toSet() + + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = when { + current.isOver -> "Cards full." + !yours -> "Seat ${current.turn} is throwing…" + !current.hasRolled -> "Your turn — throw the dice." + else -> "${current.rollsLeft} throws left, or write it down." + }, + fontWeight = FontWeight.Bold, + ) + + DiceRow( + dice = current.dice, + kept = kept, + enabled = yours && current.hasRolled && canRoll, + onToggle = { index -> + if (kept.contains(index)) kept.remove(index) else kept.add(index) + }, + ) + + if (yours && canRoll) { + Button( + onClick = { + controller.submit(RollDice(kept.toList())) + // What was kept applied to the throw just made; the next + // one starts from whatever is on the table now. + kept.clear() + }, + ) { + Text(if (current.hasRolled) "Throw the rest" else "Throw") + } + } + + Divider() + ScoreCard( + state = current, + localSeat = localSeat, + writable = if (yours) writable else emptySet(), + onWrite = { + controller.submit(ScoreIn(it)) + kept.clear() + }, + ) + + current.log.takeLast(3).forEach { + Text(it, style = MaterialTheme.typography.bodySmall) + } + } + + abandoned?.let { LeftCardDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) EndCardDialog(onExit) { confirmingEnd = false } + } +} + +@Composable +private fun DiceRow( + dice: List, + kept: List, + enabled: Boolean, + onToggle: (Int) -> Unit, +) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (dice.isEmpty()) { + repeat(DICE_COUNT) { Die(face = null, held = false, enabled = false) {} } + } else { + dice.forEachIndexed { index, face -> + Die( + face = face, + held = kept.contains(index), + enabled = enabled, + onClick = { onToggle(index) }, + ) + } + } + } +} + +@Composable +private fun Die(face: Int?, held: Boolean, enabled: Boolean, onClick: () -> Unit) { + Box( + modifier = Modifier + .size(48.dp) + .background(if (face == null) Parchment.copy(alpha = 0.3f) else Parchment, RoundedCornerShape(8.dp)) + .border( + width = if (held) 3.dp else 1.dp, + color = if (held) WallaceGold else WallaceRed.copy(alpha = 0.4f), + shape = RoundedCornerShape(8.dp), + ) + .clickable(enabled = enabled) { onClick() }, + contentAlignment = Alignment.Center, + ) { + Text( + text = face?.toString() ?: "·", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = WallaceRed, + ) + } +} + +@Composable +private fun ScoreCard( + state: YahtzeeState, + localSeat: Int, + writable: Set, + onWrite: (YahtzeeCategory) -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth()) { + Row(modifier = Modifier.fillMaxWidth()) { + Text("", modifier = Modifier.weight(2f), style = MaterialTheme.typography.labelSmall) + state.cards.indices.forEach { seat -> + Text( + text = if (seat == localSeat) "You" else "S$seat", + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + ) + } + } + YahtzeeSection.entries.forEach { section -> + YahtzeeCategory.entries.filter { it.section == section }.forEach { category -> + CardRow(state, localSeat, category, writable, onWrite) + } + if (section == YahtzeeSection.UPPER) { + TotalRow("Bonus at 63", state.cards.map { it.upperBonus }) + } + } + Divider() + TotalRow("Total", state.cards.indices.map { state.totalFor(it) }, bold = true) + } +} + +@Composable +private fun CardRow( + state: YahtzeeState, + localSeat: Int, + category: YahtzeeCategory, + writable: Set, + onWrite: (YahtzeeCategory) -> Unit, +) { + val open = writable.contains(category) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = open) { onWrite(category) } + .padding(vertical = 2.dp), + ) { + Text( + text = category.label, + modifier = Modifier.weight(2f), + style = MaterialTheme.typography.bodySmall, + fontWeight = if (open) FontWeight.Bold else FontWeight.Normal, + ) + state.cards.forEachIndexed { seat, card -> + val written = card[category] + Text( + text = when { + written != null -> "$written" + // What this throw would be worth here, which is the only + // number the decision actually turns on. + seat == localSeat && open -> "(${scoreOf(category, state.dice)})" + else -> "—" + }, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + color = if (written == null && open) WallaceRed else MaterialTheme.colorScheme.onSurface, + ) + } + } +} + +@Composable +private fun TotalRow(label: String, values: List, bold: Boolean = false) { + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + Text( + text = label, + modifier = Modifier.weight(2f), + style = MaterialTheme.typography.bodySmall, + fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal, + ) + values.forEach { + Text( + text = "$it", + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal, + ) + } + } +} + +@Composable +private fun EndCardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) { + AlertDialog( + onDismissRequest = onKeepPlaying, + title = { Text("End this game?") }, + text = { Text("The game ends here. Anyone else at the table is told you have left.") }, + confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } }, + dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } }, + ) +} + +@Composable +private fun LeftCardDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) { + AlertDialog( + onDismissRequest = onStay, + title = { Text("The game has ended") }, + text = { Text(notice) }, + confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } }, + dismissButton = { TextButton(onClick = onStay) { Text("Look at the card") } }, + ) +} diff --git a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt index 9c44e39..45ed393 100644 --- a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt @@ -73,6 +73,8 @@ class CatalogTest { GameCatalog.CRIBBAGE, GameCatalog.HEARTS, GameCatalog.PEG_SOLITAIRE, + GameCatalog.YAHTZEE, + GameCatalog.KLONDIKE, GameCatalog.WIZARD, GameCatalog.CHESS, GameCatalog.TAYU,