docs(spec): design downloaded games page, filter, settings
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
# Downloaded games: page, filter, and settings
|
||||
|
||||
Date: 2026-08-11
|
||||
|
||||
## Goal
|
||||
|
||||
1. A "Downloaded" sidebar entry and page listing only downloaded games.
|
||||
2. A downloaded filter in the filter panel on the existing browse pages.
|
||||
3. An admin setting to show or hide the Downloaded page.
|
||||
4. An admin setting that lets guests see the Downloaded page, offered only when
|
||||
the page itself is enabled.
|
||||
5. An admin setting that turns the downloaded filter on by default.
|
||||
|
||||
## Definition of "downloaded"
|
||||
|
||||
A game is downloaded when it has at least one `game_data` row with
|
||||
`presentOnDisk = 1`.
|
||||
|
||||
Games with no `game_data` rows at all — legacy titles served from htdocs — can
|
||||
never be downloaded under this definition and never appear on the page. This
|
||||
matches the existing per-game badge, which reads the same flag through the
|
||||
`MAX(presentOnDisk)` post-processing in `GameService.searchGames`.
|
||||
|
||||
## Keeping the flag truthful
|
||||
|
||||
`presentOnDisk` is currently written only by the Flashpoint Launcher and by the
|
||||
explicit Download button (`DownloadManager` → `GameDatabaseUpdater.markAsDownloaded`).
|
||||
Three gaps must close before the flag can drive a whole page.
|
||||
|
||||
**Play-path downloads never set it.** `GameZipServer.downloadAndMountInBackground`
|
||||
writes the ZIP and returns without touching the database. On success it will call
|
||||
`GameDatabaseUpdater.markAsDownloaded`. The `game_data.id` is already loaded by
|
||||
`GameDataService.getGameDataEntry`; thread it through the `mountZip` params rather
|
||||
than re-querying by `gameId` + `dateAdded`. Mark the row even when `sha256` is
|
||||
absent, since `GameDataDownloader` only verifies a checksum when one exists and the
|
||||
file is otherwise a complete, mountable download.
|
||||
|
||||
**Local-copy mode erases the flag.** With `ENABLE_LOCAL_DB_COPY=true`, queries and
|
||||
our writes hit `config.localDbPath`, and `DatabaseService.syncAndReload` copies the
|
||||
source over that file whenever the Launcher's database changes. A new
|
||||
`DownloadedReconciler` service restores truth from disk:
|
||||
|
||||
- Scan `config.flashpointGamesPath` for files matching `{uuid}-{timestamp}.zip`,
|
||||
the deterministic name produced by `GameDataDownloader.getFilename`.
|
||||
- Resolve each to its `game_data` row by `gameId` plus the timestamp derived from
|
||||
`dateAdded`, and bulk-set `presentOnDisk = 1` in a single transaction, skipping
|
||||
rows already flagged.
|
||||
- Update `game.activeDataOnDisk` where the marked row is the game's active data.
|
||||
- Run once at startup after `DatabaseService` initialization, and again after every
|
||||
`syncAndReload`. Both runs are fire-and-forget with a `.catch()`; an unreadable
|
||||
games directory logs a warning and yields zero marks.
|
||||
|
||||
The reconciler only ever marks. It never clears the flag for a missing ZIP, because
|
||||
the Launcher can store game data outside `flashpointGamesPath` and we would be
|
||||
overwriting its state with a false negative.
|
||||
|
||||
This scan also backfills every game downloaded through the Play path before this
|
||||
feature ships, which is otherwise the largest population of invisible downloads.
|
||||
|
||||
**Our own writes trigger a full reload.** In non-copy mode the write lands in the
|
||||
file `DatabaseService` watches, so the watcher fires, reopens the connection, and
|
||||
clears the search, flash-SWF, and filter-options caches — per download. Add
|
||||
`DatabaseService.noteSelfWrite()`, which re-stats the source and advances
|
||||
`lastModifiedTime` so `syncAndReload` short-circuits, and call it from
|
||||
`markAsDownloaded` and the reconciler. The caller then clears only the search and
|
||||
filter-options caches, so a freshly downloaded game appears immediately.
|
||||
|
||||
A Launcher write landing in the same millisecond as ours could be swallowed by this
|
||||
check. The consequence is a delayed reload, not data loss, and the next Launcher
|
||||
write recovers it.
|
||||
|
||||
## Backend filter
|
||||
|
||||
`downloaded?: boolean` joins the POST `/api/games` body schema, `GameSearchQuery`,
|
||||
and the filter-options query schema. `true` restricts to downloaded games;
|
||||
`false` and `undefined` both mean "no restriction", and the frontend omits the
|
||||
field when the filter is off.
|
||||
|
||||
In `GameService.searchGames` the condition is:
|
||||
|
||||
```sql
|
||||
AND EXISTS (SELECT 1 FROM game_data gd WHERE gd.gameId = g.id AND gd.presentOnDisk = 1)
|
||||
```
|
||||
|
||||
`buildFilterOptionsConditions` gets the same condition so dropdown values and the
|
||||
year range narrow to downloaded games; qualify the outer column with its table name
|
||||
there, since those queries have no alias and `game_data` has its own `id` column.
|
||||
|
||||
Both `GameSearchCache.generateCacheKey` and `GameService.getFilterOptionsCacheKey`
|
||||
include `downloaded`, normalized with `??` so `undefined` and `false` stay distinct.
|
||||
Add `'downloaded'` to the `filters` array in the search route's `logActivity`
|
||||
callback so the recorded filter count stays accurate.
|
||||
|
||||
## Frontend filter
|
||||
|
||||
`GameFilters.downloaded?: boolean` carries the value to the API.
|
||||
`FilterUrlParams.downloaded?: '0' | '1'` carries it in the URL under the unused
|
||||
single-character key `w`, guarded with `!== undefined` rather than truthiness so an
|
||||
explicit off survives `toUrlSafeFormat`.
|
||||
|
||||
The value is tri-state: `w.1` on, `w.0` off, absent means "use the admin default".
|
||||
`GameBrowseLayout` resolves it once and passes a plain boolean down.
|
||||
|
||||
Because absent means "default", removal cannot clear the parameter when the default
|
||||
is on — the filter would immediately reapply. When the admin default is on,
|
||||
`handleRemoveChip` and `handleClearAllFilters` write `w.0` instead of dropping the
|
||||
key; when it is off, they clear it as every other filter does. `CATEGORY_TO_ABBR`
|
||||
gains `Downloaded: 'W'` and `categoryToParamKeys` gains the matching entry so the
|
||||
chip participates in the existing filter hierarchy.
|
||||
|
||||
A bookmarked URL with no `w` changes meaning if an admin later flips the default.
|
||||
That is inherent to the tri-state and accepted.
|
||||
|
||||
`FilterPanel` renders a "Downloaded only" switch beside the year range in both the
|
||||
desktop and mobile rows, and an active filter chip. A new `showDownloadedFilter`
|
||||
prop hides the switch on the Downloaded page, where the filter is the page.
|
||||
|
||||
## Downloaded page
|
||||
|
||||
Route `/downloaded` renders `DownloadedView`, a thin wrapper over
|
||||
`GameBrowseLayout` with the filter pinned on and no library restriction, so results
|
||||
span arcade and theatre. `GameBrowseLayout`'s `library` prop becomes optional and it
|
||||
gains `forceDownloaded`.
|
||||
|
||||
The page passes `sectionKey={null}`, so game links resolve to the existing
|
||||
`/games/:id` detail route. No new per-section detail or play routes.
|
||||
|
||||
## Settings
|
||||
|
||||
Migration `004_downloaded_settings.sql` seeds three public boolean settings in the
|
||||
`features` category, following the existing `enable_*` naming:
|
||||
|
||||
| Key | Default | Effect |
|
||||
| --- | --- | --- |
|
||||
| `features.enable_downloaded_page` | 1 | Sidebar entry and `/downloaded` route |
|
||||
| `features.enable_downloaded_page_for_guests` | 0 | Guests may see the page |
|
||||
| `features.enable_downloaded_filter_default` | 0 | Browse pages start filtered |
|
||||
|
||||
`useFeatureFlags` exposes `enableDownloadedPage` with the usual admin bypass, so an
|
||||
admin cannot lock themselves out. The other two are read as `=== true`: they default
|
||||
off, and the hook's `?? true` fallback would otherwise invert them during the
|
||||
public-settings loading window and whenever the key is missing.
|
||||
|
||||
`ProtectedRoute` gains `'enableDownloadedPage'` in its `requireFeature` union and a
|
||||
new `requireFeatureForGuests` prop, checked only when the viewer is a guest, so a
|
||||
guest deep-linking to `/downloaded` with the guest flag off is redirected to
|
||||
`/unauthorized`. A flag check alone would let them through, since guests satisfy
|
||||
`requireAuth`.
|
||||
|
||||
These flags are presentation, not access control: `POST /api/games` is open to
|
||||
guests and accepts `downloaded` regardless, and the page shows no data a guest
|
||||
cannot already reach by filtering Browse. No backend enforcement is added.
|
||||
|
||||
`FeaturesSettingsTab` gains the three switches, with the guest row rendered only
|
||||
while the page flag is on. Its stored value persists while hidden and resurfaces if
|
||||
the page is re-enabled.
|
||||
|
||||
## Sidebar
|
||||
|
||||
The Downloaded entry sits in the Library section with Favorites. That block is
|
||||
currently hidden wholesale for guests, so its items never check guest status
|
||||
themselves. Relaxing the block requires giving each item its own predicate:
|
||||
Favorites and both playlist entries become explicitly `!isGuest`, Downloaded
|
||||
becomes `enableDownloadedPage && (!isGuest || enableDownloadedPageForGuests)`, and
|
||||
the section renders when any item survives. The existing
|
||||
`isViewingSharedPlaylistWithoutGuestAccess` case is unaffected.
|
||||
|
||||
## Testing
|
||||
|
||||
- `GameService.searchGames` returns only flagged games with `downloaded: true`, and
|
||||
is unaffected when the field is absent.
|
||||
- Filter options narrow when `downloaded` is set, and cache keys distinguish
|
||||
`undefined`, `false`, and `true`.
|
||||
- `DownloadedReconciler` maps ZIP filenames to the right `game_data` rows, ignores
|
||||
unrelated files, and marks nothing when the directory is unreadable.
|
||||
- `filterUrlCompression` round-trips `w.0` and `w.1`, and an explicit off survives
|
||||
`buildFilterSearchParams`.
|
||||
- Chip removal writes an explicit off when the admin default is on.
|
||||
|
||||
## Documentation
|
||||
|
||||
Update `docs/06-api-reference/games-api.md` (search and filter-options parameters),
|
||||
`docs/06-api-reference/settings-api.md` (three new settings), and
|
||||
`docs/10-features/02-game-browsing-filtering.md` (the filter and the page).
|
||||
Reference in New Issue
Block a user