Public Access
feat(psn): sync PlayStation trophies as achievements
Sets map by exact title id, else by name and platform. Trophy gains count as achievements; platinum or all earned is perfect. Max 30 PSN calls per run; trophy lists load only when changed. Hidden trophies stay hidden (name and detail) until earned.
This commit is contained in:
1 parent
f5ca7237a9
commit
0ddb345a41
54 files changed
+2937
-68
No files matched your search
@@ -138,8 +138,9 @@ To Be Played is split into three repositories:
|
||||
feature is off and the rest works.
|
||||
- **PlayStation link** (optional, unofficial API): the user pastes an NPSSO once, Core keeps only the encrypted refresh
|
||||
token (about 10 days). Every library sync then adds the played PS4/PS5 titles that match IGDB exactly, keeps the unsure
|
||||
ones for the import review, and updates playtime and last played date. See "PlayStation link". Off without
|
||||
`APP_PSN_TOKEN_KEY`.
|
||||
ones for the import review, and updates playtime and last played date. The same sync reads the PS4/PS5 **trophy
|
||||
sets** of the matched games: they count as achievements (platinum = perfect game) in the detail page, stats, tiles,
|
||||
public profile, sync banner and follower digest. See "PlayStation link". Off without `APP_PSN_TOKEN_KEY`.
|
||||
- **Public profile data**: totals, status counts, games in progress and finished games with rating, perfect games
|
||||
and average completion (the numbers count a linked set once; the lists keep every edition). Readable only
|
||||
with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID. `stats` holds only the stat
|
||||
@@ -216,13 +217,14 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
|
||||
| `GET`, `DELETE` | `/api/library/psn-account` (`{enabled, linked, linkedAt, expiresAt, reviewImportId}`: the PSN import waiting for review, else null), unlink (`204`, deletes the stored token) | JWT |
|
||||
| `GET`, `DELETE` | `/api/library/imports/{id}/undo-check` (`{games, changed}`), `/api/library/imports/{id}/games` (undo; `204`, `409 IMPORT_NOT_SAVED`) | JWT |
|
||||
| `POST` | `/api/library/sync` | JWT |
|
||||
| `GET` | `/api/library/games/{appid}` (detail; slice 3: `achievements[]` also hold trophies with `kind` `STEAM`\|`PSN`, `setId`, `trophyType`, `group`, `rarity`; new `achievementSets[]` = `{id, kind, platform, name, total, unlocked, perfect, visible, listed}`, Steam first, then PS5 before PS4; `hasAchievements` = any set; the top-level counts stay Steam only) | JWT |
|
||||
| `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | JWT |
|
||||
| `GET` | `/api/library/backlog?limit=&released=` (`{games}` in the user's order; `limit` 1-20, none = all) | JWT |
|
||||
| `PUT` | `/api/library/backlog` (`{appids}` = the whole order; `204`) | JWT |
|
||||
| `GET` | `/api/library/picker?maxMinutes=&mood=&exclude=&category=` (`{categories:[{source, games}]}`; each game has a `reason` object; `category` returns that row only) | JWT |
|
||||
| `GET` | `/api/library/stats?groups=` (`OVERVIEW`, `PILE`, `TOP_GAMES`, `TASTE`, `HABITS`, `FINISHED`, `TREND`, `PLATFORMS`; none = all) | JWT |
|
||||
| `GET` | `/api/library/stats/games?key=&arg=&page=` (the games behind a stats number; `400 BAD_ARG`) | JWT |
|
||||
| `GET` | `/api/library/achievements/summary` | JWT |
|
||||
| `GET` | `/api/library/achievements/summary` (Steam achievements and PlayStation trophies; one entry per game, platinum = perfect) | JWT |
|
||||
| `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` (`canSync`: a Steam ID or a PlayStation link) | JWT |
|
||||
| `GET` | `/api/public/profiles/library` | profile ticket |
|
||||
| `GET` | `/api/public/profiles/library/stats/games?key=&arg=&page=` (`403 NOT_SHARED`, `400 BAD_ARG`) | profile ticket |
|
||||
@@ -286,7 +288,7 @@ flowchart LR
|
||||
Igdb --> IgdbApi["IGDB API"]
|
||||
Igdb -->|"OAuth token"| Twitch["Twitch"]
|
||||
Speedrun --> SpeedrunApi["speedrun.com API"]
|
||||
Psn -->|"NPSSO sign-in, played titles"| PsnApi["PlayStation API (unofficial)"]
|
||||
Psn -->|"NPSSO sign-in, played titles, trophy sets and lists"| PsnApi["PlayStation API (unofficial)"]
|
||||
LibRepo -->|"own tables; picker also follow, public_user, account_name, blocked_user, deleted_user; inbox_note (PSN expiry notes)"| Db[("PostgreSQL tbp_core")]
|
||||
Stats -->|"listed_user_game, counted_user_game, game_mood, coming_game, user_game_status_change, deleted_user, blocked_user, listed_user, account_name, game_alias"| Db
|
||||
Follow -->|"follow, follow_digest, inbox_note, public_user, sync_change, user_game_status_change, account_name"| Db
|
||||
@@ -306,7 +308,7 @@ flowchart LR
|
||||
| `steam_adapter` | Steam Web API client, availability check of Steam CDN pictures |
|
||||
| `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search |
|
||||
| `speedrun_adapter` | speedrun.com client: game search by name, verified runs |
|
||||
| `psn_adapter` | PlayStation client (unofficial): NPSSO to tokens, refresh, played titles |
|
||||
| `psn_adapter` | PlayStation client (unofficial): NPSSO to tokens, refresh, played titles, trophy sets and lists |
|
||||
|
||||
`follow` and `inbox` read `account_name`, `public_user`, `blocked_user`, `deleted_user`, `sync_result`, `sync_change`,
|
||||
`user_game` and `user_game_status_change` in SQL; the sync tells `follow` through `shared.event.LibraryActivityEvent`.
|
||||
@@ -360,6 +362,9 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain`
|
||||
in the background. The achievement sync also fetches Steam's global unlock rates with each schema, and sets
|
||||
FINISHED for a game whose ending achievement is unlocked while the user never chose its status.
|
||||
|
||||
The PSN step (`PsnSyncStep`, then `PsnTrophyStep`, see "PlayStation link") runs after the Steam fetch and before
|
||||
the transaction; its `ADDED`, `PLAYTIME` and trophy `ACHIEVEMENTS` changes join the same `sync_result`.
|
||||
|
||||
The frontend polls `GET /api/library/sync-status` until the run ends.
|
||||
|
||||
Followers (#27): when the achievement run ends, `AchievementSyncService` publishes `LibraryActivityEvent`, and
|
||||
@@ -371,10 +376,13 @@ minutes; it never takes a sync result, which may still wait for its achievements
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant W as LibrarySyncWorker
|
||||
participant P as PsnSyncStep + PsnTrophyStep
|
||||
participant A as AchievementSyncService
|
||||
participant F as FollowDigestService
|
||||
participant D as Db
|
||||
W->>D: sync_result, sync_change (ADDED, STATUS, WISHLIST)
|
||||
W->>P: run (titles, trophy summaries, changed lists)
|
||||
P-->>W: ADDED, PLAYTIME, ACHIEVEMENTS (trophy gains)
|
||||
W->>D: sync_result, sync_change (ADDED, STATUS, WISHLIST, PSN changes)
|
||||
W-->>A: LibrarySyncedEvent
|
||||
A->>D: sync_change (ACHIEVEMENTS)
|
||||
A-->>F: LibraryActivityEvent
|
||||
@@ -625,8 +633,32 @@ project ships it; all values in `app.psn.*`). `PsnAccountService` in `library` h
|
||||
The client cannot start a `PSN` import with its own rows.
|
||||
- **Limits**: one limiter `psnApi` for the whole server (1 request / 4 s, taken per HTTP request). Titles bought but
|
||||
never started are not listed by PlayStation. A `PSN` row pays no rewards (no Steam proof), and a merge undo does not
|
||||
move it back. One sync costs 2 PlayStation calls (token and one page of titles), 8 s at the server-wide limit; the
|
||||
nightly loop is sequential. Trophies are not built.
|
||||
move it back. The nightly loop is sequential.
|
||||
- **Trophies** (V40, `PsnTrophyStep`, called at the end of `PsnSyncStep.run`, outside the sync transaction, never throws).
|
||||
It reuses the access token of the title part (`FetchResult.Titles` hides it in `toString`). Own tables
|
||||
`psn_trophy_set`, `psn_trophy`, `user_psn_trophy_set`, `user_psn_trophy` and the cache `psn_title_trophy_set`, so the
|
||||
Steam tables and the rewards (Steam only) stay untouched.
|
||||
- **Summary**: 1-2 calls `trophy/v1/users/me/trophyTitles` give every set with defined and earned counts,
|
||||
`trophySetVersion` and `lastUpdatedDateTime`. Hidden (`hiddenFlag`) sets, PS3/Vita-only sets and sets of unknown or
|
||||
DISMISSED titles are not stored. A summary row is saved for every mapped set. **Gains** (`ACHIEVEMENTS` changes) come
|
||||
from the earned total only; the first time a set is stored is a baseline.
|
||||
Only a **complete** summary (sets returned = the API's `totalItemCount`) removes the user's stored sets that are no
|
||||
longer in it (another account was linked); a partial one, after a broken page, removes nothing. Unlink deletes all of
|
||||
the user's trophy rows.
|
||||
- **Mapping**, two paths, the log line `PSN trophies user=… mapped by title id=N by name=M` shows which one worked.
|
||||
(1) Exact: `titles/trophyTitles?npTitleIds=` (5 ids per call) answers title id to set; cached globally, "no set" is
|
||||
asked again after 30 days. Not confirmed against the real service: a 4xx or an empty answer falls through to (2)
|
||||
Name + platform, same account only: the clean name equals that of exactly one linked title and its platform is in the
|
||||
set's platform list. Two games with the same name: the set stays unmapped. The set counts for the merge target of the game.
|
||||
- **Lists only when changed**: the global trophy list when the stored `list_version` differs from `trophySetVersion`;
|
||||
the user's earned list (also gives the rarity) when `list_updated_at` differs from `lastUpdatedDateTime`; a set with
|
||||
0 earned needs no earned call.
|
||||
- **Budget** `app.psn.trophy-calls-per-run` (30, about 2 min at 1 request / 4 s; the sync counts as stale after 5 min)
|
||||
covers title id lookups and lists. Newest sets first; the rest follow in later syncs (the detail page then says the
|
||||
list loads with the next sync). Three errors in a row stop the list calls, the summaries are still saved.
|
||||
- **Presentation**: `perfect()` = platinum or all earned; one entry per set, one per game for the counts (perfect set,
|
||||
else best percent). A hidden trophy that is not earned has no name and no description. Stored image URLs must be `https://`.
|
||||
A sync costs 2 calls (token, titles) plus the summary and the changed lists.
|
||||
|
||||
## Run locally
|
||||
|
||||
@@ -664,7 +696,8 @@ What the `local` profile does:
|
||||
- PlayStation: paste `local-npsso` in the PlayStation card of the settings (any other value is refused). The link starts
|
||||
a sync: Ghost of Yōtei and Mega Man Legacy Collection match exactly and are added by themselves. Ghost of Tsushima (PS4
|
||||
and PS5 version as one line, "DIRECTOR'S CUT" in the name) and Monster Hunter Wilds (IGDB's recording does not know it)
|
||||
wait in the import review (`/import`). The local profile has a fixed token key.
|
||||
wait in the import review (`/import`). The local profile has a fixed token key. The trophy stubs (Ghost of Yōtei PS5
|
||||
by title id, Mega Man PS4 by name, one PS3 and one hidden set) are fake `NPWR9…` ids in `local/wiremock`.
|
||||
- To see an IGDB link suggestion: add Hollow Knight by hand (game search, PS5). It shares IGDB's "same game" key with the
|
||||
Steam copy, so the link queue (auditor) lists the pair with the IGDB badge.
|
||||
|
||||
|
||||
Reference in new issue
Block a user