A menu tree, and games that can be in more than one branch of it
Three sections on the main screen -- Solitaire, Card Games, Board Games -- and Trick-taking sitting under Card Games rather than beside it, because it is a kind of card game and not a rival to one. GameDescriptor carries a set of menus rather than one category. A patience is solitaire and a card game; Euchre is a card game and a trick-taking one; peg solitaire is solitaire and a board game. Each is listed in every place it belongs, which is the point: a game reachable one way and not the other is a game somebody cannot find. GameMenu reads its parent from a `when` rather than taking it as a constructor argument. An enum entry cannot refer to another entry while the class is still initialising, and the lookup afterwards costs nothing. Counting a section counts a set rather than adding lists together, since a game in both Card Games and Trick-taking is one game and not two. hasAnything asks about a menu and its children together, so a section is only hidden when there is nothing under it anywhere -- the same rule that used to keep a heading from being stranded over an empty category. The dev-build "coming soon" behaviour is unchanged and still tested. What could not survive the change is the old test that counted games across categories and expected the total to equal the catalogue: with a game in several menus that sum is meaningly larger. It is now a union, which is what it was really checking -- that no game is stranded in no menu at all. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC
This commit is contained in:
@@ -1,22 +1,39 @@
|
|||||||
package org.prolibertate.games.game
|
package org.prolibertate.games.game
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The catalogue the main menu is built from.
|
* The menus the main screen is built from.
|
||||||
*
|
*
|
||||||
* Every game the app intends to ship appears here, whether or not its rules
|
* A tree rather than a list, because "trick-taking" is a kind of card game
|
||||||
* engine exists yet. [GameDescriptor.available] is what the menu keys off to
|
* rather than a rival to it. [parent] is read from a `when` rather than passed
|
||||||
* decide between "play" and "coming soon", so adding a game is a matter of
|
* to the constructor: an enum entry cannot refer to another entry while the
|
||||||
* flipping that flag once its engine and screen land.
|
* class is still initialising, and a lookup afterwards is free.
|
||||||
*/
|
*/
|
||||||
enum class GameCategory(val label: String) {
|
enum class GameMenu(val label: String, val blurb: String) {
|
||||||
CARD("Playing Card Games"),
|
SOLITAIRE("Solitaire", "Played alone, against the deal or the board."),
|
||||||
BOARD("Board Games"),
|
CARDS("Card Games", "Anything played with a pack."),
|
||||||
|
TRICK_TAKING("Trick-taking", "Lead, follow, and count what you took."),
|
||||||
|
BOARD("Board Games", "Boards, pieces and dice."),
|
||||||
|
;
|
||||||
|
|
||||||
|
val parent: GameMenu? get() = when (this) {
|
||||||
|
TRICK_TAKING -> CARDS
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
val children: List<GameMenu> get() = entries.filter { it.parent == this }
|
||||||
|
|
||||||
|
val isTopLevel: Boolean get() = parent == null
|
||||||
}
|
}
|
||||||
|
|
||||||
data class GameDescriptor(
|
data class GameDescriptor(
|
||||||
val id: String,
|
val id: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val category: GameCategory,
|
/**
|
||||||
|
* Every menu this game appears under. A patience is both solitaire and a
|
||||||
|
* card game, and Euchre is both a card game and a trick-taking one — so
|
||||||
|
* this is a set, and the same game is listed in each place it belongs.
|
||||||
|
*/
|
||||||
|
val menus: Set<GameMenu>,
|
||||||
val minPlayers: Int,
|
val minPlayers: Int,
|
||||||
val maxPlayers: Int,
|
val maxPlayers: Int,
|
||||||
/** True when players are grouped into partnerships rather than playing solo. */
|
/** True when players are grouped into partnerships rather than playing solo. */
|
||||||
@@ -25,6 +42,14 @@ data class GameDescriptor(
|
|||||||
val available: Boolean,
|
val available: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The catalogue the menus are built from.
|
||||||
|
*
|
||||||
|
* Every game the app intends to ship appears here, whether or not its rules
|
||||||
|
* engine exists yet. [GameDescriptor.available] is what the menu keys off to
|
||||||
|
* decide between "play" and "coming soon", so adding a game is a matter of
|
||||||
|
* flipping that flag once its engine and screen land.
|
||||||
|
*/
|
||||||
object GameCatalog {
|
object GameCatalog {
|
||||||
|
|
||||||
const val EUCHRE = "euchre"
|
const val EUCHRE = "euchre"
|
||||||
@@ -56,7 +81,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = EUCHRE,
|
id = EUCHRE,
|
||||||
title = "Euchre",
|
title = "Euchre",
|
||||||
category = GameCategory.CARD,
|
menus = setOf(GameMenu.CARDS, GameMenu.TRICK_TAKING),
|
||||||
minPlayers = 4,
|
minPlayers = 4,
|
||||||
maxPlayers = 4,
|
maxPlayers = 4,
|
||||||
teamBased = true,
|
teamBased = true,
|
||||||
@@ -66,7 +91,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = KAISER,
|
id = KAISER,
|
||||||
title = "Kaiser",
|
title = "Kaiser",
|
||||||
category = GameCategory.CARD,
|
menus = setOf(GameMenu.CARDS, GameMenu.TRICK_TAKING),
|
||||||
minPlayers = 4,
|
minPlayers = 4,
|
||||||
maxPlayers = 4,
|
maxPlayers = 4,
|
||||||
teamBased = true,
|
teamBased = true,
|
||||||
@@ -76,7 +101,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = PRESIDENT,
|
id = PRESIDENT,
|
||||||
title = "President",
|
title = "President",
|
||||||
category = GameCategory.CARD,
|
menus = setOf(GameMenu.CARDS),
|
||||||
minPlayers = 3,
|
minPlayers = 3,
|
||||||
maxPlayers = 7,
|
maxPlayers = 7,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -86,7 +111,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = GOLF,
|
id = GOLF,
|
||||||
title = "Golf",
|
title = "Golf",
|
||||||
category = GameCategory.CARD,
|
menus = setOf(GameMenu.CARDS),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 6,
|
maxPlayers = 6,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -96,7 +121,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = WIZARD,
|
id = WIZARD,
|
||||||
title = "Wizard",
|
title = "Wizard",
|
||||||
category = GameCategory.CARD,
|
menus = setOf(GameMenu.CARDS, GameMenu.TRICK_TAKING),
|
||||||
minPlayers = 3,
|
minPlayers = 3,
|
||||||
maxPlayers = 6,
|
maxPlayers = 6,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -106,7 +131,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = CRAZY_EIGHTS,
|
id = CRAZY_EIGHTS,
|
||||||
title = "Crazy 8s",
|
title = "Crazy 8s",
|
||||||
category = GameCategory.CARD,
|
menus = setOf(GameMenu.CARDS),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 6,
|
maxPlayers = 6,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -116,7 +141,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = CRIBBAGE,
|
id = CRIBBAGE,
|
||||||
title = "Cribbage",
|
title = "Cribbage",
|
||||||
category = GameCategory.CARD,
|
menus = setOf(GameMenu.CARDS),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 4,
|
maxPlayers = 4,
|
||||||
// Only at four, where the two pairs share a board between them.
|
// Only at four, where the two pairs share a board between them.
|
||||||
@@ -128,7 +153,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = HEARTS,
|
id = HEARTS,
|
||||||
title = "Hearts",
|
title = "Hearts",
|
||||||
category = GameCategory.CARD,
|
menus = setOf(GameMenu.CARDS, GameMenu.TRICK_TAKING),
|
||||||
minPlayers = 4,
|
minPlayers = 4,
|
||||||
maxPlayers = 4,
|
maxPlayers = 4,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -139,7 +164,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = KLONDIKE,
|
id = KLONDIKE,
|
||||||
title = "Klondike",
|
title = "Klondike",
|
||||||
category = GameCategory.CARD,
|
menus = setOf(GameMenu.SOLITAIRE, GameMenu.CARDS),
|
||||||
minPlayers = 1,
|
minPlayers = 1,
|
||||||
maxPlayers = 1,
|
maxPlayers = 1,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -150,7 +175,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = FREECELL,
|
id = FREECELL,
|
||||||
title = "FreeCell",
|
title = "FreeCell",
|
||||||
category = GameCategory.CARD,
|
menus = setOf(GameMenu.SOLITAIRE, GameMenu.CARDS),
|
||||||
minPlayers = 1,
|
minPlayers = 1,
|
||||||
maxPlayers = 1,
|
maxPlayers = 1,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -161,7 +186,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = SPIDER,
|
id = SPIDER,
|
||||||
title = "Spider",
|
title = "Spider",
|
||||||
category = GameCategory.CARD,
|
menus = setOf(GameMenu.SOLITAIRE, GameMenu.CARDS),
|
||||||
minPlayers = 1,
|
minPlayers = 1,
|
||||||
maxPlayers = 1,
|
maxPlayers = 1,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -172,7 +197,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = PYRAMID,
|
id = PYRAMID,
|
||||||
title = "Pyramid",
|
title = "Pyramid",
|
||||||
category = GameCategory.CARD,
|
menus = setOf(GameMenu.SOLITAIRE, GameMenu.CARDS),
|
||||||
minPlayers = 1,
|
minPlayers = 1,
|
||||||
maxPlayers = 1,
|
maxPlayers = 1,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -183,7 +208,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = SEQUENCE,
|
id = SEQUENCE,
|
||||||
title = "Sequence",
|
title = "Sequence",
|
||||||
category = GameCategory.BOARD,
|
menus = setOf(GameMenu.BOARD, GameMenu.CARDS),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 12,
|
maxPlayers = 12,
|
||||||
teamBased = true,
|
teamBased = true,
|
||||||
@@ -193,7 +218,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = MASTERMIND,
|
id = MASTERMIND,
|
||||||
title = "Mastermind",
|
title = "Mastermind",
|
||||||
category = GameCategory.BOARD,
|
menus = setOf(GameMenu.BOARD),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 2,
|
maxPlayers = 2,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -204,7 +229,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = BACKGAMMON,
|
id = BACKGAMMON,
|
||||||
title = "Backgammon",
|
title = "Backgammon",
|
||||||
category = GameCategory.BOARD,
|
menus = setOf(GameMenu.BOARD),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 2,
|
maxPlayers = 2,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -214,7 +239,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = CHESS,
|
id = CHESS,
|
||||||
title = "Chess",
|
title = "Chess",
|
||||||
category = GameCategory.BOARD,
|
menus = setOf(GameMenu.BOARD),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 2,
|
maxPlayers = 2,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -224,7 +249,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = CHECKERS,
|
id = CHECKERS,
|
||||||
title = "Checkers",
|
title = "Checkers",
|
||||||
category = GameCategory.BOARD,
|
menus = setOf(GameMenu.BOARD),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 2,
|
maxPlayers = 2,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -234,7 +259,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = MORRIS,
|
id = MORRIS,
|
||||||
title = "Nine Men's Morris",
|
title = "Nine Men's Morris",
|
||||||
category = GameCategory.BOARD,
|
menus = setOf(GameMenu.BOARD),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 2,
|
maxPlayers = 2,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -244,7 +269,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = PEG_SOLITAIRE,
|
id = PEG_SOLITAIRE,
|
||||||
title = "Peg Solitaire",
|
title = "Peg Solitaire",
|
||||||
category = GameCategory.BOARD,
|
menus = setOf(GameMenu.SOLITAIRE, GameMenu.BOARD),
|
||||||
// The first game here played alone. Everything downstream reads the
|
// The first game here played alone. Everything downstream reads the
|
||||||
// seat count from these two numbers, so one seat is all it takes.
|
// seat count from these two numbers, so one seat is all it takes.
|
||||||
minPlayers = 1,
|
minPlayers = 1,
|
||||||
@@ -257,7 +282,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = YAHTZEE,
|
id = YAHTZEE,
|
||||||
title = "Yahtzee",
|
title = "Yahtzee",
|
||||||
category = GameCategory.BOARD,
|
menus = setOf(GameMenu.BOARD),
|
||||||
minPlayers = 1,
|
minPlayers = 1,
|
||||||
maxPlayers = 6,
|
maxPlayers = 6,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -268,7 +293,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = PIRATES,
|
id = PIRATES,
|
||||||
title = "Pirates and Bulgars",
|
title = "Pirates and Bulgars",
|
||||||
category = GameCategory.BOARD,
|
menus = setOf(GameMenu.BOARD),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 2,
|
maxPlayers = 2,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
@@ -279,7 +304,7 @@ object GameCatalog {
|
|||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = TAYU,
|
id = TAYU,
|
||||||
title = "Ta Yü",
|
title = "Ta Yü",
|
||||||
category = GameCategory.BOARD,
|
menus = setOf(GameMenu.BOARD),
|
||||||
minPlayers = 2,
|
minPlayers = 2,
|
||||||
maxPlayers = 4,
|
maxPlayers = 4,
|
||||||
// Two play solo, four play in partnerships across the two axes.
|
// Two play solo, four play in partnerships across the two axes.
|
||||||
@@ -301,13 +326,24 @@ object GameCatalog {
|
|||||||
* production build lists only what can actually be played, so nobody
|
* production build lists only what can actually be played, so nobody
|
||||||
* downloads a release and taps into a dead end.
|
* downloads a release and taps into a dead end.
|
||||||
*/
|
*/
|
||||||
fun byCategory(
|
fun inMenu(
|
||||||
category: GameCategory,
|
menu: GameMenu,
|
||||||
includeComingSoon: Boolean = true,
|
includeComingSoon: Boolean = true,
|
||||||
): List<GameDescriptor> = all.filter {
|
): List<GameDescriptor> = all.filter {
|
||||||
it.category == category && (includeComingSoon || it.available)
|
it.menus.contains(menu) && (includeComingSoon || it.available)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Everything currently playable, regardless of category. */
|
/**
|
||||||
|
* Whether a menu is worth showing at all.
|
||||||
|
*
|
||||||
|
* A section with nothing in it and no populated child under it is a heading
|
||||||
|
* over an empty room, which is what a production build would get if every
|
||||||
|
* game in a menu were still unfinished.
|
||||||
|
*/
|
||||||
|
fun hasAnything(menu: GameMenu, includeComingSoon: Boolean = true): Boolean =
|
||||||
|
inMenu(menu, includeComingSoon).isNotEmpty() ||
|
||||||
|
menu.children.any { inMenu(it, includeComingSoon).isNotEmpty() }
|
||||||
|
|
||||||
|
/** Everything currently playable, whatever menu it is under. */
|
||||||
val playable: List<GameDescriptor> get() = all.filter { it.available }
|
val playable: List<GameDescriptor> get() = all.filter { it.available }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@@ -36,7 +37,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import org.prolibertate.games.R
|
import org.prolibertate.games.R
|
||||||
import org.prolibertate.games.game.GameCatalog
|
import org.prolibertate.games.game.GameCatalog
|
||||||
import org.prolibertate.games.game.GameCategory
|
import org.prolibertate.games.game.GameMenu
|
||||||
import org.prolibertate.games.game.GameDescriptor
|
import org.prolibertate.games.game.GameDescriptor
|
||||||
import org.prolibertate.games.game.engine.TableConfig
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
import org.prolibertate.games.net.LobbyController
|
import org.prolibertate.games.net.LobbyController
|
||||||
@@ -64,6 +65,9 @@ class AppEnv(
|
|||||||
*/
|
*/
|
||||||
sealed interface Route {
|
sealed interface Route {
|
||||||
data object Menu : Route
|
data object Menu : Route
|
||||||
|
|
||||||
|
/** One section of the menu: its games, and any sub-section under it. */
|
||||||
|
data class Section(val menu: GameMenu) : Route
|
||||||
data object Settings : Route
|
data object Settings : Route
|
||||||
data object Scorekeeper : Route
|
data object Scorekeeper : Route
|
||||||
data class Setup(val gameId: String) : Route
|
data class Setup(val gameId: String) : Route
|
||||||
@@ -152,12 +156,20 @@ private fun AppContent(
|
|||||||
// Dev builds show the games that are not finished yet; production
|
// Dev builds show the games that are not finished yet; production
|
||||||
// releases list only what can actually be played.
|
// releases list only what can actually be played.
|
||||||
showComingSoon = showComingSoon,
|
showComingSoon = showComingSoon,
|
||||||
onPickGame = { push(Route.Setup(it.id)) },
|
onPickSection = { push(Route.Section(it)) },
|
||||||
onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) },
|
onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) },
|
||||||
onScorekeeper = { push(Route.Scorekeeper) },
|
onScorekeeper = { push(Route.Scorekeeper) },
|
||||||
onSettings = { push(Route.Settings) },
|
onSettings = { push(Route.Settings) },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
is Route.Section -> SectionScreen(
|
||||||
|
menu = route.menu,
|
||||||
|
showComingSoon = showComingSoon,
|
||||||
|
onPickGame = { push(Route.Setup(it.id)) },
|
||||||
|
onPickSection = { push(Route.Section(it)) },
|
||||||
|
onBack = pop,
|
||||||
|
)
|
||||||
|
|
||||||
is Route.Settings -> SettingsScreen(
|
is Route.Settings -> SettingsScreen(
|
||||||
env = env,
|
env = env,
|
||||||
settings = settings,
|
settings = settings,
|
||||||
@@ -206,7 +218,7 @@ private fun AppContent(
|
|||||||
@Composable
|
@Composable
|
||||||
fun MainMenuScreen(
|
fun MainMenuScreen(
|
||||||
showComingSoon: Boolean,
|
showComingSoon: Boolean,
|
||||||
onPickGame: (GameDescriptor) -> Unit,
|
onPickSection: (GameMenu) -> Unit,
|
||||||
onJoinGame: () -> Unit,
|
onJoinGame: () -> Unit,
|
||||||
onScorekeeper: () -> Unit,
|
onScorekeeper: () -> Unit,
|
||||||
onSettings: () -> Unit,
|
onSettings: () -> Unit,
|
||||||
@@ -248,25 +260,124 @@ fun MainMenuScreen(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
GameCategory.entries.forEach { category ->
|
// Only the top of the tree. Trick-taking is reached through
|
||||||
val games = GameCatalog.byCategory(category, includeComingSoon = showComingSoon)
|
// Card Games, which is where somebody looking for it would go.
|
||||||
// Don't leave a heading stranded over nothing.
|
val sections = GameMenu.entries.filter {
|
||||||
if (games.isEmpty()) return@forEach
|
it.isTopLevel && GameCatalog.hasAnything(it, includeComingSoon = showComingSoon)
|
||||||
|
}
|
||||||
|
items(sections) { section ->
|
||||||
|
SectionTile(
|
||||||
|
menu = section,
|
||||||
|
count = countIn(section, showComingSoon),
|
||||||
|
onClick = { onPickSection(section) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
|
|
||||||
|
/**
|
||||||
|
* How many games a section offers, counting whatever is under it as well.
|
||||||
|
*
|
||||||
|
* A game in both a section and its sub-section is one game, not two, so this
|
||||||
|
* counts a set rather than adding the lists together.
|
||||||
|
*/
|
||||||
|
private fun countIn(menu: GameMenu, showComingSoon: Boolean): Int =
|
||||||
|
(GameCatalog.inMenu(menu, showComingSoon) +
|
||||||
|
menu.children.flatMap { GameCatalog.inMenu(it, showComingSoon) })
|
||||||
|
.toSet().size
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One section of the menu.
|
||||||
|
*
|
||||||
|
* Sub-sections come first and its own games after, so Card Games opens on
|
||||||
|
* "Trick-taking" and then everything played with a pack — including the
|
||||||
|
* trick-taking games themselves, which belong in both places and are listed in
|
||||||
|
* both. A game reachable one way and not the other would just be a game
|
||||||
|
* somebody could not find.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SectionScreen(
|
||||||
|
menu: GameMenu,
|
||||||
|
showComingSoon: Boolean,
|
||||||
|
onPickGame: (GameDescriptor) -> Unit,
|
||||||
|
onPickSection: (GameMenu) -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
val children = menu.children.filter {
|
||||||
|
GameCatalog.hasAnything(it, includeComingSoon = showComingSoon)
|
||||||
|
}
|
||||||
|
val games = GameCatalog.inMenu(menu, includeComingSoon = showComingSoon)
|
||||||
|
|
||||||
|
ScreenScaffold(title = menu.label, onBack = onBack) { modifier ->
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Adaptive(minSize = 172.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
Text(
|
Text(
|
||||||
text = category.label,
|
text = menu.blurb,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(children) { child ->
|
||||||
|
SectionTile(
|
||||||
|
menu = child,
|
||||||
|
count = countIn(child, showComingSoon),
|
||||||
|
onClick = { onPickSection(child) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (games.isNotEmpty() && children.isNotEmpty()) {
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
Text(
|
||||||
|
text = "All ${menu.label.lowercase()}",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
|
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
items(games) { game ->
|
items(games) { game ->
|
||||||
GameTile(game = game, onClick = { onPickGame(game) })
|
GameTile(game = game, onClick = { onPickGame(game) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A way into a section, rather than into a game. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun SectionTile(menu: GameMenu, count: Int, onClick: () -> Unit) {
|
||||||
|
Card(
|
||||||
|
onClick = onClick,
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
Text(
|
||||||
|
text = menu.label,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = menu.blurb,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (count == 1) "1 game" else "$count games",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,21 +16,54 @@ class CatalogTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a production build lists only games that can be played`() {
|
fun `a production build lists only games that can be played`() {
|
||||||
GameCategory.entries.forEach { category ->
|
GameMenu.entries.forEach { menu ->
|
||||||
val shown = GameCatalog.byCategory(category, includeComingSoon = false)
|
val shown = GameCatalog.inMenu(menu, includeComingSoon = false)
|
||||||
assertTrue(
|
assertTrue(
|
||||||
"$category offers an unfinished game in a release build",
|
"$menu offers an unfinished game in a release build",
|
||||||
shown.all { it.available },
|
shown.all { it.available },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a dev build lists the whole catalogue`() {
|
fun `every game is reachable from at least one menu`() {
|
||||||
val shown = GameCategory.entries.flatMap {
|
// Counting is no good now that a game can be in several menus: the
|
||||||
GameCatalog.byCategory(it, includeComingSoon = true)
|
// union is what matters, and a game in none of them would be in the
|
||||||
|
// catalogue but on no screen.
|
||||||
|
val reachable = GameMenu.entries.flatMap {
|
||||||
|
GameCatalog.inMenu(it, includeComingSoon = true)
|
||||||
|
}.toSet()
|
||||||
|
assertEquals(GameCatalog.all.toSet(), reachable)
|
||||||
|
GameCatalog.all.forEach {
|
||||||
|
assertTrue("${it.id} is in no menu", it.menus.isNotEmpty())
|
||||||
}
|
}
|
||||||
assertEquals(GameCatalog.all.size, shown.size)
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a game can be in more than one menu`() {
|
||||||
|
// The whole point of the set. A patience is solitaire and a card game;
|
||||||
|
// Euchre is a card game and a trick-taking one.
|
||||||
|
val klondike = GameCatalog.byId(GameCatalog.KLONDIKE)!!
|
||||||
|
assertTrue(klondike.menus.containsAll(setOf(GameMenu.SOLITAIRE, GameMenu.CARDS)))
|
||||||
|
assertTrue(GameCatalog.inMenu(GameMenu.SOLITAIRE).contains(klondike))
|
||||||
|
assertTrue(GameCatalog.inMenu(GameMenu.CARDS).contains(klondike))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `trick-taking sits under cards, and its games are card games too`() {
|
||||||
|
assertEquals(GameMenu.CARDS, GameMenu.TRICK_TAKING.parent)
|
||||||
|
assertEquals(listOf(GameMenu.TRICK_TAKING), GameMenu.CARDS.children)
|
||||||
|
assertTrue(GameMenu.TRICK_TAKING.isTopLevel.not())
|
||||||
|
assertTrue(GameMenu.SOLITAIRE.isTopLevel)
|
||||||
|
|
||||||
|
val tricks = GameCatalog.inMenu(GameMenu.TRICK_TAKING)
|
||||||
|
assertTrue("there should be some", tricks.isNotEmpty())
|
||||||
|
// A sub-menu that listed something its parent did not would be a game
|
||||||
|
// you could reach one way and not the other.
|
||||||
|
assertTrue(
|
||||||
|
"every trick-taking game is also a card game",
|
||||||
|
tricks.all { it.menus.contains(GameMenu.CARDS) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -42,19 +75,19 @@ class CatalogTest {
|
|||||||
// engine is written.
|
// engine is written.
|
||||||
assertTrue(GameCatalog.all.all { it.available })
|
assertTrue(GameCatalog.all.all { it.available })
|
||||||
assertEquals(
|
assertEquals(
|
||||||
GameCatalog.byCategory(GameCategory.BOARD, includeComingSoon = false),
|
GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = false),
|
||||||
GameCatalog.byCategory(GameCategory.BOARD, includeComingSoon = true),
|
GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = true),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `hiding unfinished games still leaves something to play in each category`() {
|
fun `hiding unfinished games still leaves something to play in every menu`() {
|
||||||
// A category filtered down to nothing would leave a stranded heading,
|
// A menu filtered down to nothing would leave a stranded heading, and a
|
||||||
// and a menu with no playable game at all would be a broken release.
|
// release where a whole section was empty would be a broken one.
|
||||||
GameCategory.entries.forEach { category ->
|
GameMenu.entries.forEach { menu ->
|
||||||
assertFalse(
|
assertTrue(
|
||||||
"$category has nothing playable in a release build",
|
"$menu has nothing playable in a release build",
|
||||||
GameCatalog.byCategory(category, includeComingSoon = false).isEmpty(),
|
GameCatalog.hasAnything(menu, includeComingSoon = false),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
assertTrue(GameCatalog.playable.isNotEmpty())
|
assertTrue(GameCatalog.playable.isNotEmpty())
|
||||||
|
|||||||
Reference in New Issue
Block a user