Screens for Hearts and peg solitaire, and a seat for one
Both engines had been sitting behind ids with no screen. This gives them one each and puts them in the catalogue. Peg solitaire is the first game here played alone, and almost nothing had to move for it. seatCountFor already fell through to minPlayers, so one seat came free; CatalogTest's floor of two players is the only invariant that had to go, and it went to one. The setup screen grows a solo path instead: a one-seat game has nobody to play against and no seat anyone could join, so it offers "Play" and not "Host a game for others to join", and the menu says "On your own" rather than "1 players". Playing again cannot go through advanceIdle. driveIdleSeats returns the moment rules.isFinished is true, before it ever reaches the confirmation branch, and a peg board with no jumps left genuinely is finished -- unlike a Wizard round, which is only between deals. Rather than bend isFinished into meaning something else, the screen rebuilds the controller behind a generation counter. The board is deterministic, so the retry is the same puzzle rather than a new one. One board layout serves both geometries. Each hole is placed in hole-widths rather than pixels and a triangle offsets its rows by half a hole, which is the same staggering that gives a triangular hole six neighbours instead of four. The Hearts screen reads the phase because two different things happen at it: choosing three cards to give away, which is a multiple selection nothing else in the app does, and then playing one card at a time like every other trick screen here. Passing is capped at three by the picker rather than by the error the rules would otherwise raise. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC
This commit is contained in:
@@ -41,12 +41,6 @@ object GameCatalog {
|
|||||||
* [all] when its screen lands.
|
* [all] when its screen lands.
|
||||||
*/
|
*/
|
||||||
const val HEARTS = "hearts"
|
const val HEARTS = "hearts"
|
||||||
|
|
||||||
/**
|
|
||||||
* Peg solitaire, likewise an id ahead of its screen. It is also the first
|
|
||||||
* game here played by one person, so it arrives with the seat-count change
|
|
||||||
* that allows it rather than before it.
|
|
||||||
*/
|
|
||||||
const val PEG_SOLITAIRE = "pegsolitaire"
|
const val PEG_SOLITAIRE = "pegsolitaire"
|
||||||
const val SEQUENCE = "sequence"
|
const val SEQUENCE = "sequence"
|
||||||
const val MASTERMIND = "mastermind"
|
const val MASTERMIND = "mastermind"
|
||||||
@@ -130,6 +124,17 @@ object GameCatalog {
|
|||||||
"twice round the board to 121.",
|
"twice round the board to 121.",
|
||||||
available = true,
|
available = true,
|
||||||
),
|
),
|
||||||
|
GameDescriptor(
|
||||||
|
id = HEARTS,
|
||||||
|
title = "Hearts",
|
||||||
|
category = GameCategory.CARD,
|
||||||
|
minPlayers = 4,
|
||||||
|
maxPlayers = 4,
|
||||||
|
teamBased = false,
|
||||||
|
blurb = "Take no trick worth taking. Every heart costs a point, the queen " +
|
||||||
|
"of spades costs thirteen, and taking all of them costs everybody else.",
|
||||||
|
available = true,
|
||||||
|
),
|
||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = SEQUENCE,
|
id = SEQUENCE,
|
||||||
title = "Sequence",
|
title = "Sequence",
|
||||||
@@ -191,6 +196,19 @@ object GameCatalog {
|
|||||||
blurb = "Three in a row takes a piece. Reduce them to two and the game is yours.",
|
blurb = "Three in a row takes a piece. Reduce them to two and the game is yours.",
|
||||||
available = true,
|
available = true,
|
||||||
),
|
),
|
||||||
|
GameDescriptor(
|
||||||
|
id = PEG_SOLITAIRE,
|
||||||
|
title = "Peg Solitaire",
|
||||||
|
category = GameCategory.BOARD,
|
||||||
|
// The first game here played alone. Everything downstream reads the
|
||||||
|
// seat count from these two numbers, so one seat is all it takes.
|
||||||
|
minPlayers = 1,
|
||||||
|
maxPlayers = 1,
|
||||||
|
teamBased = false,
|
||||||
|
blurb = "The tee game from the table, and four larger boards. Jump a peg, " +
|
||||||
|
"take the one you passed, and try to finish with one standing.",
|
||||||
|
available = true,
|
||||||
|
),
|
||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = PIRATES,
|
id = PIRATES,
|
||||||
title = "Pirates and Bulgars",
|
title = "Pirates and Bulgars",
|
||||||
|
|||||||
@@ -297,14 +297,12 @@ private fun GameTile(game: GameDescriptor, onClick: () -> Unit) {
|
|||||||
modifier = Modifier.padding(top = 4.dp),
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = if (game.available) {
|
text = when {
|
||||||
if (game.minPlayers == game.maxPlayers) {
|
!game.available -> "Coming soon"
|
||||||
"${game.minPlayers} players"
|
// A puzzle for one, rather than "1 players".
|
||||||
} else {
|
game.maxPlayers == 1 -> "On your own"
|
||||||
"${game.minPlayers}–${game.maxPlayers} players"
|
game.minPlayers == game.maxPlayers -> "${game.minPlayers} players"
|
||||||
}
|
else -> "${game.minPlayers}–${game.maxPlayers} players"
|
||||||
} else {
|
|
||||||
"Coming soon"
|
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
modifier = Modifier.padding(top = 8.dp),
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ import org.prolibertate.games.game.crazyeights.CrazyEightsOptions
|
|||||||
import org.prolibertate.games.game.cribbage.CribbageOptions
|
import org.prolibertate.games.game.cribbage.CribbageOptions
|
||||||
import org.prolibertate.games.game.euchre.EuchreOptions
|
import org.prolibertate.games.game.euchre.EuchreOptions
|
||||||
import org.prolibertate.games.game.golf.GolfOptions
|
import org.prolibertate.games.game.golf.GolfOptions
|
||||||
|
import org.prolibertate.games.game.hearts.HeartsOptions
|
||||||
|
import org.prolibertate.games.game.pegsolitaire.PegBoard
|
||||||
|
import org.prolibertate.games.game.pegsolitaire.PegGoal
|
||||||
|
import org.prolibertate.games.game.pegsolitaire.PegSolitaireOptions
|
||||||
import org.prolibertate.games.game.kaiser.KaiserOptions
|
import org.prolibertate.games.game.kaiser.KaiserOptions
|
||||||
import org.prolibertate.games.game.mastermind.MastermindLevel
|
import org.prolibertate.games.game.mastermind.MastermindLevel
|
||||||
import org.prolibertate.games.game.mastermind.MastermindOptions
|
import org.prolibertate.games.game.mastermind.MastermindOptions
|
||||||
@@ -88,6 +92,8 @@ fun GameSetupScreen(
|
|||||||
var backgammon by remember { mutableStateOf(BackgammonOptions()) }
|
var backgammon by remember { mutableStateOf(BackgammonOptions()) }
|
||||||
var mastermind by remember { mutableStateOf(MastermindOptions()) }
|
var mastermind by remember { mutableStateOf(MastermindOptions()) }
|
||||||
var pirates by remember { mutableStateOf(PiratesOptions()) }
|
var pirates by remember { mutableStateOf(PiratesOptions()) }
|
||||||
|
var hearts by remember { mutableStateOf(HeartsOptions()) }
|
||||||
|
var pegs by remember { mutableStateOf(PegSolitaireOptions()) }
|
||||||
// Not an engine option: which seat the local player takes. Seat 0 is
|
// Not an engine option: which seat the local player takes. Seat 0 is
|
||||||
// always White, so choosing Black means sitting in seat 1.
|
// always White, so choosing Black means sitting in seat 1.
|
||||||
var playWhite by remember { mutableStateOf(true) }
|
var playWhite by remember { mutableStateOf(true) }
|
||||||
@@ -111,6 +117,8 @@ fun GameSetupScreen(
|
|||||||
GameCatalog.BACKGAMMON -> setupJson.encodeToString(backgammon)
|
GameCatalog.BACKGAMMON -> setupJson.encodeToString(backgammon)
|
||||||
GameCatalog.MASTERMIND -> setupJson.encodeToString(mastermind)
|
GameCatalog.MASTERMIND -> setupJson.encodeToString(mastermind)
|
||||||
GameCatalog.PIRATES -> setupJson.encodeToString(pirates)
|
GameCatalog.PIRATES -> setupJson.encodeToString(pirates)
|
||||||
|
GameCatalog.HEARTS -> setupJson.encodeToString(hearts)
|
||||||
|
GameCatalog.PEG_SOLITAIRE -> setupJson.encodeToString(pegs)
|
||||||
else -> "{}"
|
else -> "{}"
|
||||||
}
|
}
|
||||||
val seatCount = seatCountFor(descriptor.id, optionsJson)
|
val seatCount = seatCountFor(descriptor.id, optionsJson)
|
||||||
@@ -162,15 +170,27 @@ fun GameSetupScreen(
|
|||||||
onSide = { playPirates = it },
|
onSide = { playPirates = it },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
GameCatalog.HEARTS -> HeartsOptionsEditor(hearts) { hearts = it }
|
||||||
|
|
||||||
|
GameCatalog.PEG_SOLITAIRE -> PegSolitaireOptionsEditor(pegs) { pegs = it }
|
||||||
|
|
||||||
else -> Text("No options yet for this game.")
|
else -> Text("No options yet for this game.")
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
Text("Opponents", fontWeight = FontWeight.Bold)
|
// A one-seat game has nobody to play against and no seat anyone
|
||||||
|
// could join, so it is offered as itself rather than as a table
|
||||||
|
// with every other chair filled by the computer.
|
||||||
|
val solo = seatCount == 1
|
||||||
|
Text(if (solo) "On your own" else "Opponents", fontWeight = FontWeight.Bold)
|
||||||
Text(
|
Text(
|
||||||
text = "Play now and every other seat is taken by the computer. Host a game " +
|
text = if (solo) {
|
||||||
"instead and people can claim those seats as they join — whatever is " +
|
"A puzzle for one. There is nobody to wait for and nothing to host."
|
||||||
"still empty when you start stays computer-controlled.",
|
} else {
|
||||||
|
"Play now and every other seat is taken by the computer. Host a game " +
|
||||||
|
"instead and people can claim those seats as they join — whatever is " +
|
||||||
|
"still empty when you start stays computer-controlled."
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -182,14 +202,16 @@ fun GameSetupScreen(
|
|||||||
descriptor.id == GameCatalog.PIRATES && playPirates -> 1
|
descriptor.id == GameCatalog.PIRATES && playPirates -> 1
|
||||||
else -> 0
|
else -> 0
|
||||||
}
|
}
|
||||||
PrimaryAction(text = "Play against the computer") {
|
PrimaryAction(text = if (solo) "Play" else "Play against the computer") {
|
||||||
onPlayOffline(
|
onPlayOffline(
|
||||||
offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat),
|
offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat),
|
||||||
localSeat,
|
localSeat,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
PrimaryAction(text = "Host a game for others to join") {
|
if (!solo) {
|
||||||
onHostOnline(optionsJson)
|
PrimaryAction(text = "Host a game for others to join") {
|
||||||
|
onHostOnline(optionsJson)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -944,3 +966,49 @@ private fun ToggleRow(
|
|||||||
Switch(checked = checked, onCheckedChange = onChange)
|
Switch(checked = checked, onCheckedChange = onChange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HeartsOptionsEditor(options: HeartsOptions, onChange: (HeartsOptions) -> Unit) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
ChipRow(
|
||||||
|
label = "Game ends at",
|
||||||
|
values = listOf(50, 100),
|
||||||
|
selected = options.targetScore,
|
||||||
|
display = { "$it points" },
|
||||||
|
onSelect = { onChange(options.copy(targetScore = it)) },
|
||||||
|
)
|
||||||
|
ToggleRow(
|
||||||
|
title = "Shooting the moon",
|
||||||
|
subtitle = "Take all twenty-six and score nothing, while everyone else takes " +
|
||||||
|
"the lot. Switched off, a moon is simply the worst hand at the table.",
|
||||||
|
checked = options.allowShootTheMoon,
|
||||||
|
onChange = { onChange(options.copy(allowShootTheMoon = it)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PegSolitaireOptionsEditor(
|
||||||
|
options: PegSolitaireOptions,
|
||||||
|
onChange: (PegSolitaireOptions) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
ChipRow(
|
||||||
|
label = "Board",
|
||||||
|
values = PegBoard.entries.toList(),
|
||||||
|
selected = options.board,
|
||||||
|
display = { it.label },
|
||||||
|
// The opening hole is cleared as well: a hole that exists on the old
|
||||||
|
// board may be a missing corner on the new one, and the options
|
||||||
|
// refuse to be built that way.
|
||||||
|
onSelect = { onChange(options.copy(board = it, startEmpty = null)) },
|
||||||
|
)
|
||||||
|
ChipRow(
|
||||||
|
label = "Finish",
|
||||||
|
values = PegGoal.entries.toList(),
|
||||||
|
selected = options.goal,
|
||||||
|
display = { it.label },
|
||||||
|
onSelect = { onChange(options.copy(goal = it)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import org.prolibertate.games.game.GameCatalog
|
import org.prolibertate.games.game.GameCatalog
|
||||||
import org.prolibertate.games.game.chess.ChessAi
|
import org.prolibertate.games.game.chess.ChessAi
|
||||||
@@ -26,6 +29,15 @@ import org.prolibertate.games.game.euchre.EuchreMove
|
|||||||
import org.prolibertate.games.game.euchre.EuchrePhase
|
import org.prolibertate.games.game.euchre.EuchrePhase
|
||||||
import org.prolibertate.games.game.euchre.EuchreRules
|
import org.prolibertate.games.game.euchre.EuchreRules
|
||||||
import org.prolibertate.games.game.euchre.EuchreState
|
import org.prolibertate.games.game.euchre.EuchreState
|
||||||
|
import org.prolibertate.games.game.hearts.HeartsAi
|
||||||
|
import org.prolibertate.games.game.hearts.HeartsMove
|
||||||
|
import org.prolibertate.games.game.hearts.HeartsPhase
|
||||||
|
import org.prolibertate.games.game.hearts.HeartsRules
|
||||||
|
import org.prolibertate.games.game.hearts.HeartsState
|
||||||
|
import org.prolibertate.games.game.pegsolitaire.PegSolitaireAi
|
||||||
|
import org.prolibertate.games.game.pegsolitaire.PegSolitaireMove
|
||||||
|
import org.prolibertate.games.game.pegsolitaire.PegSolitaireRules
|
||||||
|
import org.prolibertate.games.game.pegsolitaire.PegSolitaireState
|
||||||
import org.prolibertate.games.game.golf.GolfAi
|
import org.prolibertate.games.game.golf.GolfAi
|
||||||
import org.prolibertate.games.game.golf.GolfMove
|
import org.prolibertate.games.game.golf.GolfMove
|
||||||
import org.prolibertate.games.game.golf.GolfPhase
|
import org.prolibertate.games.game.golf.GolfPhase
|
||||||
@@ -430,6 +442,67 @@ fun PlayScreen(
|
|||||||
PiratesScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
PiratesScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GameCatalog.HEARTS -> {
|
||||||
|
val controller = remember(route) {
|
||||||
|
MatchController<HeartsState, HeartsMove>(
|
||||||
|
rules = HeartsRules,
|
||||||
|
ai = HeartsAi,
|
||||||
|
config = route.config,
|
||||||
|
scope = scope,
|
||||||
|
role = role,
|
||||||
|
localSeats = setOf(route.localSeat),
|
||||||
|
primarySeat = route.localSeat,
|
||||||
|
advanceIdle = { state ->
|
||||||
|
if (state.phase == HeartsPhase.ROUND_OVER) {
|
||||||
|
HeartsRules.startNextRound(state)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// What a round cost is read before the next deal.
|
||||||
|
awaitsConfirmation = { state -> state.phase == HeartsPhase.ROUND_OVER },
|
||||||
|
aiThinkingMillis = { settings.scaled(700L) },
|
||||||
|
holdBeforeNextMove = { state ->
|
||||||
|
if (state.completedTrick.isNotEmpty()) {
|
||||||
|
settings.scaled(TRICK_HOLD_MILLIS)
|
||||||
|
} else {
|
||||||
|
0L
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
StartMatch(env, controller, route)
|
||||||
|
HeartsScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||||
|
}
|
||||||
|
|
||||||
|
GameCatalog.PEG_SOLITAIRE -> {
|
||||||
|
// Playing again means a fresh board rather than a fresh round, and
|
||||||
|
// it cannot go through advanceIdle: driveIdleSeats returns as soon
|
||||||
|
// as rules.isFinished is true, and a peg board with no jumps left
|
||||||
|
// genuinely is finished. Rebuilding the controller is the honest
|
||||||
|
// way to deal again, and the board is deterministic, so the retry
|
||||||
|
// is the same puzzle.
|
||||||
|
var generation by remember(route) { mutableIntStateOf(0) }
|
||||||
|
val controller = remember(route, generation) {
|
||||||
|
MatchController<PegSolitaireState, PegSolitaireMove>(
|
||||||
|
rules = PegSolitaireRules,
|
||||||
|
// Never consulted while a person holds the only seat.
|
||||||
|
ai = PegSolitaireAi,
|
||||||
|
config = route.config,
|
||||||
|
scope = scope,
|
||||||
|
role = role,
|
||||||
|
localSeats = setOf(route.localSeat),
|
||||||
|
primarySeat = route.localSeat,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
StartMatch(env, controller, route)
|
||||||
|
PegSolitaireScreen(
|
||||||
|
controller = controller,
|
||||||
|
onRestart = { generation++ },
|
||||||
|
onExit = onExit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
GameCatalog.MORRIS -> {
|
GameCatalog.MORRIS -> {
|
||||||
val controller = remember(route) {
|
val controller = remember(route) {
|
||||||
MatchController<MorrisState, MorrisMove>(
|
MatchController<MorrisState, MorrisMove>(
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
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.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
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.cards.Card
|
||||||
|
import org.prolibertate.games.game.hearts.HeartsMove
|
||||||
|
import org.prolibertate.games.game.hearts.HeartsPhase
|
||||||
|
import org.prolibertate.games.game.hearts.HeartsState
|
||||||
|
import org.prolibertate.games.game.hearts.PASS_SIZE
|
||||||
|
import org.prolibertate.games.game.hearts.PassCards
|
||||||
|
import org.prolibertate.games.game.hearts.PlayCard
|
||||||
|
import org.prolibertate.games.game.hearts.pointsOf
|
||||||
|
import org.prolibertate.games.net.MatchController
|
||||||
|
import org.prolibertate.games.ui.ScreenScaffold
|
||||||
|
import org.prolibertate.games.ui.common.PlayingCardView
|
||||||
|
|
||||||
|
private const val TITLE = "Hearts"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Hearts table.
|
||||||
|
*
|
||||||
|
* Two quite different things happen at this screen, so it reads the phase and
|
||||||
|
* shows one or the other: choosing three cards to give away, which is a
|
||||||
|
* multiple selection nothing else in the app does, and then playing one card at
|
||||||
|
* a time, which every trick-taking screen here does the same way.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun HeartsScreen(
|
||||||
|
controller: MatchController<HeartsState, HeartsMove>,
|
||||||
|
localSeat: Int,
|
||||||
|
onExit: () -> Unit,
|
||||||
|
) {
|
||||||
|
val state by controller.state.collectAsState()
|
||||||
|
val legal by controller.legalMoves.collectAsState()
|
||||||
|
val abandoned by controller.abandoned.collectAsState()
|
||||||
|
val awaiting by controller.awaitingConfirmation.collectAsState()
|
||||||
|
|
||||||
|
val chosen = remember { mutableStateListOf<Card>() }
|
||||||
|
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 { LeftHeartsDialog(it, onExit, controller::dismissAbandoned) }
|
||||||
|
if (confirmingEnd) EndHeartsDialog(onExit) { confirmingEnd = false }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val hand = current.hands.getOrElse(localSeat) { emptyList() }
|
||||||
|
val passing = current.phase == HeartsPhase.PASSING
|
||||||
|
val alreadyPassed = current.passSelections.getOrElse(localSeat) { emptyList() }.isNotEmpty()
|
||||||
|
val playable = legal.filterIsInstance<PlayCard>().map { it.card }.toSet()
|
||||||
|
|
||||||
|
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||||
|
Column(
|
||||||
|
modifier = modifier.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = when {
|
||||||
|
current.phase == HeartsPhase.GAME_OVER -> "Game over — lowest score wins."
|
||||||
|
current.phase == HeartsPhase.ROUND_OVER -> "Round scored."
|
||||||
|
passing && alreadyPassed -> "Waiting for the others to choose…"
|
||||||
|
passing -> "Choose three to pass ${current.passDirection.label}"
|
||||||
|
playable.isEmpty() -> "Waiting…"
|
||||||
|
else -> "Your turn"
|
||||||
|
},
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Every heart costs a point and the queen of spades costs thirteen. " +
|
||||||
|
"Take none of them.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
|
||||||
|
TrickView(current)
|
||||||
|
ScoreRow(current, localSeat)
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = if (passing && !alreadyPassed) {
|
||||||
|
"Passing ${current.passDirection.label} — ${chosen.size} of $PASS_SIZE chosen"
|
||||||
|
} else {
|
||||||
|
"Your hand"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
hand.forEach { card ->
|
||||||
|
val selectable = passing && !alreadyPassed
|
||||||
|
PlayingCardView(
|
||||||
|
card = card,
|
||||||
|
width = 52.dp,
|
||||||
|
selected = chosen.contains(card),
|
||||||
|
enabled = if (selectable) true else playable.contains(card),
|
||||||
|
caption = pointsOf(card).takeIf { it > 0 }?.let { "−$it" },
|
||||||
|
onClick = {
|
||||||
|
if (selectable) {
|
||||||
|
toggle(chosen, card)
|
||||||
|
} else if (playable.contains(card)) {
|
||||||
|
controller.submit(PlayCard(card))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passing && !alreadyPassed) {
|
||||||
|
Button(
|
||||||
|
enabled = chosen.size == PASS_SIZE,
|
||||||
|
onClick = {
|
||||||
|
controller.submit(PassCards(chosen.toList()))
|
||||||
|
chosen.clear()
|
||||||
|
},
|
||||||
|
) { Text("Pass these three") }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (awaiting) {
|
||||||
|
Button(onClick = controller::confirmAdvance) { Text("Next round") }
|
||||||
|
}
|
||||||
|
|
||||||
|
current.log.takeLast(3).forEach {
|
||||||
|
Text(it, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abandoned?.let { LeftHeartsDialog(it, onExit, controller::dismissAbandoned) }
|
||||||
|
if (confirmingEnd) EndHeartsDialog(onExit) { confirmingEnd = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds or removes [card], never letting the selection run past three. */
|
||||||
|
private fun toggle(chosen: SnapshotStateList<Card>, card: Card) {
|
||||||
|
if (chosen.contains(card)) {
|
||||||
|
chosen.remove(card)
|
||||||
|
} else if (chosen.size < PASS_SIZE) {
|
||||||
|
chosen.add(card)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun TrickView(state: HeartsState) {
|
||||||
|
// The trick just swept is held on the table for a moment, so show that when
|
||||||
|
// there is nothing in front of the players yet.
|
||||||
|
val showing = state.trick.ifEmpty { state.completedTrick }
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(
|
||||||
|
text = if (state.trick.isEmpty() && showing.isNotEmpty()) {
|
||||||
|
"Last trick — taken by seat ${state.leader}"
|
||||||
|
} else {
|
||||||
|
"Trick ${state.trickNumber + 1} of ${state.tricksPerRound}"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
|
if (showing.isEmpty()) {
|
||||||
|
Text("Nothing played yet.", style = MaterialTheme.typography.bodySmall)
|
||||||
|
} else {
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
showing.forEach { played ->
|
||||||
|
PlayingCardView(
|
||||||
|
card = played.card,
|
||||||
|
width = 46.dp,
|
||||||
|
caption = "seat ${played.seat}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ScoreRow(state: HeartsState, localSeat: Int) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
state.scores.forEachIndexed { seat, score ->
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
text = if (seat == localSeat) "You" else "Seat $seat",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = if (seat == localSeat) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
)
|
||||||
|
Text("$score", fontWeight = FontWeight.Bold)
|
||||||
|
// What this round has cost so far, which is the number anybody
|
||||||
|
// actually watches while a hand is being played.
|
||||||
|
Text(
|
||||||
|
text = "+${state.roundScores.getOrElse(seat) { 0 }}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EndHeartsDialog(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 LeftHeartsDialog(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") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
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.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
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.pegsolitaire.Hole
|
||||||
|
import org.prolibertate.games.game.pegsolitaire.PegBoard
|
||||||
|
import org.prolibertate.games.game.pegsolitaire.PegJump
|
||||||
|
import org.prolibertate.games.game.pegsolitaire.PegSolitaireMove
|
||||||
|
import org.prolibertate.games.game.pegsolitaire.PegSolitaireState
|
||||||
|
import org.prolibertate.games.net.MatchController
|
||||||
|
import org.prolibertate.games.ui.ScreenScaffold
|
||||||
|
import org.prolibertate.games.ui.theme.FeltGreenDark
|
||||||
|
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 = "Peg Solitaire"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The peg board.
|
||||||
|
*
|
||||||
|
* One layout serves both board shapes. Each hole is placed at a position in
|
||||||
|
* hole-widths rather than pixels, and a triangle simply offsets its rows by half
|
||||||
|
* a hole so they stagger — which is the same thing that gives a triangular hole
|
||||||
|
* six neighbours instead of four. Scaling that grid to whatever width the phone
|
||||||
|
* has is then the only sizing decision left.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PegSolitaireScreen(
|
||||||
|
controller: MatchController<PegSolitaireState, PegSolitaireMove>,
|
||||||
|
onRestart: () -> Unit,
|
||||||
|
onExit: () -> Unit,
|
||||||
|
) {
|
||||||
|
val state by controller.state.collectAsState()
|
||||||
|
val legal by controller.legalMoves.collectAsState()
|
||||||
|
val abandoned by controller.abandoned.collectAsState()
|
||||||
|
|
||||||
|
var selected by remember { mutableStateOf<Hole?>(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("Setting up…") }
|
||||||
|
abandoned?.let { LeftBoardDialog(it, onExit, controller::dismissAbandoned) }
|
||||||
|
if (confirmingEnd) EndBoardDialog(onExit) { confirmingEnd = false }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val jumps = legal.filterIsInstance<PegJump>()
|
||||||
|
val movable = jumps.map { it.from }.toSet()
|
||||||
|
val landings = selected?.let { from -> jumps.filter { it.from == from } }.orEmpty()
|
||||||
|
val destinations = landings.map { it.to }.toSet()
|
||||||
|
val finished = jumps.isEmpty()
|
||||||
|
|
||||||
|
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||||
|
Column(
|
||||||
|
modifier = modifier.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = when {
|
||||||
|
current.solved -> "Solved, in ${current.jumps} jumps."
|
||||||
|
finished -> "Stuck with ${current.remaining} pegs."
|
||||||
|
selected != null -> "Tap a ringed hole to jump into it."
|
||||||
|
else -> "Tap a peg that can jump."
|
||||||
|
},
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${current.board.label}. ${current.remaining} pegs left, " +
|
||||||
|
"${current.jumps} jumps made.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
|
||||||
|
Board(
|
||||||
|
state = current,
|
||||||
|
selected = selected,
|
||||||
|
movable = movable,
|
||||||
|
destinations = destinations,
|
||||||
|
onTap = { hole ->
|
||||||
|
selected = when {
|
||||||
|
// Completing a jump takes priority: the tapped hole is
|
||||||
|
// one the selected peg was offered.
|
||||||
|
selected != null && destinations.contains(hole) -> {
|
||||||
|
controller.submit(PegJump(from = selected!!, to = hole))
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
movable.contains(hole) -> hole
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (finished) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(onClick = onRestart) { Text("Play again") }
|
||||||
|
TextButton(onClick = onExit) { Text("Leave") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
current.log.takeLast(3).forEach {
|
||||||
|
Text(it, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abandoned?.let { LeftBoardDialog(it, onExit, controller::dismissAbandoned) }
|
||||||
|
if (confirmingEnd) EndBoardDialog(onExit) { confirmingEnd = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Board(
|
||||||
|
state: PegSolitaireState,
|
||||||
|
selected: Hole?,
|
||||||
|
movable: Set<Hole>,
|
||||||
|
destinations: Set<Hole>,
|
||||||
|
onTap: (Hole) -> Unit,
|
||||||
|
) {
|
||||||
|
val board = state.board
|
||||||
|
val holes = remember(board) { board.holes().toList() }
|
||||||
|
val places = remember(board) { holes.associateWith { placeOf(board, it) } }
|
||||||
|
val xs = places.values.map { it.first }
|
||||||
|
val ys = places.values.map { it.second }
|
||||||
|
val minX = xs.min()
|
||||||
|
val minY = ys.min()
|
||||||
|
val spanX = xs.max() - minX
|
||||||
|
val spanY = ys.max() - minY
|
||||||
|
|
||||||
|
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
// One extra hole-width so the last column has somewhere to sit.
|
||||||
|
val cell: Dp = maxWidth / (spanX + 1f)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(cell * (spanY + 1f))
|
||||||
|
.background(FeltGreenDark),
|
||||||
|
) {
|
||||||
|
holes.forEach { hole ->
|
||||||
|
val (x, y) = places.getValue(hole)
|
||||||
|
HoleView(
|
||||||
|
filled = state.pegs.contains(hole),
|
||||||
|
selected = hole == selected,
|
||||||
|
canMove = movable.contains(hole),
|
||||||
|
isDestination = destinations.contains(hole),
|
||||||
|
size = cell,
|
||||||
|
modifier = Modifier.offset(x = cell * (x - minX), y = cell * (y - minY)),
|
||||||
|
onTap = { onTap(hole) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a hole sits, in hole-widths from the board's top left.
|
||||||
|
*
|
||||||
|
* The half-hole shift on a triangle is the whole difference between the two
|
||||||
|
* geometries as far as drawing is concerned.
|
||||||
|
*/
|
||||||
|
private fun placeOf(board: PegBoard, hole: Hole): Pair<Float, Float> =
|
||||||
|
if (board.isTriangular) {
|
||||||
|
(hole.col - hole.row / 2f) to hole.row.toFloat()
|
||||||
|
} else {
|
||||||
|
hole.col.toFloat() to hole.row.toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HoleView(
|
||||||
|
filled: Boolean,
|
||||||
|
selected: Boolean,
|
||||||
|
canMove: Boolean,
|
||||||
|
isDestination: Boolean,
|
||||||
|
size: Dp,
|
||||||
|
modifier: Modifier,
|
||||||
|
onTap: () -> Unit,
|
||||||
|
) {
|
||||||
|
Box(modifier = modifier.size(size), contentAlignment = Alignment.Center) {
|
||||||
|
val diameter = size * 0.72f
|
||||||
|
val ring = when {
|
||||||
|
selected -> WallaceGold
|
||||||
|
isDestination -> WallaceGold
|
||||||
|
else -> Parchment.copy(alpha = 0.35f)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(diameter)
|
||||||
|
.background(
|
||||||
|
color = if (filled) WallaceRed else FeltGreenDark,
|
||||||
|
shape = CircleShape,
|
||||||
|
)
|
||||||
|
.border(
|
||||||
|
// A destination is drawn as an empty ring rather than filled,
|
||||||
|
// so it never reads as a peg already sitting there.
|
||||||
|
border = BorderStroke(if (selected || isDestination) 3.dp else 1.dp, ring),
|
||||||
|
shape = CircleShape,
|
||||||
|
)
|
||||||
|
.clickable(enabled = filled && canMove || isDestination) { onTap() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EndBoardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onKeepPlaying,
|
||||||
|
title = { Text("End this game?") },
|
||||||
|
text = { Text("The board is left as it stands.") },
|
||||||
|
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||||
|
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LeftBoardDialog(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 board") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -71,6 +71,8 @@ class CatalogTest {
|
|||||||
GameCatalog.KAISER,
|
GameCatalog.KAISER,
|
||||||
GameCatalog.CRAZY_EIGHTS,
|
GameCatalog.CRAZY_EIGHTS,
|
||||||
GameCatalog.CRIBBAGE,
|
GameCatalog.CRIBBAGE,
|
||||||
|
GameCatalog.HEARTS,
|
||||||
|
GameCatalog.PEG_SOLITAIRE,
|
||||||
GameCatalog.WIZARD,
|
GameCatalog.WIZARD,
|
||||||
GameCatalog.CHESS,
|
GameCatalog.CHESS,
|
||||||
GameCatalog.TAYU,
|
GameCatalog.TAYU,
|
||||||
@@ -87,7 +89,11 @@ class CatalogTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `player counts are sane`() {
|
fun `player counts are sane`() {
|
||||||
GameCatalog.all.forEach { game ->
|
GameCatalog.all.forEach { game ->
|
||||||
assertTrue("${game.id} min players", game.minPlayers >= 2)
|
// One, not two. Peg solitaire is played alone, and the engine took
|
||||||
|
// that without changes — one seat, always on the clock, nothing to
|
||||||
|
// redact. Everything downstream reads the seat count from here, so
|
||||||
|
// this is the only place that had to allow it.
|
||||||
|
assertTrue("${game.id} min players", game.minPlayers >= 1)
|
||||||
assertTrue("${game.id} max >= min", game.maxPlayers >= game.minPlayers)
|
assertTrue("${game.id} max >= min", game.maxPlayers >= game.minPlayers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user