diff --git a/README.md b/README.md index 780e783..0e8985f 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,11 @@ The app shell is complete and two games are playable end to end: | **Playable** | Euchre, Sequence | | **Listed, not yet implemented** | Kaiser, President, Golf, Wizard, Crazy 8s, Mastermind, Backgammon, Chess, Checkers | -Unimplemented games appear in the menu marked *Coming soon*. Adding one means +Unimplemented games appear in the menu marked *Coming soon* **on dev builds +only** — a production release lists just the games that can actually be played, +so nobody installs it and taps into a dead end. The menu decides this from the +installed APK's own version name, so it follows the build rather than the +channel selected for future updates. Adding one means writing a `GameRules` implementation, a `GameAi`, and a screen, then flipping `available` in `GameCatalog` — the menu, lobby, networking, settings and OTA layers are game-agnostic and need no changes. 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 1667c94..41f66c8 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -154,6 +154,22 @@ object GameCatalog { fun byId(id: String): GameDescriptor? = all.firstOrNull { it.id == id } - fun byCategory(category: GameCategory): List = - all.filter { it.category == category } + /** + * Games in a category. + * + * [includeComingSoon] is what separates the two update channels: a dev + * build lists everything, including the games that are still only entries + * in this table, so the roadmap is visible while working on it. A + * production build lists only what can actually be played, so nobody + * downloads a release and taps into a dead end. + */ + fun byCategory( + category: GameCategory, + includeComingSoon: Boolean = true, + ): List = all.filter { + it.category == category && (includeComingSoon || it.available) + } + + /** Everything currently playable, regardless of category. */ + val playable: List get() = all.filter { it.available } } diff --git a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt index 0d596c4..d886eae 100644 --- a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt +++ b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt @@ -39,6 +39,7 @@ import org.prolibertate.games.game.engine.TableConfig import org.prolibertate.games.net.LobbyController import org.prolibertate.games.settings.Settings import org.prolibertate.games.settings.SettingsRepository +import org.prolibertate.games.update.UpdateChannel import org.prolibertate.games.update.Updater /** Everything the screens need, assembled once by the activity. */ @@ -75,6 +76,12 @@ fun AppRoot(env: AppEnv) { val settings by env.settingsRepository.settings.collectAsState(initial = Settings()) var stack by remember { mutableStateOf(listOf(Route.Menu)) } + // Read from the installed APK's own version name, so this follows the build + // rather than the channel the user has selected for future updates. + val showComingSoon = remember { + env.updater.installedChannel() == UpdateChannel.DEV + } + fun push(route: Route) { stack = stack + route } @@ -94,6 +101,9 @@ fun AppRoot(env: AppEnv) { when (val route = stack.last()) { is Route.Menu -> MainMenuScreen( + // Dev builds show the games that are not finished yet; production + // releases list only what can actually be played. + showComingSoon = showComingSoon, onPickGame = { push(Route.Setup(it.id)) }, onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) }, onSettings = { push(Route.Settings) }, @@ -141,6 +151,7 @@ fun AppRoot(env: AppEnv) { @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainMenuScreen( + showComingSoon: Boolean, onPickGame: (GameDescriptor) -> Unit, onJoinGame: () -> Unit, onSettings: () -> Unit, @@ -175,6 +186,10 @@ fun MainMenuScreen( modifier = Modifier.fillMaxSize(), ) { GameCategory.entries.forEach { category -> + val games = GameCatalog.byCategory(category, includeComingSoon = showComingSoon) + // Don't leave a heading stranded over nothing. + if (games.isEmpty()) return@forEach + item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) { Text( text = category.label, @@ -183,7 +198,7 @@ fun MainMenuScreen( modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), ) } - items(GameCatalog.byCategory(category)) { game -> + items(games) { game -> GameTile(game = game, onClick = { onPickGame(game) }) } } diff --git a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt new file mode 100644 index 0000000..6def631 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt @@ -0,0 +1,65 @@ +package org.prolibertate.games.game + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CatalogTest { + + @Test + fun `every game has a unique id`() { + val ids = GameCatalog.all.map { it.id } + assertEquals("ids must be unique", ids.size, ids.distinct().size) + ids.forEach { assertEquals(it, GameCatalog.byId(it)?.id) } + } + + @Test + fun `a production build lists only games that can be played`() { + GameCategory.entries.forEach { category -> + val shown = GameCatalog.byCategory(category, includeComingSoon = false) + assertTrue( + "$category offers an unfinished game in a release build", + shown.all { it.available }, + ) + } + } + + @Test + fun `a dev build lists everything including what is still coming`() { + val shown = GameCategory.entries.flatMap { + GameCatalog.byCategory(it, includeComingSoon = true) + } + assertEquals(GameCatalog.all.size, shown.size) + assertTrue("unfinished games are visible while developing", shown.any { !it.available }) + } + + @Test + fun `hiding unfinished games still leaves something to play in each category`() { + // A category filtered down to nothing would leave a stranded heading, + // and a menu with no playable game at all would be a broken release. + GameCategory.entries.forEach { category -> + assertFalse( + "$category has nothing playable in a release build", + GameCatalog.byCategory(category, includeComingSoon = false).isEmpty(), + ) + } + assertTrue(GameCatalog.playable.isNotEmpty()) + } + + @Test + fun `playable games are the ones with engines wired up`() { + assertEquals( + setOf(GameCatalog.EUCHRE, GameCatalog.SEQUENCE), + GameCatalog.playable.map { it.id }.toSet(), + ) + } + + @Test + fun `player counts are sane`() { + GameCatalog.all.forEach { game -> + assertTrue("${game.id} min players", game.minPlayers >= 2) + assertTrue("${game.id} max >= min", game.maxPlayers >= game.minPlayers) + } + } +}