From ce83f7c2c4187f5d036da0b3e4d3b52277ff0b19 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 02:45:56 +0000 Subject: [PATCH] Hide unfinished games from production builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A release listed all eleven games with nine of them marked "Coming soon", so most of the menu was a dead end for anyone installing from the production channel. Production now lists only what can be played; dev builds still show everything, which is where seeing the roadmap is useful. The menu reads the installed APK's own version name, so this follows the build rather than the update channel selected for the future — a dev build shows the full list even if its owner has switched back to production for updates. CatalogTest pins both views, including that filtering never empties a category and strands its heading. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN --- README.md | 6 +- .../org/prolibertate/games/game/Catalog.kt | 20 +++++- .../java/org/prolibertate/games/ui/AppRoot.kt | 17 ++++- .../prolibertate/games/game/CatalogTest.kt | 65 +++++++++++++++++++ 4 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 app/src/test/java/org/prolibertate/games/game/CatalogTest.kt 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) + } + } +}