Public Access
feat(library): group same game across platforms into sets
One entry per game across platforms (Gitea #34, slice 1 of 3). Same IGDB game is linked; a user can split one key off its set. V42 keeps status, rating and notes per set; rewards pay once per set.
This commit is contained in:
1 parent
8e4c382563
commit
82d3b59165
21 files changed
+1134
-64
No files matched your search
@@ -139,12 +139,15 @@ flowchart LR
|
||||
track reaches the item's target, retroactively and never lost. The five tracks (`Unlocks`): games finished
|
||||
(`FINISHED` + `FINISHED_MANUAL` rows + starter grant / 20), games completed, weeks in a row with a finish, months in
|
||||
a row with a `MONTH` bonus, months in a row with more Steam playtime than the month before (hidden games count too).
|
||||
A set (`user_game_set`, see "Editions of one game") pays `STARTED`, `FINISHED`/`FINISHED_MANUAL` and `COMPLETED` once: a
|
||||
game whose other edition was paid is no candidate, and two editions of a set give one candidate (the first event).
|
||||
A `BUY` or `UNLOCK` row proves ownership,
|
||||
`reward_equipped` holds what is worn. A platform admin owns every item (the role of the login token, or the `role`
|
||||
claim of the profile ticket for visitors).
|
||||
- **Editions and links**: editions, remasters and ports of one game stay separate library rows, but an AUDITOR can
|
||||
link their keys as one game for every user (see "Editions of one game"). IGDB suggests pairs, users can ask for
|
||||
one, and one queue holds both. Every link, unlink and dismissal goes to `admin_audit`
|
||||
- **Editions and links**: editions, remasters and ports of one game stay separate library rows, but their keys are one
|
||||
game (a set): IGDB's "same game" pairs are linked automatically, an AUDITOR can link or unlink keys for every user,
|
||||
and a user can split a key off a set for themselves (see "Editions of one game"). A set shares one status, rating,
|
||||
note and priority. Users can ask for a pair; the auditor queue holds user requests and the IGDB pairs not linked yet (`OPEN` only). Every link, unlink and dismissal goes to `admin_audit`
|
||||
(`GAME_LINK`, `GAME_UNLINK`, `GAME_LINK_DISMISS`); auditors read that log through `GET /api/library/audit-log`, with
|
||||
the actor's display name from Auth's account state push.
|
||||
- **Wishlist**: synced from Steam with store price and discount.
|
||||
@@ -220,6 +223,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
|
||||
| `GET` | `/api/library/catalog/{appid}` (what the correction dialog reads: name, image, speedrun, moods, endless, genres, timeToBeat, corrections; `404 GAME_NOT_FOUND`) | JWT, AUDITOR |
|
||||
| `POST` | `/api/library/games/{appid}/merge` (`{otherAppid}`; `200 {appid}` = the key that stays, `400 SAME_GAME` / `STEAM_GAMES`, `404 GAME_NOT_FOUND`) | JWT, AUDITOR |
|
||||
| `POST` | `/api/library/merges/{mergeId}/undo` (`mergeId` = the `id` of a `GAME_MERGE` audit row, within 30 days; `204`, `404 MERGE_NOT_FOUND`, `409 UNDO_NOT_AVAILABLE` / `NEWER_MERGE`, `410 UNDO_EXPIRED`) | JWT, AUDITOR |
|
||||
| `DELETE` | `/api/library/games/{appid}/set` (the user splits the key off its set for themselves; `204`, `404 NOT_LINKED` when no other listed edition is in the user's set) | JWT |
|
||||
| `POST`, `DELETE` | `/api/library/games/{appid}/link` (POST `{otherAppid}`, `204`, `400 SAME_GAME`, `404 GAME_NOT_FOUND`, `409 ALREADY_LINKED`; DELETE takes the game out of its set, `204`, `404 NOT_LINKED`) | JWT, AUDITOR |
|
||||
| `GET` | `/api/library/audit-log?action=&page=&size=` (the newest `admin_audit` rows, `PageDto` of `{id, actorUserId, actorName, action, appid, gameName, targetAppid, targetGameName, detail, createdAt, undoable}`; `action` is `GAME_MERGE`, `GAME_MERGE_UNDO`, `GAME_LINK`, `GAME_UNLINK`, `GAME_LINK_DISMISS`, `GAME_CORRECT` or `GAME_CORRECTION_RESET`; size 20, 40 or 80) | JWT, AUDITOR |
|
||||
| `GET` | `/api/library/link-queue?page=&size=` (`PageDto` of `{a, b, igdb, requests, createdAt}`: both games, most requested first; size 20, 40 or 80) | JWT, AUDITOR |
|
||||
@@ -338,7 +342,8 @@ flowchart LR
|
||||
SQL, which ArchUnit cannot see: `stats` reads `listed_user`, `account_name`, `game_alias`, `listed_user_game`, `counted_user_game`, `steam_game`, `game_mood`, `coming_game`, `user_game_status_change`, `deleted_user` and `blocked_user`; `account`
|
||||
deletes the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes
|
||||
into its list) and writes `user_seen_at` and `last_synced_at` of `user_steam_sync` (`AccountStateRepository`); `library`
|
||||
reads `deleted_user` and `blocked_user`, and for the picker `follow`, `public_user` and `account_name`. A module that moves to its own service takes these reads along as calls.
|
||||
reads `deleted_user` and `blocked_user`, and for the picker `follow`, `public_user` and `account_name`; `rewards` reads
|
||||
`user_game_set` (a set pays once). A module that moves to its own service takes these reads along as calls.
|
||||
The listed rule ("still owned, not hidden") is `UserGameRepository.LISTED` for JPQL and the view
|
||||
`listed_user_game` for native SQL; V24 says what a migration of `user_game` must do with the view. The numbers that
|
||||
count games once per linked set read `counted_user_game` (V28), built on `listed_user_game`: a migration that changes
|
||||
@@ -521,18 +526,49 @@ like `game_mood` (stale check, nightly sync, add, detail view; `app.library.edit
|
||||
`version_parent` for an edition, else `parent_game` when `game_type` is 9 (remaster) or 11 (port), else the game's own
|
||||
IGDB id; remakes (8) keep their own id. `null` when IGDB does not know the game.
|
||||
|
||||
- `game_link(appid, set_id)`: the keys an auditor linked, for every user. A set is every row with one `set_id`.
|
||||
- `game_link(appid, set_id)`: the keys that are one game, for every user. A set is every row with one `set_id`.
|
||||
An unlink takes one key out; a set of one is dropped. A merge puts the kept key in the merged key's set; when both have a set, the sets join.
|
||||
- `game_link_pair` is the auditor queue (`OPEN`, `DISMISSED`, `LINKED`). IGDB pairs are inserted when the queue is
|
||||
read; a row in any state keeps the pair out for good. An unlink dismisses the pairs of that key with the former
|
||||
- **Auto link** (V42): `GameEditionService.linkSameGames` links every pair of keys with the same `same_game_id`, after each
|
||||
editions refresh and once at start for the games already there (`linkExisting`). No auditor, so no audit row. A pair
|
||||
in `DISMISSED` (an auditor dismissal or an unlink) is never linked again, also not through a third edition: a link
|
||||
is skipped when a dismissed pair lies between the two sets. The link is global: a wrong IGDB key groups
|
||||
the game for every user until an auditor unlinks it.
|
||||
- **Split** (`DELETE /api/library/games/{appid}/set`): `user_game_split(user_id, appid)` makes the key stand alone for
|
||||
this user, only while another listed edition is in the user's set. No sync or link touches it, so it stays split.
|
||||
The view `user_game_set(user_id, appid, set_id)` is `game_link` for the user's rows minus the split keys; every
|
||||
reader of a user's set (`counted_user_game`, the editions of the detail, the `source` filter, the questions, rewards)
|
||||
reads it. There is no undo of a split yet.
|
||||
- **One status per set** (V42 triggers, like V14, so no code path can forget it): a change of `status`,
|
||||
`status_set_by_user`, `rating`, `notes` or `priority` on one row is copied to the other rows of the user's set,
|
||||
also hidden and removed ones (`user_game_set_share`; `pg_trigger_depth()` stops the echo). When rows join a set (a new
|
||||
row, a new link, a merge of sets), `align_user_game_set` applies the join rule: the winner is the counted edition
|
||||
(listed first, then status rank, playtime, older `first_seen_at`, lower appid); its status wins, `status_set_by_user`
|
||||
is true if any row has it, rating and priority fall back to the first row that has one, and the distinct notes are
|
||||
joined with a blank line, cut to 2000. A row that changes is first copied to `user_game_set_backup` (the first copy
|
||||
wins; kept until the account is deleted). The status log of a join is no hand change (`by_user` false), and a row that
|
||||
takes a finished status takes the set's first finish time, so "finished per year" does not move.
|
||||
- `game_link_pair` is the auditor queue (`OPEN`, `DISMISSED`, `LINKED`). IGDB pairs are inserted when the queue is read. A row in any state keeps the pair out for good. An unlink dismisses the pairs of that key with the former
|
||||
members. `game_link_request` holds who asked for a pair (the queue shows only the count). A user asks for a pair of
|
||||
two games of their own library; at most 20 open requests per user (a per-user advisory lock makes the cap hold for parallel calls), a dismissed pair answers `409 PAIR_DISMISSED`.
|
||||
- Pile, stats, snapshots and the dashboard count a set once (`counted_user_game`, see "Stats"); the library
|
||||
filter `hideDuplicates=true` hides the lesser editions. Pile Points are unchanged: `STARTED` and `FINISHED` stay per row,
|
||||
and the `MONTH` bonus reads `backlog_snapshot`, which counts a set once.
|
||||
filter `hideDuplicates=true` hides the lesser editions. Pile Points pay a set once (see "Pile Points"), and the
|
||||
`MONTH` bonus reads `backlog_snapshot`, which counts a set once.
|
||||
- The game detail has `linked` and `editions` (the user's other listed games of the set); a game row has `editions`
|
||||
(size of the set in the user's library, `null` below 2).
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Edition["game_edition (IGDB same_game_id)"] -->|"linkSameGames"| Link["game_link (global sets)"]
|
||||
Auditor["auditor link / unlink"] --> Link
|
||||
Link --> Set["user_game_set"]
|
||||
Split["user_game_split (user splits)"] -->|"minus"| Set
|
||||
Set --> Counted["counted_user_game"]
|
||||
Set -->|"join: align_user_game_set"| Row["user_game (status, rating, notes, priority)"]
|
||||
Row -->|"update: user_game_set_share"| Row
|
||||
Row -.->|"old values"| Backup["user_game_set_backup"]
|
||||
Set --> Rewards["rewards: a set pays once"]
|
||||
```
|
||||
|
||||
### Deleted accounts
|
||||
|
||||
Auth drives it (see Auth's readme). `POST /internal/accounts/deleted` adds a tombstone in `deleted_user`: the user
|
||||
@@ -542,7 +578,8 @@ user ids, which removes the rows of users Auth does not know (refused for more t
|
||||
`app.account-cleanup.max-orphans`, default 5). `app.account-cleanup.dry-run` (default `true`) only counts the rows.
|
||||
The tombstone also ends the user's follows in both directions without a note and removes the user's `public_user` and
|
||||
`follow_digest` rows; the purge removes the user's inbox notes and the notes about the user.
|
||||
The purge also removes the user's `account_name` and `listed_user` rows and link requests (`game_link_request`).
|
||||
The purge also removes the user's `account_name` and `listed_user` rows, link requests (`game_link_request`), splits
|
||||
(`user_game_split`) and set backups (`user_game_set_backup`).
|
||||
The tombstone also deletes the user's stored PlayStation token (`user_psn_account`) at once.
|
||||
|
||||
### Blocked users and Steam ID changes
|
||||
|
||||
Reference in new issue
Block a user