diff --git a/local/wiremock/__files/psn-trophies-NPWR90001_00.json b/local/wiremock/__files/psn-trophies-NPWR90001_00.json new file mode 100644 index 0000000..847f7b2 --- /dev/null +++ b/local/wiremock/__files/psn-trophies-NPWR90001_00.json @@ -0,0 +1,60 @@ +{ + "trophySetVersion": "01.00", + "hasTrophyGroups": true, + "trophies": [ + { + "trophyId": 0, + "trophyHidden": false, + "trophyType": "platinum", + "trophyName": "Legend of Yōtei", + "trophyDetail": "Earn all other trophies.", + "trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t0.png", + "trophyGroupId": "default" + }, + { + "trophyId": 1, + "trophyHidden": false, + "trophyType": "gold", + "trophyName": "Wolf of Ezo", + "trophyDetail": "Finish the story.", + "trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t1.png", + "trophyGroupId": "default" + }, + { + "trophyId": 2, + "trophyHidden": false, + "trophyType": "silver", + "trophyName": "Lone Blade", + "trophyDetail": "Win a duel.", + "trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t2.png", + "trophyGroupId": "default" + }, + { + "trophyId": 3, + "trophyHidden": false, + "trophyType": "bronze", + "trophyName": "First Steps", + "trophyDetail": "Leave the village.", + "trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t3.png", + "trophyGroupId": "default" + }, + { + "trophyId": 4, + "trophyHidden": true, + "trophyType": "bronze", + "trophyName": "Secret Name", + "trophyDetail": "Secret detail text.", + "trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t4.png", + "trophyGroupId": "default" + }, + { + "trophyId": 5, + "trophyHidden": false, + "trophyType": "bronze", + "trophyName": "Island Trial", + "trophyDetail": "Finish the DLC trial.", + "trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t5.png", + "trophyGroupId": "001" + } + ] +} diff --git a/local/wiremock/__files/psn-trophies-NPWR90002_00.json b/local/wiremock/__files/psn-trophies-NPWR90002_00.json new file mode 100644 index 0000000..e70e6c0 --- /dev/null +++ b/local/wiremock/__files/psn-trophies-NPWR90002_00.json @@ -0,0 +1,33 @@ +{ + "trophySetVersion": "01.00", + "hasTrophyGroups": false, + "trophies": [ + { + "trophyId": 0, + "trophyHidden": false, + "trophyType": "bronze", + "trophyName": "Blue Bomber", + "trophyDetail": "Start the game.", + "trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t0.png", + "trophyGroupId": "default" + }, + { + "trophyId": 1, + "trophyHidden": false, + "trophyType": "bronze", + "trophyName": "Rock On", + "trophyDetail": "Beat Mega Man 1.", + "trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t1.png", + "trophyGroupId": "default" + }, + { + "trophyId": 2, + "trophyHidden": false, + "trophyType": "silver", + "trophyName": "Robot Master", + "trophyDetail": "Beat Mega Man 2.", + "trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t2.png", + "trophyGroupId": "default" + } + ] +} diff --git a/local/wiremock/__files/psn-trophies-earned-NPWR90001_00.json b/local/wiremock/__files/psn-trophies-earned-NPWR90001_00.json new file mode 100644 index 0000000..d0500d3 --- /dev/null +++ b/local/wiremock/__files/psn-trophies-earned-NPWR90001_00.json @@ -0,0 +1,58 @@ +{ + "trophySetVersion": "01.00", + "hasTrophyGroups": true, + "trophies": [ + { + "trophyId": 0, + "trophyHidden": false, + "trophyType": "platinum", + "earned": true, + "trophyRare": 3, + "trophyEarnedRate": "2.1", + "earnedDateTime": "2026-09-20T18:00:00Z" + }, + { + "trophyId": 1, + "trophyHidden": false, + "trophyType": "gold", + "earned": true, + "trophyRare": 3, + "trophyEarnedRate": "30.5", + "earnedDateTime": "2026-09-21T18:00:00Z" + }, + { + "trophyId": 2, + "trophyHidden": false, + "trophyType": "silver", + "earned": true, + "trophyRare": 3, + "trophyEarnedRate": "55.0", + "earnedDateTime": "2026-09-22T18:00:00Z" + }, + { + "trophyId": 3, + "trophyHidden": false, + "trophyType": "bronze", + "earned": true, + "trophyRare": 3, + "trophyEarnedRate": "90.2", + "earnedDateTime": "2026-09-23T18:00:00Z" + }, + { + "trophyId": 4, + "trophyHidden": true, + "trophyType": "bronze", + "earned": false, + "trophyRare": 3, + "trophyEarnedRate": "1.3" + }, + { + "trophyId": 5, + "trophyHidden": false, + "trophyType": "bronze", + "earned": false, + "trophyRare": 3, + "trophyEarnedRate": "12.0" + } + ] +} diff --git a/local/wiremock/__files/psn-trophies-earned-NPWR90002_00.json b/local/wiremock/__files/psn-trophies-earned-NPWR90002_00.json new file mode 100644 index 0000000..e3d5d60 --- /dev/null +++ b/local/wiremock/__files/psn-trophies-earned-NPWR90002_00.json @@ -0,0 +1,31 @@ +{ + "trophySetVersion": "01.00", + "hasTrophyGroups": false, + "trophies": [ + { + "trophyId": 0, + "trophyHidden": false, + "trophyType": "bronze", + "earned": false, + "trophyRare": 3, + "trophyEarnedRate": "40.0" + }, + { + "trophyId": 1, + "trophyHidden": false, + "trophyType": "bronze", + "earned": true, + "earnedDateTime": "2024-03-02T16:10:30Z", + "trophyRare": 3, + "trophyEarnedRate": "25.5" + }, + { + "trophyId": 2, + "trophyHidden": false, + "trophyType": "silver", + "earned": false, + "trophyRare": 2, + "trophyEarnedRate": "10.0" + } + ] +} diff --git a/local/wiremock/__files/psn-trophy-title-sets.json b/local/wiremock/__files/psn-trophy-title-sets.json new file mode 100644 index 0000000..e64c405 --- /dev/null +++ b/local/wiremock/__files/psn-trophy-title-sets.json @@ -0,0 +1,33 @@ +{ + "titles": [ + { + "npTitleId": "PPSA26344_00", + "trophyTitles": [ + { + "npServiceName": "trophy2", + "npCommunicationId": "NPWR90001_00", + "trophySetVersion": "01.00", + "trophyTitleName": "Ghost of Yōtei", + "trophyTitleIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/ghost-set.png", + "trophyTitlePlatform": "PS5", + "hasTrophyGroups": true, + "definedTrophies": { + "bronze": 3, + "silver": 1, + "gold": 1, + "platinum": 1 + }, + "progress": 66, + "earnedTrophies": { + "bronze": 1, + "silver": 1, + "gold": 1, + "platinum": 1 + }, + "hiddenFlag": false, + "lastUpdatedDateTime": "2026-10-01T20:41:07Z" + } + ] + } + ] +} diff --git a/local/wiremock/__files/psn-trophy-titles.json b/local/wiremock/__files/psn-trophy-titles.json new file mode 100644 index 0000000..96a943d --- /dev/null +++ b/local/wiremock/__files/psn-trophy-titles.json @@ -0,0 +1,102 @@ +{ + "totalItemCount": 4, + "nextOffset": null, + "trophyTitles": [ + { + "npServiceName": "trophy2", + "npCommunicationId": "NPWR90001_00", + "trophySetVersion": "01.00", + "trophyTitleName": "Ghost of Yōtei", + "trophyTitleIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/ghost-set.png", + "trophyTitlePlatform": "PS5", + "hasTrophyGroups": true, + "definedTrophies": { + "bronze": 3, + "silver": 1, + "gold": 1, + "platinum": 1 + }, + "progress": 66, + "earnedTrophies": { + "bronze": 1, + "silver": 1, + "gold": 1, + "platinum": 1 + }, + "hiddenFlag": false, + "lastUpdatedDateTime": "2026-10-01T20:41:07Z" + }, + { + "npServiceName": "trophy", + "npCommunicationId": "NPWR90002_00", + "trophySetVersion": "01.00", + "trophyTitleName": "Mega Man Legacy Collection", + "trophyTitleIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/megaman-set.png", + "trophyTitlePlatform": "PS4", + "hasTrophyGroups": false, + "definedTrophies": { + "bronze": 2, + "silver": 1, + "gold": 0, + "platinum": 0 + }, + "progress": 33, + "earnedTrophies": { + "bronze": 1, + "silver": 0, + "gold": 0, + "platinum": 0 + }, + "hiddenFlag": false, + "lastUpdatedDateTime": "2024-03-02T16:10:30Z" + }, + { + "npServiceName": "trophy", + "npCommunicationId": "NPWR90003_00", + "trophySetVersion": "01.00", + "trophyTitleName": "Old PS3 Game", + "trophyTitleIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/ps3-set.png", + "trophyTitlePlatform": "PS3", + "hasTrophyGroups": false, + "definedTrophies": { + "bronze": 5, + "silver": 0, + "gold": 0, + "platinum": 0 + }, + "progress": 20, + "earnedTrophies": { + "bronze": 1, + "silver": 0, + "gold": 0, + "platinum": 0 + }, + "hiddenFlag": false, + "lastUpdatedDateTime": "2012-05-01T10:00:00Z" + }, + { + "npServiceName": "trophy", + "npCommunicationId": "NPWR90004_00", + "trophySetVersion": "01.00", + "trophyTitleName": "Hidden By The User", + "trophyTitleIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/hidden-set.png", + "trophyTitlePlatform": "PS4", + "hasTrophyGroups": false, + "definedTrophies": { + "bronze": 1, + "silver": 0, + "gold": 0, + "platinum": 0 + }, + "progress": 0, + "earnedTrophies": { + "bronze": 0, + "silver": 0, + "gold": 0, + "platinum": 0 + }, + "hiddenFlag": true, + "lastUpdatedDateTime": "2020-01-01T10:00:00Z" + } + ] +} diff --git a/local/wiremock/mappings/psn-trophies-NPWR90001_00.json b/local/wiremock/mappings/psn-trophies-NPWR90001_00.json new file mode 100644 index 0000000..80fac31 --- /dev/null +++ b/local/wiremock/mappings/psn-trophies-NPWR90001_00.json @@ -0,0 +1,13 @@ +{ + "request": { + "method": "GET", + "urlPath": "/psn/trophy/v1/npCommunicationIds/NPWR90001_00/trophyGroups/all/trophies" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "bodyFileName": "psn-trophies-NPWR90001_00.json" + } +} diff --git a/local/wiremock/mappings/psn-trophies-NPWR90002_00.json b/local/wiremock/mappings/psn-trophies-NPWR90002_00.json new file mode 100644 index 0000000..b9e932a --- /dev/null +++ b/local/wiremock/mappings/psn-trophies-NPWR90002_00.json @@ -0,0 +1,13 @@ +{ + "request": { + "method": "GET", + "urlPath": "/psn/trophy/v1/npCommunicationIds/NPWR90002_00/trophyGroups/all/trophies" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "bodyFileName": "psn-trophies-NPWR90002_00.json" + } +} diff --git a/local/wiremock/mappings/psn-trophies-earned-NPWR90001_00.json b/local/wiremock/mappings/psn-trophies-earned-NPWR90001_00.json new file mode 100644 index 0000000..058322a --- /dev/null +++ b/local/wiremock/mappings/psn-trophies-earned-NPWR90001_00.json @@ -0,0 +1,13 @@ +{ + "request": { + "method": "GET", + "urlPath": "/psn/trophy/v1/users/me/npCommunicationIds/NPWR90001_00/trophyGroups/all/trophies" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "bodyFileName": "psn-trophies-earned-NPWR90001_00.json" + } +} diff --git a/local/wiremock/mappings/psn-trophies-earned-NPWR90002_00.json b/local/wiremock/mappings/psn-trophies-earned-NPWR90002_00.json new file mode 100644 index 0000000..9e7bf00 --- /dev/null +++ b/local/wiremock/mappings/psn-trophies-earned-NPWR90002_00.json @@ -0,0 +1,13 @@ +{ + "request": { + "method": "GET", + "urlPath": "/psn/trophy/v1/users/me/npCommunicationIds/NPWR90002_00/trophyGroups/all/trophies" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "bodyFileName": "psn-trophies-earned-NPWR90002_00.json" + } +} diff --git a/local/wiremock/mappings/psn-trophy-title-sets.json b/local/wiremock/mappings/psn-trophy-title-sets.json new file mode 100644 index 0000000..860dfb3 --- /dev/null +++ b/local/wiremock/mappings/psn-trophy-title-sets.json @@ -0,0 +1,13 @@ +{ + "request": { + "method": "GET", + "urlPath": "/psn/trophy/v1/users/me/titles/trophyTitles" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "bodyFileName": "psn-trophy-title-sets.json" + } +} diff --git a/local/wiremock/mappings/psn-trophy-titles.json b/local/wiremock/mappings/psn-trophy-titles.json new file mode 100644 index 0000000..67393a5 --- /dev/null +++ b/local/wiremock/mappings/psn-trophy-titles.json @@ -0,0 +1,13 @@ +{ + "request": { + "method": "GET", + "urlPath": "/psn/trophy/v1/users/me/trophyTitles" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "bodyFileName": "psn-trophy-titles.json" + } +} diff --git a/readme.md b/readme.md index 0960f7a..00a0f6d 100644 --- a/readme.md +++ b/readme.md @@ -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. diff --git a/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java index 7956d79..321da6d 100644 --- a/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java +++ b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java @@ -22,7 +22,7 @@ public class AccountCleanupRepository { // Children before user_game (user_game_status_change would also go by its cascade), the tombstones last. static final List TABLES = List.of("user_game_status_change", "user_game_achievement", "user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot", "backlog_order", - "sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "user_psn_title", "user_psn_account", "reward_ledger", "reward_equipped", + "sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_psn_trophy", "user_psn_trophy_set", "user_game", "game_link_request", "account_name", "game_import", "user_psn_title", "user_psn_account", "reward_ledger", "reward_equipped", "account_role_change", "blocked_user", "listed_user", "follow", "follow_digest", "inbox_note", "public_user", "deleted_user"); // Every user with data has a row in one of these; deleteRows then removes the child rows too. diff --git a/src/main/java/core_service/modules/library/api/dto/AchievementDto.java b/src/main/java/core_service/modules/library/api/dto/AchievementDto.java index 42455c1..508214c 100644 --- a/src/main/java/core_service/modules/library/api/dto/AchievementDto.java +++ b/src/main/java/core_service/modules/library/api/dto/AchievementDto.java @@ -1,7 +1,20 @@ package core_service.modules.library.api.dto; +import core_service.modules.library.domain.AchievementKind; +import core_service.modules.library.domain.TrophyType; + import java.time.Instant; +/** + * One Steam achievement or PlayStation trophy. A hidden PlayStation trophy that is not earned has no name and no + * description. + * + * @param apiName Steam: the api name; PSN: the trophy id + * @param setId null for Steam; PSN: the npCommunicationId + * @param trophyType PSN only + * @param group PSN only: null for the base game, "001" and up for DLC + * @param rarity percent of all players who have it; null when unknown + */ public record AchievementDto( String apiName, String displayName, @@ -10,5 +23,10 @@ public record AchievementDto( String iconGrayUrl, boolean hidden, boolean unlocked, - Instant unlockedAt + Instant unlockedAt, + AchievementKind kind, + String setId, + TrophyType trophyType, + String group, + Double rarity ) {} diff --git a/src/main/java/core_service/modules/library/api/dto/AchievementSetDto.java b/src/main/java/core_service/modules/library/api/dto/AchievementSetDto.java new file mode 100644 index 0000000..b4aece9 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/AchievementSetDto.java @@ -0,0 +1,25 @@ +package core_service.modules.library.api.dto; + +import core_service.modules.library.domain.AchievementKind; + +/** + * The achievements of a game come in sets: Steam's, and one per PlayStation trophy set. + * + * @param id "steam", or the npCommunicationId of the trophy set + * @param platform PSN: PS5 or PS4; null for Steam + * @param name PSN: the set's name; null for Steam + * @param perfect all earned, or the platinum trophy + * @param visible false when Steam hides the user's achievements + * @param listed false until the sync has fetched the trophy list of the set + */ +public record AchievementSetDto( + String id, + AchievementKind kind, + String platform, + String name, + int total, + int unlocked, + boolean perfect, + boolean visible, + boolean listed +) {} diff --git a/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java b/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java index 6096b2b..a6d5d8f 100644 --- a/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java +++ b/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java @@ -22,6 +22,7 @@ public record GameDetailDto( Integer rating, String notes, GamePriority priority, + // any set, Steam or PlayStation boolean hasAchievements, boolean statsVisible, int achievementsTotal, @@ -54,7 +55,9 @@ public record GameDetailDto( // see GameDto#coming boolean coming, // see GameDto#comingOn - LocalDate comingOn + LocalDate comingOn, + // Steam's set first, then the PlayStation sets, PS5 before PS4; the counts above are Steam's only + List achievementSets ) { /** diff --git a/src/main/java/core_service/modules/library/api/dto/GameDto.java b/src/main/java/core_service/modules/library/api/dto/GameDto.java index 3905312..4457dea 100644 --- a/src/main/java/core_service/modules/library/api/dto/GameDto.java +++ b/src/main/java/core_service/modules/library/api/dto/GameDto.java @@ -10,6 +10,7 @@ import java.time.LocalDate; * @param timeToBeatMinutes IGDB's normal time to beat, null when unknown * @param achievementsTotal null when the game has no achievements or they were not fetched yet * @param achievementsUnlocked null when not fetched yet or hidden by Steam + * @param achievementsPerfect every achievement unlocked, or the platinum trophy earned; the counts stay the real ones * @param platform where the user owns a game added by hand, e.g. "PS5"; null for Steam games * @param imageUrl a game added by hand from IGDB, or a repaired Steam header; null means the fixed Steam CDN header * @param hidden the user hid the game; it shows only under the library's hidden filter @@ -28,6 +29,7 @@ public record GameDto( Integer timeToBeatMinutes, Integer achievementsUnlocked, Integer achievementsTotal, + boolean achievementsPerfect, GameSource source, String platform, String imageUrl, diff --git a/src/main/java/core_service/modules/library/domain/AchievementKind.java b/src/main/java/core_service/modules/library/domain/AchievementKind.java new file mode 100644 index 0000000..17485d3 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/AchievementKind.java @@ -0,0 +1,3 @@ +package core_service.modules.library.domain; + +public enum AchievementKind { STEAM, PSN } diff --git a/src/main/java/core_service/modules/library/domain/AchievementProgress.java b/src/main/java/core_service/modules/library/domain/AchievementProgress.java index 85e7b50..14212b6 100644 --- a/src/main/java/core_service/modules/library/domain/AchievementProgress.java +++ b/src/main/java/core_service/modules/library/domain/AchievementProgress.java @@ -1,15 +1,26 @@ package core_service.modules.library.domain; +import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; /** - * Achievement state of one owned game. + * Achievement state of one set of one owned game: its Steam achievements or one PlayStation trophy set. * * @param total 0 when the game has no achievements; null when the schema was never fetched * @param unlocked null when the user's achievements were never fetched * @param visible false when Steam hides the user's achievements + * @param platinum the platinum trophy is earned: the set counts as perfect, whatever else is missing */ -public record AchievementProgress(Long appid, Integer total, Integer unlocked, boolean visible) { +public record AchievementProgress(Long appid, Integer total, Integer unlocked, boolean visible, boolean platinum) { + + /** + * The JPQL constructor of the Steam query. + */ + public AchievementProgress(Long appid, Integer total, Integer unlocked, boolean visible) { + this(appid, total, unlocked, visible, false); + } public boolean hasAchievements() { return total != null && total > 0; @@ -23,6 +34,14 @@ public record AchievementProgress(Long appid, Integer total, Integer unlocked, b return unlocked == null || total == null ? 0 : capped(unlocked, total); } + public boolean perfect() { + return platinum || total != null && unlockedCapped() == total; + } + + public double percent() { + return perfect() ? 100.0 : unlockedCapped() * 100.0 / total; + } + /** * A schema can lose achievements after the user unlocked them. */ @@ -35,6 +54,19 @@ public record AchievementProgress(Long appid, Integer total, Integer unlocked, b } public static List perfectAppids(List started) { - return started.stream().filter(g -> g.unlockedCapped() == g.total()).map(AchievementProgress::appid).toList(); + return started.stream().filter(AchievementProgress::perfect).map(AchievementProgress::appid).distinct().toList(); + } + + /** + * One entry per game, the one that counts for it: a perfect set, else the highest percent. + */ + public static List perGame(List sets) { + Comparator best = Comparator.comparing(AchievementProgress::perfect) + .thenComparingDouble(AchievementProgress::percent); + Map games = new LinkedHashMap<>(); + for (AchievementProgress set : sets) { + games.merge(set.appid(), set, (a, b) -> best.compare(b, a) > 0 ? b : a); + } + return games.values().stream().sorted(Comparator.comparing(AchievementProgress::appid)).toList(); } } diff --git a/src/main/java/core_service/modules/library/domain/PsnEarned.java b/src/main/java/core_service/modules/library/domain/PsnEarned.java new file mode 100644 index 0000000..e6fdbb5 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/PsnEarned.java @@ -0,0 +1,12 @@ +package core_service.modules.library.domain; + +import java.time.Instant; + +/** + * One line of the user's trophy list. Every trophy of the set is in it: the rate is known also for the ones not + * earned. + * + * @param earnedAt may be null, also for an earned trophy + * @param earnedRate percent of all players; may be null + */ +public record PsnEarned(int trophyId, boolean earned, Instant earnedAt, Double earnedRate) {} diff --git a/src/main/java/core_service/modules/library/domain/PsnTrophy.java b/src/main/java/core_service/modules/library/domain/PsnTrophy.java new file mode 100644 index 0000000..7bdcd89 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/PsnTrophy.java @@ -0,0 +1,12 @@ +package core_service.modules.library.domain; + +import java.time.Instant; + +/** + * One trophy of a set. The sync writes it without the earned fields; the detail read has them. + * + * @param groupId "default" is the base game, "001" and up are DLC + * @param earnedRate percent of all players; may be null + */ +public record PsnTrophy(String setId, int trophyId, TrophyType type, String name, String detail, String iconUrl, + boolean hidden, String groupId, Double earnedRate, boolean earned, Instant earnedAt) {} diff --git a/src/main/java/core_service/modules/library/domain/PsnTrophySet.java b/src/main/java/core_service/modules/library/domain/PsnTrophySet.java new file mode 100644 index 0000000..0d562c0 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/PsnTrophySet.java @@ -0,0 +1,14 @@ +package core_service.modules.library.domain; + +import java.time.Instant; + +/** + * The summary values of a trophy set for one user (V40), as written by the sync. + * + * @param version trophySetVersion + * @param earnedTotal trophies the user earned + * @param lastUpdatedAt when the user last earned in the set; may be null + */ +public record PsnTrophySet(String id, String serviceName, String version, String name, String iconUrl, + String platform, int definedBronze, int definedSilver, int definedGold, + int definedPlatinum, int earnedTotal, boolean platinumEarned, Instant lastUpdatedAt) {} diff --git a/src/main/java/core_service/modules/library/domain/PsnTrophySetProgress.java b/src/main/java/core_service/modules/library/domain/PsnTrophySetProgress.java new file mode 100644 index 0000000..5cf4ce6 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/PsnTrophySetProgress.java @@ -0,0 +1,9 @@ +package core_service.modules.library.domain; + +/** + * A user's progress in one trophy set, for the detail page. + * + * @param listed true when the trophy rows are stored; false until a sync has fetched them + */ +public record PsnTrophySetProgress(String id, String platform, String name, int total, int earned, + boolean platinumEarned, boolean listed) {} diff --git a/src/main/java/core_service/modules/library/domain/PsnTrophySetState.java b/src/main/java/core_service/modules/library/domain/PsnTrophySetState.java new file mode 100644 index 0000000..18fc1cf --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/PsnTrophySetState.java @@ -0,0 +1,12 @@ +package core_service.modules.library.domain; + +import java.time.Instant; + +/** + * What is stored of a trophy set before a sync run. + * + * @param listVersion version of the stored trophy rows; null when never fetched + * @param earnedTotal null when the user has no row for the set yet + * @param listUpdatedAt the lastUpdatedDateTime the stored earned list has; null when never fetched + */ +public record PsnTrophySetState(String id, String listVersion, Integer earnedTotal, Instant listUpdatedAt) {} diff --git a/src/main/java/core_service/modules/library/domain/TrophyType.java b/src/main/java/core_service/modules/library/domain/TrophyType.java new file mode 100644 index 0000000..7c020e0 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/TrophyType.java @@ -0,0 +1,22 @@ +package core_service.modules.library.domain; + +import java.util.Optional; + +public enum TrophyType { + BRONZE, SILVER, GOLD, PLATINUM; + + /** + * @param psnValue as PlayStation spells it, any case + */ + public static Optional of(String psnValue) { + if (psnValue == null) { + return Optional.empty(); + } + for (TrophyType type : values()) { + if (type.name().equalsIgnoreCase(psnValue)) { + return Optional.of(type); + } + } + return Optional.empty(); + } +} diff --git a/src/main/java/core_service/modules/library/repo/PsnTrophyRepository.java b/src/main/java/core_service/modules/library/repo/PsnTrophyRepository.java new file mode 100644 index 0000000..2613a6e --- /dev/null +++ b/src/main/java/core_service/modules/library/repo/PsnTrophyRepository.java @@ -0,0 +1,253 @@ +package core_service.modules.library.repo; + +import core_service.modules.library.domain.AchievementProgress; +import core_service.modules.library.domain.PsnEarned; +import core_service.modules.library.domain.PsnTrophy; +import core_service.modules.library.domain.PsnTrophySet; +import core_service.modules.library.domain.PsnTrophySetProgress; +import core_service.modules.library.domain.PsnTrophySetState; +import core_service.modules.library.domain.TrophyType; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * PlayStation trophies (V40): the global sets and trophies and what each user earned. + */ +@Repository +@RequiredArgsConstructor +public class PsnTrophyRepository { + + private final NamedParameterJdbcTemplate jdbc; + + /** + * @param recheckBefore a "no set" answer checked before this time is asked again + * @return title id to set id, null for a title PlayStation has no set for; a title missing from the map must + * be asked + */ + public Map findTitleSets(Collection titleIds, Instant recheckBefore) { + Map sets = new HashMap<>(); + if (titleIds.isEmpty()) { + return sets; + } + jdbc.query(""" + select title_id, np_communication_id from psn_title_trophy_set + where title_id in (:ids) and (np_communication_id is not null or checked_at >= :before)""", + new MapSqlParameterSource("ids", titleIds).addValue("before", Timestamp.from(recheckBefore)), + rs -> { sets.put(rs.getString("title_id"), rs.getString("np_communication_id")); }); + return sets; + } + + /** + * @param asked every title id that was asked + * @param found title id to set id of the answers; an asked id without an answer is stored as "no set" + */ + public void saveTitleSets(Collection asked, Map found) { + if (asked.isEmpty()) { + return; + } + jdbc.batchUpdate(""" + insert into psn_title_trophy_set (title_id, np_communication_id) values (:title, :set) + on conflict (title_id) do update set np_communication_id = excluded.np_communication_id, + checked_at = now()""", + asked.stream().map(title -> new MapSqlParameterSource("title", title).addValue("set", found.get(title))) + .toArray(MapSqlParameterSource[]::new)); + } + + /** + * A set without a user row has {@code earnedTotal} null; a set never stored is missing. + */ + public Map findStates(long userId, Collection setIds) { + Map states = new HashMap<>(); + if (setIds.isEmpty()) { + return states; + } + jdbc.query(""" + select s.np_communication_id, s.list_version, u.earned_total, u.list_updated_at + from psn_trophy_set s + left join user_psn_trophy_set u on u.np_communication_id = s.np_communication_id and u.user_id = :user + where s.np_communication_id in (:ids)""", + new MapSqlParameterSource("user", userId).addValue("ids", setIds), rs -> { + Timestamp listUpdatedAt = rs.getTimestamp("list_updated_at"); + states.put(rs.getString("np_communication_id"), new PsnTrophySetState( + rs.getString("np_communication_id"), rs.getString("list_version"), + rs.getObject("earned_total", Integer.class), + listUpdatedAt == null ? null : listUpdatedAt.toInstant())); + }); + return states; + } + + /** + * The summary of one set: the global row (not its list version) and the user's counts (not their list marker). + */ + public void saveSet(long userId, long appid, PsnTrophySet set) { + jdbc.update(""" + insert into psn_trophy_set (np_communication_id, service_name, platform, name, icon_url, set_version, + defined_bronze, defined_silver, defined_gold, defined_platinum) + values (:id, :service, :platform, :name, :icon, :version, :bronze, :silver, :gold, :platinum) + on conflict (np_communication_id) do update set service_name = excluded.service_name, + platform = excluded.platform, name = excluded.name, icon_url = excluded.icon_url, + set_version = excluded.set_version, defined_bronze = excluded.defined_bronze, + defined_silver = excluded.defined_silver, defined_gold = excluded.defined_gold, + defined_platinum = excluded.defined_platinum, refreshed_at = now()""", + new MapSqlParameterSource("id", set.id()).addValue("service", set.serviceName()) + .addValue("platform", set.platform()).addValue("name", set.name()) + .addValue("icon", set.iconUrl()).addValue("version", set.version()) + .addValue("bronze", set.definedBronze()).addValue("silver", set.definedSilver()) + .addValue("gold", set.definedGold()).addValue("platinum", set.definedPlatinum())); + jdbc.update(""" + insert into user_psn_trophy_set (user_id, np_communication_id, appid, earned_total, platinum_earned, + last_updated_at) + values (:user, :id, :appid, :earned, :platinum, :updated) + on conflict (user_id, np_communication_id) do update set appid = excluded.appid, + earned_total = excluded.earned_total, platinum_earned = excluded.platinum_earned, + last_updated_at = excluded.last_updated_at""", + new MapSqlParameterSource("user", userId).addValue("id", set.id()).addValue("appid", appid) + .addValue("earned", set.earnedTotal()).addValue("platinum", set.platinumEarned()) + .addValue("updated", timestamp(set.lastUpdatedAt()))); + } + + /** + * Writes the trophies of a set and the version they have. The earned rates stay; a trophy PlayStation dropped + * goes. + */ + public void replaceTrophies(String setId, String version, List trophies) { + jdbc.batchUpdate(""" + insert into psn_trophy (np_communication_id, trophy_id, type, name, detail, icon_url, hidden, group_id) + values (:set, :trophy, :type, :name, :detail, :icon, :hidden, :group) + on conflict (np_communication_id, trophy_id) do update set type = excluded.type, name = excluded.name, + detail = excluded.detail, icon_url = excluded.icon_url, hidden = excluded.hidden, + group_id = excluded.group_id""", + trophies.stream().map(trophy -> new MapSqlParameterSource("set", setId) + .addValue("trophy", trophy.trophyId()).addValue("type", trophy.type().name()) + .addValue("name", trophy.name()).addValue("detail", trophy.detail()) + .addValue("icon", trophy.iconUrl()).addValue("hidden", trophy.hidden()) + .addValue("group", trophy.groupId())).toArray(MapSqlParameterSource[]::new)); + List ids = trophies.stream().map(PsnTrophy::trophyId).toList(); + if (ids.isEmpty()) { + jdbc.update("delete from psn_trophy where np_communication_id = :set", new MapSqlParameterSource("set", setId)); + } else { + jdbc.update("delete from psn_trophy where np_communication_id = :set and trophy_id not in (:ids)", + new MapSqlParameterSource("set", setId).addValue("ids", ids)); + } + jdbc.update("update psn_trophy_set set list_version = :version where np_communication_id = :set", + new MapSqlParameterSource("set", setId).addValue("version", version)); + } + + /** + * Replaces the user's earned trophies of a set and stores the rates; {@code listUpdatedAt} marks the state the + * list has. + */ + public void replaceEarned(long userId, String setId, Instant listUpdatedAt, List earned) { + jdbc.update("delete from user_psn_trophy where user_id = :user and np_communication_id = :set", + new MapSqlParameterSource("user", userId).addValue("set", setId)); + jdbc.batchUpdate(""" + insert into user_psn_trophy (user_id, np_communication_id, trophy_id, earned_at) + values (:user, :set, :trophy, :at)""", + earned.stream().filter(PsnEarned::earned).map(trophy -> new MapSqlParameterSource("user", userId) + .addValue("set", setId).addValue("trophy", trophy.trophyId()) + .addValue("at", timestamp(trophy.earnedAt()))).toArray(MapSqlParameterSource[]::new)); + jdbc.batchUpdate(""" + update psn_trophy set earned_rate = :rate where np_communication_id = :set and trophy_id = :trophy""", + earned.stream().filter(trophy -> trophy.earnedRate() != null) + .map(trophy -> new MapSqlParameterSource("set", setId).addValue("trophy", trophy.trophyId()) + .addValue("rate", trophy.earnedRate())).toArray(MapSqlParameterSource[]::new)); + jdbc.update(""" + update user_psn_trophy_set set list_updated_at = :updated + where user_id = :user and np_communication_id = :set""", + new MapSqlParameterSource("user", userId).addValue("set", setId) + .addValue("updated", timestamp(listUpdatedAt))); + } + + public void deleteUserSets(long userId, Collection setIds) { + if (setIds.isEmpty()) { + return; + } + MapSqlParameterSource params = new MapSqlParameterSource("user", userId).addValue("ids", setIds); + jdbc.update("delete from user_psn_trophy where user_id = :user and np_communication_id in (:ids)", params); + jdbc.update("delete from user_psn_trophy_set where user_id = :user and np_communication_id in (:ids)", params); + } + + /** + * Removes the sets the account no longer has: another account was linked. + * + * @param keepSetIds the sets the account lists now; must not be empty + */ + public void deleteUserSetsExcept(long userId, Collection keepSetIds) { + if (keepSetIds.isEmpty()) { + return; + } + MapSqlParameterSource params = new MapSqlParameterSource("user", userId).addValue("ids", keepSetIds); + jdbc.update("delete from user_psn_trophy where user_id = :user and np_communication_id not in (:ids)", params); + jdbc.update("delete from user_psn_trophy_set where user_id = :user and np_communication_id not in (:ids)", params); + } + + public void deleteUser(long userId) { + MapSqlParameterSource params = new MapSqlParameterSource("user", userId); + jdbc.update("delete from user_psn_trophy where user_id = :user", params); + jdbc.update("delete from user_psn_trophy_set where user_id = :user", params); + } + + /** + * One entry per set of a listed game: the defined trophies are the total, the earned ones the unlocked count. + */ + public List findProgress(long userId) { + return jdbc.query(""" + select u.appid, s.defined_bronze + s.defined_silver + s.defined_gold + s.defined_platinum as total, + u.earned_total, u.platinum_earned + from user_psn_trophy_set u + join psn_trophy_set s on s.np_communication_id = u.np_communication_id + join listed_user_game ug on ug.user_id = u.user_id and ug.appid = u.appid + where u.user_id = :user""", new MapSqlParameterSource("user", userId), + (rs, i) -> new AchievementProgress(rs.getLong("appid"), rs.getInt("total"), rs.getInt("earned_total"), + true, rs.getBoolean("platinum_earned"))); + } + + public List findSets(long userId, long appid) { + return jdbc.query(""" + select s.np_communication_id, s.platform, s.name, + s.defined_bronze + s.defined_silver + s.defined_gold + s.defined_platinum as total, + u.earned_total, u.platinum_earned, s.list_version is not null and (u.earned_total = 0 or u.list_updated_at is not null) as listed + from user_psn_trophy_set u + join psn_trophy_set s on s.np_communication_id = u.np_communication_id + where u.user_id = :user and u.appid = :appid + order by s.platform desc, s.name, s.np_communication_id""", + new MapSqlParameterSource("user", userId).addValue("appid", appid), + (rs, i) -> new PsnTrophySetProgress(rs.getString("np_communication_id"), rs.getString("platform"), + rs.getString("name"), rs.getInt("total"), rs.getInt("earned_total"), + rs.getBoolean("platinum_earned"), rs.getBoolean("listed"))); + } + + public List findTrophies(long userId, long appid) { + return jdbc.query(""" + select t.np_communication_id, t.trophy_id, t.type, t.name, t.detail, t.icon_url, t.hidden, t.group_id, + t.earned_rate, e.trophy_id is not null as earned, e.earned_at + from user_psn_trophy_set u + join psn_trophy t on t.np_communication_id = u.np_communication_id + left join user_psn_trophy e on e.user_id = u.user_id and e.np_communication_id = t.np_communication_id + and e.trophy_id = t.trophy_id + where u.user_id = :user and u.appid = :appid + order by t.np_communication_id, t.group_id = 'default' desc, t.group_id, t.trophy_id""", + new MapSqlParameterSource("user", userId).addValue("appid", appid), + (rs, i) -> { + Timestamp earnedAt = rs.getTimestamp("earned_at"); + return new PsnTrophy(rs.getString("np_communication_id"), rs.getInt("trophy_id"), + TrophyType.valueOf(rs.getString("type")), rs.getString("name"), rs.getString("detail"), + rs.getString("icon_url"), rs.getBoolean("hidden"), rs.getString("group_id"), + rs.getObject("earned_rate", Double.class), rs.getBoolean("earned"), + earnedAt == null ? null : earnedAt.toInstant()); + }); + } + + private static Timestamp timestamp(Instant instant) { + return instant == null ? null : Timestamp.from(instant); + } +} diff --git a/src/main/java/core_service/modules/library/service/GameDetailService.java b/src/main/java/core_service/modules/library/service/GameDetailService.java index 53c1e12..875e18f 100644 --- a/src/main/java/core_service/modules/library/service/GameDetailService.java +++ b/src/main/java/core_service/modules/library/service/GameDetailService.java @@ -1,10 +1,12 @@ package core_service.modules.library.service; import core_service.modules.library.api.dto.AchievementDto; +import core_service.modules.library.api.dto.AchievementSetDto; import core_service.modules.library.api.dto.CorrectionMarkDto; import core_service.modules.library.api.dto.EditionDto; import core_service.modules.library.api.dto.FinishedHintDto; import core_service.modules.library.api.dto.GameDetailDto; +import core_service.modules.library.domain.AchievementKind; import core_service.modules.library.domain.AchievementProgress; import core_service.modules.library.domain.ComingGame; import core_service.modules.library.domain.GameAchievementSchema; @@ -12,6 +14,8 @@ import core_service.modules.library.domain.GameAchievementSchemaMeta; import core_service.modules.library.domain.FinishedHint; import core_service.modules.library.domain.GameMoods; import core_service.modules.library.domain.GameTimeToBeat; +import core_service.modules.library.domain.PsnTrophy; +import core_service.modules.library.domain.PsnTrophySetProgress; import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.GameSpeedrun; import core_service.modules.library.domain.SteamGame; @@ -28,6 +32,7 @@ import core_service.modules.library.repo.GameMoodsRepository; import core_service.modules.library.repo.GameTimeToBeatRepository; import core_service.modules.library.repo.GameAchievementSchemaRepository; import core_service.modules.library.repo.GameSpeedrunRepository; +import core_service.modules.library.repo.PsnTrophyRepository; import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.UserGameAchievementMetaRepository; import core_service.modules.library.repo.UserGameAchievementRepository; @@ -40,12 +45,14 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import java.time.Duration; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; +import java.util.stream.Stream; @Service @RequiresRole(Role.USER) @@ -73,6 +80,7 @@ public class GameDetailService { private final GameCorrectionRepository gameCorrectionRepository; private final GameLinkRepository gameLinkRepository; private final GameMergeRepository mergeRepository; + private final PsnTrophyRepository psnTrophyRepository; @Value("${app.library.achievement-ttl-hours:1}") private long achievementTtlHours; @@ -156,8 +164,10 @@ public class GameDetailService { // Reads only. private GameDetailDto assemble(UserGame userGame, SteamGame meta, boolean hasSchema) { + List psnSets = psnSets(userGame); + List psnTrophies = psnSets.isEmpty() ? List.of() : psnTrophies(userGame); if (!hasSchema) { - return buildDto(userGame, meta, true, 0, 0, List.of(), List.of()); + return buildDto(userGame, meta, true, 0, 0, List.of(), List.of(), psnSets, psnTrophies); } Long userId = userGame.getUserId(); Long appid = userGame.getAppid(); @@ -183,17 +193,41 @@ public class GameDetailService { s.getIconGrayUrl(), s.isHidden(), isUnlocked, - u != null ? u.getUnlockedAt() : null + u != null ? u.getUnlockedAt() : null, + AchievementKind.STEAM, null, null, null, s.getGlobalPercent() ); }) .toList(); return buildDto(userGame, meta, statsVisible, schemaList.size(), - AchievementProgress.capped(unlocked, schemaList.size()), achievementDtos, schemaList); + AchievementProgress.capped(unlocked, schemaList.size()), achievementDtos, schemaList, psnSets, psnTrophies); + } + + private List psnSets(UserGame userGame) { + return psnTrophyRepository.findSets(userGame.getUserId(), userGame.getAppid()).stream() + .map(set -> new AchievementSetDto(set.id(), AchievementKind.PSN, set.platform(), set.name(), set.total(), + AchievementProgress.capped(set.earned(), set.total()), + set.platinumEarned() || set.earned() >= set.total(), true, set.listed())) + .toList(); + } + + private List psnTrophies(UserGame userGame) { + return psnTrophyRepository.findTrophies(userGame.getUserId(), userGame.getAppid()).stream() + .map(GameDetailService::toDto).toList(); + } + + // A hidden trophy keeps its name and description from the user until it is earned. + private static AchievementDto toDto(PsnTrophy trophy) { + boolean secret = trophy.hidden() && !trophy.earned(); + return new AchievementDto(String.valueOf(trophy.trophyId()), secret ? null : trophy.name(), + secret ? null : trophy.detail(), trophy.iconUrl(), trophy.iconUrl(), trophy.hidden(), trophy.earned(), + trophy.earnedAt(), AchievementKind.PSN, trophy.setId(), trophy.type(), + "default".equals(trophy.groupId()) ? null : trophy.groupId(), trophy.earnedRate()); } private GameDetailDto buildDto(UserGame userGame, SteamGame meta, boolean statsVisible, int total, int unlocked, - List achievements, List schema) { + List achievements, List schema, + List psnSets, List psnTrophies) { Optional timeToBeat = gameTimeToBeatRepository.findById(userGame.getAppid()); Set unlockedNames = achievements.stream().filter(AchievementDto::unlocked).map(AchievementDto::apiName) .collect(Collectors.toSet()); @@ -206,7 +240,13 @@ public class GameDetailService { .map(hint -> new FinishedHintDto(hint.type(), hint.achievement())) .orElse(null); Optional coming = comingGameRepository.findById(userGame.getAppid()); - boolean hasAchievements = total > 0; + boolean hasAchievements = total > 0 || !psnSets.isEmpty(); + List sets = new ArrayList<>(); + if (total > 0) { + sets.add(new AchievementSetDto("steam", AchievementKind.STEAM, null, null, total, unlocked, + unlocked >= total, statsVisible, true)); + } + sets.addAll(psnSets); int remaining = Math.max(0, total - unlocked); double pct = total == 0 ? 0.0 : (unlocked * 100.0 / total); return new GameDetailDto( @@ -228,7 +268,7 @@ public class GameDetailService { unlocked, remaining, Math.round(pct * 10.0) / 10.0, - achievements, + Stream.concat(achievements.stream(), psnTrophies.stream()).toList(), timeToBeat .map(t -> new GameDetailDto.TimeToBeatDto(t.getHastilyMinutes(), t.getNormallyMinutes(), t.getCompletelyMinutes())) .orElse(null), @@ -248,7 +288,8 @@ public class GameDetailService { .map(e -> new EditionDto(e.appid(), e.name(), e.platform(), e.source(), e.status(), e.imageUrl())) .toList(), coming.isPresent(), - coming.map(ComingGame::getReleaseDate).orElse(null) + coming.map(ComingGame::getReleaseDate).orElse(null), + sets ); } } diff --git a/src/main/java/core_service/modules/library/service/LibraryQueryService.java b/src/main/java/core_service/modules/library/service/LibraryQueryService.java index 1c9193d..4b9aa3e 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -36,6 +36,7 @@ import core_service.modules.library.repo.GameCreditRepository; import core_service.modules.library.repo.GameLinkRepository; import core_service.modules.library.repo.GameTimeToBeatRepository; import core_service.modules.library.repo.PlaytimeDayRepository; +import core_service.modules.library.repo.PsnTrophyRepository; import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.UserGameAchievementMetaRepository; import core_service.modules.library.repo.UserGameRepository; @@ -100,6 +101,7 @@ public class LibraryQueryService { private final GameLinkRepository gameLinkRepository; private final GameCreditRepository gameCreditRepository; private final FriendPlayRepository friendPlayRepository; + private final PsnTrophyRepository psnTrophyRepository; @Value("${app.library.zone:Europe/Berlin}") private ZoneId zone; @@ -273,10 +275,13 @@ public class LibraryQueryService { .collect(Collectors.toMap(UserGameAchievementMeta::getAppid, m -> m)); Map editions = gameLinkRepository.editionCounts(games.getFirst().getUserId(), appids); Map coming = comingOf(appids); + Map trophies = AchievementProgress + .perGame(psnTrophyRepository.findProgress(games.getFirst().getUserId())).stream() + .collect(Collectors.toMap(AchievementProgress::appid, t -> t)); return games.stream() .map(ug -> toDto(ug, meta.get(ug.getAppid()), times.get(ug.getAppid()), schemas.get(ug.getAppid()), unlocks.get(ug.getAppid()), editions.get(ug.getAppid()), - coming.get(ug.getAppid()))) + coming.get(ug.getAppid()), trophies.get(ug.getAppid()))) .toList(); } @@ -285,11 +290,12 @@ public class LibraryQueryService { } public AchievementSummaryDto getAchievementSummary(Long userId) { - List games = userGameRepository.findAchievementProgress(userId); + List games = progress(userId); List started = AchievementProgress.started(games); - List closest = started.stream() - .filter(g -> g.unlockedCapped() < g.total()) - .sorted(Comparator.comparingDouble((AchievementProgress g) -> (double) g.unlockedCapped() / g.total()) + List perGame = AchievementProgress.perGame(started); + List closest = perGame.stream() + .filter(g -> !g.perfect()) + .sorted(Comparator.comparingDouble(AchievementProgress::percent) .reversed() .thenComparingInt(g -> g.total() - g.unlockedCapped()) .thenComparing(AchievementProgress::appid)) @@ -299,9 +305,9 @@ public class LibraryQueryService { .stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g)); return new AchievementSummaryDto( started.stream().mapToInt(AchievementProgress::unlockedCapped).sum(), - started.size(), + perGame.size(), AchievementProgress.perfectAppids(started).size(), - averageCompletion(started), + averageCompletion(perGame), (int) games.stream().filter(g -> !g.known()).count(), (int) games.stream().filter(g -> g.hasAchievements() && g.unlocked() != null && !g.visible()).count(), closest.stream() @@ -312,9 +318,15 @@ public class LibraryQueryService { .toList()); } - private static Double averageCompletion(List started) { - if (started.isEmpty()) return null; - double mean = started.stream().mapToDouble(g -> g.unlockedCapped() * 100.0 / g.total()).average().orElse(0); + // Steam achievements and PlayStation trophies; one entry per set. + private List progress(Long userId) { + return Stream.concat(userGameRepository.findAchievementProgress(userId).stream(), + psnTrophyRepository.findProgress(userId).stream()).toList(); + } + + private static Double averageCompletion(List perGame) { + if (perGame.isEmpty()) return null; + double mean = perGame.stream().mapToDouble(AchievementProgress::percent).average().orElse(0); return Math.round(mean * 10.0) / 10.0; } @@ -383,7 +395,7 @@ public class LibraryQueryService { Map meta = steamGameRepository.findAllById(shownAppids) .stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g)); Map coming = comingOf(shownAppids); - List started = summary ? AchievementProgress.started(userGameRepository.findAchievementProgress(userId)) : null; + List started = summary ? AchievementProgress.started(progress(userId)) : null; Set shared = sections.contains(ProfileSection.STATS) ? StatGroup.shared(profileStats) : Set.of(); return new PublicLibraryDto( summary ? byStatus.values().stream().mapToLong(Long::longValue).sum() : null, @@ -392,7 +404,7 @@ public class LibraryQueryService { sections.contains(ProfileSection.STATUS) ? byStatus : null, toPublicGames(playing, meta, coming), toPublicGames(finished, meta, coming), summary ? AchievementProgress.perfectAppids(started).size() : null, - summary ? averageCompletion(started) : null, + summary ? averageCompletion(AchievementProgress.perGame(started)) : null, shared.isEmpty() ? null : userStatsService.getStats(userId, shared)); } @@ -423,11 +435,17 @@ public class LibraryQueryService { private static GameDto toDto(UserGame ug, SteamGame meta, GameTimeToBeat time, GameAchievementSchemaMeta schema, UserGameAchievementMeta unlocks, - Integer editions, ComingGame coming) { + Integer editions, ComingGame coming, AchievementProgress trophies) { Integer achievementsTotal = ug.getSource() == GameSource.STEAM && schema != null && schema.isHasSchema() && schema.getAchievementsTotal() > 0 ? schema.getAchievementsTotal() : null; Integer achievementsUnlocked = achievementsTotal != null && unlocks != null && unlocks.isStatsVisible() ? AchievementProgress.capped(unlocks.getAchievementsUnlocked(), achievementsTotal) : null; + boolean perfect = achievementsTotal != null && achievementsTotal.equals(achievementsUnlocked); + if (trophies != null && trophiesCount(ug, achievementsTotal, achievementsUnlocked, trophies)) { + achievementsTotal = trophies.total(); + achievementsUnlocked = trophies.unlockedCapped(); + perfect = trophies.perfect(); + } return new GameDto( ug.getAppid(), meta != null ? meta.getName() : null, @@ -439,6 +457,7 @@ public class LibraryQueryService { time != null ? time.getNormallyMinutes() : null, achievementsUnlocked, achievementsTotal, + perfect, ug.getSource(), ug.getPlatform(), meta != null ? meta.getImageUrl() : null, @@ -448,6 +467,16 @@ public class LibraryQueryService { coming != null ? coming.getReleaseDate() : null); } + // The tile shows what counts for the game: its best set, Steam's on a tie. + private static boolean trophiesCount(UserGame ug, Integer steamTotal, Integer steamUnlocked, + AchievementProgress trophies) { + if (steamTotal == null || steamUnlocked == null) { + return true; + } + AchievementProgress steam = new AchievementProgress(ug.getAppid(), steamTotal, steamUnlocked, true); + return AchievementProgress.perGame(List.of(steam, trophies)).getFirst() == trophies; + } + public Optional getSyncStatus(Long userId) { return userSteamSyncRepository.findById(userId); } diff --git a/src/main/java/core_service/modules/library/service/PsnAccountService.java b/src/main/java/core_service/modules/library/service/PsnAccountService.java index c9351b5..d241df4 100644 --- a/src/main/java/core_service/modules/library/service/PsnAccountService.java +++ b/src/main/java/core_service/modules/library/service/PsnAccountService.java @@ -4,6 +4,7 @@ import core_service.modules.library.domain.GameImport; import core_service.modules.library.domain.PsnAccount; import core_service.modules.library.repo.GameImportRepository; import core_service.modules.library.repo.PsnAccountRepository; +import core_service.modules.library.repo.PsnTrophyRepository; import core_service.modules.psn_adapter.service.PsnApiService; import core_service.modules.psn_adapter.service.PsnAuthService; import core_service.shared.security.RequiresRole; @@ -54,7 +55,15 @@ public class PsnAccountService { } public sealed interface FetchResult { - record Titles(List titles) implements FetchResult {} + /** + * @param accessToken valid for about an hour; the sync reuses it for the trophy calls + */ + record Titles(List titles, String accessToken) implements FetchResult { + @Override + public String toString() { + return "Titles[titles=" + titles.size() + "]"; + } + } record NotLinked() implements FetchResult {} @@ -77,18 +86,21 @@ public class PsnAccountService { private final PsnAuthService authService; private final PsnApiService apiService; private final GameImportRepository importRepository; + private final PsnTrophyRepository trophyRepository; private final Duration warningTime; private final SecretBox box; // One PlayStation call per user at a time: parallel calls of one user must not hold the shared rate limiter. private final Set inFlight = ConcurrentHashMap.newKeySet(); public PsnAccountService(PsnAccountRepository repository, PsnAuthService authService, PsnApiService apiService, - GameImportRepository importRepository, @Value("${app.psn.token-key:}") String tokenKey, + GameImportRepository importRepository, PsnTrophyRepository trophyRepository, + @Value("${app.psn.token-key:}") String tokenKey, @Value("${app.psn.expiry-warning-days:2}") long warningDays) { this.repository = repository; this.authService = authService; this.apiService = apiService; this.importRepository = importRepository; + this.trophyRepository = trophyRepository; this.warningTime = Duration.ofDays(warningDays); // A wrong key stops Core at start; no key only switches the feature off. this.box = tokenKey == null || tokenKey.isBlank() ? null : SecretBox.fromBase64Key(tokenKey); @@ -138,6 +150,8 @@ public class PsnAccountService { } public void unlink(Long userId) { + // The trophies came with this account; another account linked later must start from nothing. + trophyRepository.deleteUser(userId); if (repository.delete(userId) > 0) { log.info("PSN account unlinked for user={}", userId); } @@ -191,7 +205,8 @@ public class PsnAccountService { if (!tokens.get().refreshToken().equals(stored)) { repository.updateToken(userId, box.seal(tokens.get().refreshToken()), tokens.get().refreshExpiresAt()); } - return new FetchResult.Titles(apiService.getPlayedTitles(tokens.get().accessToken())); + return new FetchResult.Titles(apiService.getPlayedTitles(tokens.get().accessToken()), + tokens.get().accessToken()); } catch (RestClientException | RequestNotPermitted e) { logFailure("PSN fetch failed", e); return new FetchResult.Unavailable(); diff --git a/src/main/java/core_service/modules/library/service/PsnSyncStep.java b/src/main/java/core_service/modules/library/service/PsnSyncStep.java index 1b5400c..4cc3797 100644 --- a/src/main/java/core_service/modules/library/service/PsnSyncStep.java +++ b/src/main/java/core_service/modules/library/service/PsnSyncStep.java @@ -33,8 +33,8 @@ import java.util.Set; import java.util.stream.Collectors; /** - * The PlayStation part of the library sync. {@link #run} calls PlayStation and IGDB and adds the games, outside the - * sync transaction; {@link #apply} writes the playtime inside it. PlayStation never fails the Steam part. + * The PlayStation part of the library sync. {@link #run} calls PlayStation and IGDB, adds the games and reads the + * trophies, outside the sync transaction; {@link #apply} writes the playtime inside it. PlayStation never fails the Steam part. */ @Service @Slf4j @@ -60,6 +60,7 @@ public class PsnSyncStep { } private final PsnAccountService accountService; + private final PsnTrophyStep trophyStep; private final PsnTitleRepository psnTitleRepository; private final GameImportRepository importRepository; private final GameMergeRepository mergeRepository; @@ -68,11 +69,12 @@ public class PsnSyncStep { private final PlaytimeDayRepository playtimeDayRepository; private final TransactionTemplate transaction; - public PsnSyncStep(PsnAccountService accountService, PsnTitleRepository psnTitleRepository, + public PsnSyncStep(PsnAccountService accountService, PsnTrophyStep trophyStep, PsnTitleRepository psnTitleRepository, GameImportRepository importRepository, GameMergeRepository mergeRepository, ManualGameService manualGameService, UserGameRepository userGameRepository, PlaytimeDayRepository playtimeDayRepository, PlatformTransactionManager transactionManager) { this.accountService = accountService; + this.trophyStep = trophyStep; this.psnTitleRepository = psnTitleRepository; this.importRepository = importRepository; this.mergeRepository = mergeRepository; @@ -90,12 +92,13 @@ public class PsnSyncStep { List playtimes = new ArrayList<>(); boolean baseline = false; try { - Optional> titles = fetch(userId); - if (titles.isEmpty()) { + Optional found = fetch(userId); + if (found.isEmpty()) { return Prepared.NONE; } - List lines = PsnTitles.toLines(titles.get()); - Map lastPlayed = PsnTitles.lastPlayedByKey(titles.get()); + List titles = found.get().titles(); + List lines = PsnTitles.toLines(titles); + Map lastPlayed = PsnTitles.lastPlayedByKey(titles); Map known = psnTitleRepository.findAll(userId); baseline = known.isEmpty(); Set open = importRepository.findOpenPsnKeys(userId); @@ -109,7 +112,10 @@ public class PsnSyncStep { } } saveReview(userId, review); - log.info("PSN sync user={} added {} titles, {} wait for review", userId, changes.size(), review.size()); + int added = changes.size(); + changes.addAll(trophyStep.run(userId, found.get().accessToken(), titles)); + log.info("PSN sync user={} added {} titles, {} wait for review, {} games gained trophies", userId, added, + review.size(), changes.size() - added); return new Prepared(changes, mergedKeys(playtimes), baseline); } catch (RuntimeException e) { log.error("PSN step failed for user {}", userId, e); @@ -117,10 +123,10 @@ public class PsnSyncStep { } } - private Optional> fetch(Long userId) { + private Optional fetch(Long userId) { switch (accountService.fetchTitles(userId)) { case PsnAccountService.FetchResult.Titles found -> { - return Optional.of(found.titles()); + return Optional.of(found); } case PsnAccountService.FetchResult.Relink _ -> log.info("PSN sync skipped for user={}: link expired", userId); diff --git a/src/main/java/core_service/modules/library/service/PsnTitles.java b/src/main/java/core_service/modules/library/service/PsnTitles.java index bb731d7..8e19489 100644 --- a/src/main/java/core_service/modules/library/service/PsnTitles.java +++ b/src/main/java/core_service/modules/library/service/PsnTitles.java @@ -74,9 +74,15 @@ final class PsnTitles { private static Map> group(List titles) { Map> groups = new LinkedHashMap<>(); for (PsnApiService.Title title : titles) { - String key = title.conceptId() != null ? "c" + title.conceptId() : "t" + title.titleId(); - groups.computeIfAbsent(key, k -> new ArrayList<>()).add(title); + groups.computeIfAbsent(key(title), k -> new ArrayList<>()).add(title); } return groups; } + + /** + * The library key of a title: its concept, so PS4 and PS5 versions share it. + */ + static String key(PsnApiService.Title title) { + return title.conceptId() != null ? "c" + title.conceptId() : "t" + title.titleId(); + } } diff --git a/src/main/java/core_service/modules/library/service/PsnTrophies.java b/src/main/java/core_service/modules/library/service/PsnTrophies.java new file mode 100644 index 0000000..50ce95a --- /dev/null +++ b/src/main/java/core_service/modules/library/service/PsnTrophies.java @@ -0,0 +1,145 @@ +package core_service.modules.library.service; + +import core_service.modules.library.domain.PsnEarned; +import core_service.modules.library.domain.PsnTitle; +import core_service.modules.library.domain.PsnTrophy; +import core_service.modules.library.domain.PsnTrophySet; +import core_service.modules.library.domain.TrophyType; +import core_service.modules.psn_adapter.service.PsnApiService; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Maps PlayStation trophy sets to library games and turns PlayStation values into stored ones. Only the user's own + * linked titles can map a set; a set is never matched across users or by name alone. + */ +final class PsnTrophies { + + private static final int MAX_NAME_LENGTH = 512; + private static final int MAX_DETAIL_LENGTH = 2048; + private static final int MAX_URL_LENGTH = 1024; + private static final int MAX_GROUP_LENGTH = 8; + + private PsnTrophies() { + } + + /** + * Exact: the set PlayStation names for a linked title id, then by name for the sets left. + * + * @return set id to library key; a set with two candidate games is left out + */ + static Map setAppids(List titles, Map links, + Map setIdByTitleId, List sets) { + Map mapped = byTitleId(titles, links, setIdByTitleId); + mapped.putAll(byName(titles, links, sets, mapped.keySet())); + return mapped; + } + + /** + * @param setIdByTitleId title id to set id; values may be null + */ + static Map byTitleId(List titles, Map links, + Map setIdByTitleId) { + Map mapped = new HashMap<>(); + Set conflicts = new HashSet<>(); + for (PsnApiService.Title title : titles) { + String setId = setIdByTitleId.get(title.titleId()); + Long appid = linkedAppid(links, title); + if (setId != null && appid != null) { + collect(mapped, conflicts, setId, appid); + } + } + conflicts.forEach(mapped::remove); + return mapped; + } + + /** + * The sets not in {@code skip} whose clean name and platform match exactly one game of the user's linked titles. + */ + static Map byName(List titles, Map links, + List sets, Set skip) { + Map mapped = new HashMap<>(); + Set conflicts = new HashSet<>(); + for (PsnApiService.TrophySet set : sets) { + if (skip.contains(set.npCommunicationId())) { + continue; + } + for (PsnApiService.Title title : titles) { + Long appid = linkedAppid(links, title); + if (appid != null && onPlatform(set, PsnTitles.platform(title.category())) + && GameImportWorker.sameName(PsnTitles.cleanName(title.name()), PsnTitles.cleanName(set.name()))) { + collect(mapped, conflicts, set.npCommunicationId(), appid); + } + } + } + conflicts.forEach(mapped::remove); + return mapped; + } + + static boolean inScope(PsnApiService.TrophySet set) { + return !set.hidden() && (onPlatform(set, "PS4") || onPlatform(set, "PS5")); + } + + static PsnTrophySet toSet(PsnApiService.TrophySet set) { + PsnApiService.Counts defined = set.defined(); + return new PsnTrophySet(set.npCommunicationId(), serviceName(set), set.version(), + cut(set.name(), MAX_NAME_LENGTH), httpsOrNull(set.iconUrl()), onPlatform(set, "PS5") ? "PS5" : "PS4", + defined.bronze(), defined.silver(), defined.gold(), defined.platinum(), set.earned().total(), + set.earned().platinum() > 0, set.lastUpdatedAt()); + } + + /** + * trophy2 is PS5, trophy is PS4; PlayStation sends the name, the platform is the fallback. + */ + static String serviceName(PsnApiService.TrophySet set) { + return set.serviceName() != null ? set.serviceName() : onPlatform(set, "PS5") ? "trophy2" : "trophy"; + } + + /** + * @return empty for a trophy type this code does not know + */ + static Optional toTrophy(String setId, PsnApiService.Trophy trophy) { + return TrophyType.of(trophy.type()).map(type -> new PsnTrophy(setId, trophy.trophyId(), type, + cut(trophy.name(), MAX_NAME_LENGTH), cut(trophy.detail(), MAX_DETAIL_LENGTH), + httpsOrNull(trophy.iconUrl()), trophy.hidden(), cut(trophy.groupId(), MAX_GROUP_LENGTH), null, false, + null)); + } + + static PsnEarned toEarned(PsnApiService.EarnedTrophy trophy) { + return new PsnEarned(trophy.trophyId(), trophy.earned(), trophy.earnedAt(), trophy.earnedRate()); + } + + /** + * Only https URLs are stored; the page renders them as images. + */ + private static String httpsOrNull(String url) { + return url != null && url.length() <= MAX_URL_LENGTH && url.regionMatches(true, 0, "https://", 0, 8) ? url : null; + } + + private static String cut(String text, int max) { + return text == null || text.length() <= max ? text : text.substring(0, max); + } + + static Long linkedAppid(Map links, PsnApiService.Title title) { + PsnTitle link = links.get(PsnTitles.key(title)); + return link != null && link.state() != PsnTitle.State.DISMISSED ? link.appid() : null; + } + + private static boolean onPlatform(PsnApiService.TrophySet set, String platform) { + return set.platform() != null && Arrays.stream(set.platform().split(",")) + .anyMatch(listed -> listed.strip().equalsIgnoreCase(platform)); + } + + private static void collect(Map mapped, Set conflicts, String setId, long appid) { + Long previous = mapped.putIfAbsent(setId, appid); + if (previous != null && previous != appid) { + conflicts.add(setId); + } + } +} diff --git a/src/main/java/core_service/modules/library/service/PsnTrophyStep.java b/src/main/java/core_service/modules/library/service/PsnTrophyStep.java new file mode 100644 index 0000000..b62142f --- /dev/null +++ b/src/main/java/core_service/modules/library/service/PsnTrophyStep.java @@ -0,0 +1,209 @@ +package core_service.modules.library.service; + +import core_service.modules.library.domain.PsnEarned; +import core_service.modules.library.domain.PsnTitle; +import core_service.modules.library.domain.PsnTrophy; +import core_service.modules.library.domain.PsnTrophySetState; +import core_service.modules.library.domain.SyncChange; +import core_service.modules.library.repo.GameMergeRepository; +import core_service.modules.library.repo.PsnTitleRepository; +import core_service.modules.library.repo.PsnTrophyRepository; +import core_service.modules.psn_adapter.service.PsnApiService; +import io.github.resilience4j.ratelimiter.RequestNotPermitted; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestClientResponseException; + +import java.time.Duration; +import java.time.Instant; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +/** + * The trophy part of the PlayStation sync. Outside any transaction, after the title part. The summary of every + * trophy set costs one or two calls; the trophy lists are fetched only for sets that changed, newest first, within + * a budget of calls per run. The access token comes from the title part and stays out of every log. + */ +@Service +@Slf4j +public class PsnTrophyStep { + + static final int MAX_ERRORS_IN_A_ROW = 3; + private static final Duration RECHECK_NO_SET = Duration.ofDays(30); + + private final PsnApiService apiService; + private final PsnTrophyRepository repository; + private final PsnTitleRepository titleRepository; + private final GameMergeRepository mergeRepository; + private final TransactionTemplate transaction; + private final int callsPerRun; + + public PsnTrophyStep(PsnApiService apiService, PsnTrophyRepository repository, PsnTitleRepository titleRepository, + GameMergeRepository mergeRepository, PlatformTransactionManager transactionManager, + @Value("${app.psn.trophy-calls-per-run:30}") int callsPerRun) { + this.apiService = apiService; + this.repository = repository; + this.titleRepository = titleRepository; + this.mergeRepository = mergeRepository; + this.transaction = new TransactionTemplate(transactionManager); + this.callsPerRun = callsPerRun; + } + + /** + * Outside any transaction. Never throws. + * + * @return ACHIEVEMENTS changes of the games whose sets gained trophies; a set seen the first time is a baseline + */ + public List run(Long userId, String accessToken, List titles) { + Map gains = new LinkedHashMap<>(); + try { + PsnApiService.TrophySets summary = apiService.getTrophySets(accessToken); + List sets = summary.sets(); + repository.deleteUserSets(userId, sets.stream().filter(PsnApiService.TrophySet::hidden) + .map(PsnApiService.TrophySet::npCommunicationId).toList()); + // A complete list shows the sets of the linked account: those of an earlier account go. A partial or empty + // list proves nothing. + if (summary.complete() && !sets.isEmpty()) { + repository.deleteUserSetsExcept(userId, + sets.stream().map(PsnApiService.TrophySet::npCommunicationId).toList()); + } + List inScope = sets.stream().filter(PsnTrophies::inScope).toList(); + Budget budget = new Budget(callsPerRun); + Map appids = mapSets(userId, accessToken, titles, inScope, budget); + saveSets(userId, accessToken, inScope, appids, budget, gains); + } catch (RestClientException | RequestNotPermitted e) { + logFailure(e); + } catch (RuntimeException e) { + log.error("PSN trophy step failed for user {}", userId, e); + } + return gains.entrySet().stream().map(gain -> SyncChange.achievements(gain.getKey(), gain.getValue())).toList(); + } + + private Map mapSets(Long userId, String accessToken, List titles, + List sets, Budget budget) { + Map links = titleRepository.findAll(userId); + Set linkedTitleIds = titles.stream().filter(title -> PsnTrophies.linkedAppid(links, title) != null) + .map(PsnApiService.Title::titleId).collect(Collectors.toCollection(LinkedHashSet::new)); + Map setIdByTitleId = new HashMap<>( + repository.findTitleSets(linkedTitleIds, Instant.now().minus(RECHECK_NO_SET))); + List toAsk = linkedTitleIds.stream().filter(id -> !setIdByTitleId.containsKey(id)) + .limit((long) budget.callsLeft * PsnApiService.TITLE_IDS_PER_CALL).toList(); + if (!toAsk.isEmpty()) { + PsnApiService.TrophySetIds ids = apiService.getTrophySetIds(accessToken, toAsk); + budget.callsLeft -= ids.calls(); + if (ids.failure() != null) { + logFailure(ids.failure()); + } + // Only an answered title can be "no set"; a failed or unsent call leaves no mark. + repository.saveTitleSets(ids.answered(), ids.found()); + setIdByTitleId.putAll(ids.found()); + } + Map mapped = PsnTrophies.byTitleId(titles, links, setIdByTitleId); + Set inScopeIds = sets.stream().map(PsnApiService.TrophySet::npCommunicationId).collect(Collectors.toSet()); + mapped.keySet().retainAll(inScopeIds); + int exact = mapped.size(); + mapped.putAll(PsnTrophies.byName(titles, links, sets, mapped.keySet())); + log.info("PSN trophies user={} sets={} mapped by title id={} by name={}", userId, sets.size(), exact, + mapped.size() - exact); + Map targets = mergeRepository.findTargets(mapped.values()); + mapped.replaceAll((setId, appid) -> targets.getOrDefault(appid, appid)); + return mapped; + } + + private void saveSets(Long userId, String accessToken, List inScope, + Map appids, Budget budget, Map gains) { + List mapped = inScope.stream().filter(set -> appids.containsKey(set.npCommunicationId())) + .sorted(Comparator.comparing(PsnApiService.TrophySet::lastUpdatedAt, + Comparator.nullsLast(Comparator.reverseOrder()))) + .toList(); + Map states = repository.findStates(userId, + mapped.stream().map(PsnApiService.TrophySet::npCommunicationId).toList()); + for (PsnApiService.TrophySet set : mapped) { + String setId = set.npCommunicationId(); + PsnTrophySetState state = states.get(setId); + Integer storedEarned = state == null ? null : state.earnedTotal(); + boolean needList = state == null || state.listVersion() == null + || set.version() != null && !set.version().equals(state.listVersion()); + boolean needEarned = set.earned().total() > 0 && (storedEarned == null + || !Objects.equals(state.listUpdatedAt(), set.lastUpdatedAt())); + List trophies = needList ? fetchTrophies(accessToken, set, budget) : null; + List earned = needEarned ? fetchEarned(accessToken, set, budget) : null; + long appid = appids.get(setId); + transaction.executeWithoutResult(tx -> { + repository.saveSet(userId, appid, PsnTrophies.toSet(set)); + if (trophies != null) { + repository.replaceTrophies(setId, set.version() != null ? set.version() : "", trophies); + } + if (earned != null) { + repository.replaceEarned(userId, setId, set.lastUpdatedAt(), earned); + } + }); + if (storedEarned != null && set.earned().total() > storedEarned) { + gains.merge(appid, set.earned().total() - storedEarned, Integer::sum); + } + } + } + + private List fetchTrophies(String accessToken, PsnApiService.TrophySet set, Budget budget) { + return budget.call(() -> apiService.getTrophies(accessToken, set.npCommunicationId(), + PsnTrophies.serviceName(set)).trophies().stream() + .map(trophy -> PsnTrophies.toTrophy(set.npCommunicationId(), trophy)) + .flatMap(Optional::stream).toList()); + } + + private List fetchEarned(String accessToken, PsnApiService.TrophySet set, Budget budget) { + return budget.call(() -> apiService.getEarnedTrophies(accessToken, set.npCommunicationId(), + PsnTrophies.serviceName(set)).stream() + .map(PsnTrophies::toEarned).toList()); + } + + // The class and the status only: the message of a RestClientException can hold the answer's body. + private static void logFailure(RuntimeException e) { + log.warn("PSN trophy call failed: {}{}", e.getClass().getSimpleName(), + e instanceof RestClientResponseException response ? " status " + response.getStatusCode().value() : ""); + } + + /** + * The calls left of a run; list calls stop after {@link #MAX_ERRORS_IN_A_ROW} errors. + */ + private static final class Budget { + private int callsLeft; + private int errorsInARow; + + private Budget(int callsLeft) { + this.callsLeft = callsLeft; + } + + /** + * @return null when the budget is spent or the call failed + */ + private T call(Supplier call) { + if (callsLeft <= 0 || errorsInARow >= MAX_ERRORS_IN_A_ROW) { + return null; + } + callsLeft--; + try { + T result = call.get(); + errorsInARow = 0; + return result; + } catch (RestClientException | RequestNotPermitted e) { + logFailure(e); + errorsInARow++; + return null; + } + } + } +} diff --git a/src/main/java/core_service/modules/library/service/StatGamesService.java b/src/main/java/core_service/modules/library/service/StatGamesService.java index 6aff8a2..108b5e4 100644 --- a/src/main/java/core_service/modules/library/service/StatGamesService.java +++ b/src/main/java/core_service/modules/library/service/StatGamesService.java @@ -5,6 +5,7 @@ import core_service.modules.library.domain.ProfileSection; import core_service.modules.library.domain.StatGroup; import core_service.modules.library.domain.StatKey; import core_service.modules.library.domain.UserStats; +import core_service.modules.library.repo.PsnTrophyRepository; import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserStatsRepository; import core_service.shared.security.Public; @@ -24,6 +25,7 @@ import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.stream.Stream; /** * The games behind a stat number, see {@link StatKey}. Reads the same sets as the numbers of {@link UserStatsService}. @@ -38,6 +40,7 @@ public class StatGamesService { private final UserStatsRepository userStatsRepository; private final UserGameRepository userGameRepository; + private final PsnTrophyRepository psnTrophyRepository; @Value("${app.library.zone:Europe/Berlin}") private ZoneId zone; @@ -81,8 +84,9 @@ public class StatGamesService { case LONGEST_DAY -> userStatsRepository.longestDay(userId).map(day -> (Object) day.day()).orElse(null); case TREND_ADDED, TREND_DONE -> today.withDayOfMonth(1).atStartOfDay(zone).toInstant(); case TREND_MOVES -> today.minusDays(UserStatsService.TREND_DAYS - 1L).atStartOfDay(zone).toInstant(); - case PERFECT -> AchievementProgress.perfectAppids( - AchievementProgress.started(userGameRepository.findAchievementProgress(userId))); + case PERFECT -> AchievementProgress.perfectAppids(AchievementProgress.started( + Stream.concat(userGameRepository.findAchievementProgress(userId).stream(), + psnTrophyRepository.findProgress(userId).stream()).toList())); default -> arg; }; } diff --git a/src/main/java/core_service/modules/psn_adapter/api/dto/response/PsnTitleTrophySetsResponse.java b/src/main/java/core_service/modules/psn_adapter/api/dto/response/PsnTitleTrophySetsResponse.java new file mode 100644 index 0000000..cf84a63 --- /dev/null +++ b/src/main/java/core_service/modules/psn_adapter/api/dto/response/PsnTitleTrophySetsResponse.java @@ -0,0 +1,15 @@ +package core_service.modules.psn_adapter.api.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import java.util.List; + +/** + * The trophy sets PlayStation names for title ids (trophy v1, titles/trophyTitles). + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record PsnTitleTrophySetsResponse(List titles) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Title(String npTitleId, List<PsnTrophyTitlesResponse.TrophyTitle> trophyTitles) {} +} diff --git a/src/main/java/core_service/modules/psn_adapter/api/dto/response/PsnTrophiesResponse.java b/src/main/java/core_service/modules/psn_adapter/api/dto/response/PsnTrophiesResponse.java new file mode 100644 index 0000000..5602a36 --- /dev/null +++ b/src/main/java/core_service/modules/psn_adapter/api/dto/response/PsnTrophiesResponse.java @@ -0,0 +1,18 @@ +package core_service.modules.psn_adapter.api.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import java.util.List; + +/** + * The trophies of one set, from the global list or from the user's list (which adds earned, earnedDateTime and + * trophyEarnedRate). trophyType is bronze, silver, gold or platinum; trophyEarnedRate is a string like "12.3". + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record PsnTrophiesResponse(String trophySetVersion, List<Trophy> trophies) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Trophy(Integer trophyId, Boolean trophyHidden, String trophyType, String trophyName, + String trophyDetail, String trophyIconUrl, String trophyGroupId, Boolean earned, + String earnedDateTime, String trophyEarnedRate) {} +} diff --git a/src/main/java/core_service/modules/psn_adapter/api/dto/response/PsnTrophyTitlesResponse.java b/src/main/java/core_service/modules/psn_adapter/api/dto/response/PsnTrophyTitlesResponse.java new file mode 100644 index 0000000..02af5c4 --- /dev/null +++ b/src/main/java/core_service/modules/psn_adapter/api/dto/response/PsnTrophyTitlesResponse.java @@ -0,0 +1,22 @@ +package core_service.modules.psn_adapter.api.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import java.util.List; + +/** + * One page of the trophy sets of the account (trophy v1). trophyTitlePlatform is a comma list like "PS5" or + * "PS4,PSVITA"; npServiceName is trophy (PS4) or trophy2 (PS5). + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record PsnTrophyTitlesResponse(List<TrophyTitle> trophyTitles, Integer nextOffset, Integer totalItemCount) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record TrophyTitle(String npServiceName, String npCommunicationId, String trophySetVersion, + String trophyTitleName, String trophyTitleIconUrl, String trophyTitlePlatform, + Boolean hiddenFlag, Counts definedTrophies, Counts earnedTrophies, + String lastUpdatedDateTime) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Counts(int bronze, int silver, int gold, int platinum) {} +} diff --git a/src/main/java/core_service/modules/psn_adapter/service/PsnApiService.java b/src/main/java/core_service/modules/psn_adapter/service/PsnApiService.java index fdefb2d..9331ed4 100644 --- a/src/main/java/core_service/modules/psn_adapter/service/PsnApiService.java +++ b/src/main/java/core_service/modules/psn_adapter/service/PsnApiService.java @@ -1,23 +1,33 @@ package core_service.modules.psn_adapter.service; import core_service.modules.psn_adapter.api.dto.response.PsnGameListResponse; +import core_service.modules.psn_adapter.api.dto.response.PsnTitleTrophySetsResponse; +import core_service.modules.psn_adapter.api.dto.response.PsnTrophiesResponse; +import core_service.modules.psn_adapter.api.dto.response.PsnTrophyTitlesResponse; import core_service.shared.security.RequiresRole; import core_service.shared.security.Role; import io.github.resilience4j.ratelimiter.RateLimiter; import io.github.resilience4j.ratelimiter.RateLimiterRegistry; +import io.github.resilience4j.ratelimiter.RequestNotPermitted; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriBuilder; +import java.net.URI; import java.time.Duration; import java.time.Instant; import java.time.format.DateTimeParseException; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.function.Function; /** - * The titles a PlayStation account has played (gamelist v2, unofficial), PS4 and PS5. + * The titles a PlayStation account has played (gamelist v2, unofficial), PS4 and PS5, and their trophies (trophy v1). */ @Service @RequiresRole(Role.USER) @@ -30,8 +40,40 @@ public class PsnApiService { public record Title(String titleId, String name, String category, Duration playDuration, Long conceptId, Instant lastPlayedAt) {} + /** + * @param earned the user's earned counts; the earned total is {@code earned.total()} + * @param lastUpdatedAt null when PlayStation sends none or an unreadable value + * @param hidden the user hid the set on PlayStation + */ + public record TrophySet(String npCommunicationId, String serviceName, String version, String name, String iconUrl, + String platform, Counts defined, Counts earned, Instant lastUpdatedAt, boolean hidden) {} + + public record Counts(int bronze, int silver, int gold, int platinum) { + public int total() { + return bronze + silver + gold + platinum; + } + } + + /** + * @param type as PlayStation spells it: bronze, silver, gold or platinum + */ + public record Trophy(int trophyId, String type, String name, String detail, String iconUrl, boolean hidden, + String groupId) {} + + /** + * @param earnedRate percent of all players; null when PlayStation sends none or an unreadable value + */ + public record EarnedTrophy(int trophyId, boolean earned, Instant earnedAt, Double earnedRate) {} + + /** + * @param version trophySetVersion; null when PlayStation sends none + */ + public record TrophyList(String version, List<Trophy> trophies) {} + static final int PAGE_SIZE = 200; - static final int MAX_TITLES = 1000; + public static final int MAX_TITLES = 1000; + static final int TROPHY_PAGE_SIZE = 800; + public static final int TITLE_IDS_PER_CALL = 5; private final RestClient apiClient; private final RateLimiter rateLimiter; @@ -49,16 +91,12 @@ public class PsnApiService { int offset = 0; while (titles.size() < MAX_TITLES) { int from = offset; - RateLimiter.waitForPermission(rateLimiter); - PsnGameListResponse page = apiClient.get() - .uri(uri -> uri.path("/gamelist/v2/users/me/titles") + PsnGameListResponse page = get(accessToken, PsnGameListResponse.class, + uri -> uri.path("/gamelist/v2/users/me/titles") .queryParam("categories", "ps4_game,ps5_native_game") .queryParam("limit", PAGE_SIZE) .queryParam("offset", from) - .build()) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) - .retrieve() - .body(PsnGameListResponse.class); + .build()); if (page == null || page.titles() == null) { break; } @@ -77,6 +115,167 @@ public class PsnApiService { return titles; } + /** + * @param complete the number of sets equals the totalItemCount PlayStation reports: nothing is missing, so a set + * that is not in the list is not in the account. False after a broken page or the cap. + */ + public record TrophySets(List<TrophySet> sets, boolean complete) {} + + /** + * Every trophy set of the account, also PS3 and Vita ones and hidden ones; the caller decides what is in scope. + * + * @throws org.springframework.web.client.RestClientException when PlayStation fails + */ + public TrophySets getTrophySets(String accessToken) { + List<TrophySet> sets = new ArrayList<>(); + Integer total = null; + int offset = 0; + while (sets.size() < MAX_TITLES) { + int from = offset; + PsnTrophyTitlesResponse page = get(accessToken, PsnTrophyTitlesResponse.class, + uri -> uri.path("/trophy/v1/users/me/trophyTitles") + .queryParam("limit", TROPHY_PAGE_SIZE) + .queryParam("offset", from) + .build()); + if (page == null || page.trophyTitles() == null) { + break; + } + total = page.totalItemCount(); + for (PsnTrophyTitlesResponse.TrophyTitle title : page.trophyTitles()) { + if (title.npCommunicationId() != null && title.trophyTitleName() != null && sets.size() < MAX_TITLES) { + sets.add(toSet(title)); + } + } + if (page.nextOffset() == null || page.nextOffset() <= offset) { + break; + } + offset = page.nextOffset(); + } + return new TrophySets(sets, total != null && total == sets.size()); + } + + /** + * The answer of {@link #getTrophySetIds}. + * + * @param found title id to npCommunicationId; an answered title without a set is left out + * @param answered the title ids of the calls PlayStation answered: only these can be "no set" + * @param calls the HTTP calls made, also the failed one + * @param failure the error that ended the calls early; null when all were answered + */ + public record TrophySetIds(Map<String, String> found, List<String> answered, int calls, RuntimeException failure) {} + + /** + * Asks PlayStation which trophy set belongs to each title id, 5 ids per call. Unconfirmed against the real + * service: any failure (also a 4xx or a rate limit) ends the calls and returns what was answered so far. + */ + public TrophySetIds getTrophySetIds(String accessToken, List<String> titleIds) { + Map<String, String> found = new LinkedHashMap<>(); + List<String> answered = new ArrayList<>(); + int calls = 0; + for (int from = 0; from < titleIds.size(); from += TITLE_IDS_PER_CALL) { + List<String> chunk = titleIds.subList(from, Math.min(titleIds.size(), from + TITLE_IDS_PER_CALL)); + String ids = String.join(",", chunk); + PsnTitleTrophySetsResponse answer; + calls++; + try { + answer = get(accessToken, PsnTitleTrophySetsResponse.class, + uri -> uri.path("/trophy/v1/users/me/titles/trophyTitles").queryParam("npTitleIds", ids).build()); + } catch (RestClientException | RequestNotPermitted e) { + return new TrophySetIds(found, answered, calls, e); + } + answered.addAll(chunk); + if (answer == null || answer.titles() == null) { + continue; + } + for (PsnTitleTrophySetsResponse.Title title : answer.titles()) { + if (title.npTitleId() != null && title.trophyTitles() != null) { + title.trophyTitles().stream().filter(set -> set.npCommunicationId() != null).findFirst() + .ifPresent(set -> found.put(title.npTitleId(), set.npCommunicationId())); + } + } + } + return new TrophySetIds(found, answered, calls, null); + } + + /** + * The trophies of a set, the same for every user. + * + * @throws org.springframework.web.client.RestClientException when PlayStation fails + */ + public TrophyList getTrophies(String accessToken, String npCommunicationId, String serviceName) { + PsnTrophiesResponse answer = fetchTrophies("/trophy/v1/npCommunicationIds/{id}/trophyGroups/all/trophies", + accessToken, npCommunicationId, serviceName); + if (answer == null || answer.trophies() == null) { + return new TrophyList(null, List.of()); + } + List<Trophy> trophies = new ArrayList<>(); + for (PsnTrophiesResponse.Trophy trophy : answer.trophies()) { + if (trophy.trophyId() != null && trophy.trophyType() != null) { + trophies.add(new Trophy(trophy.trophyId(), trophy.trophyType(), trophy.trophyName(), + trophy.trophyDetail(), trophy.trophyIconUrl(), Boolean.TRUE.equals(trophy.trophyHidden()), + trophy.trophyGroupId() != null ? trophy.trophyGroupId() : "default")); + } + } + return new TrophyList(answer.trophySetVersion(), trophies); + } + + /** + * What the account earned in a set, with the share of all players per trophy. + * + * @throws org.springframework.web.client.RestClientException when PlayStation fails + */ + public List<EarnedTrophy> getEarnedTrophies(String accessToken, String npCommunicationId, String serviceName) { + PsnTrophiesResponse answer = fetchTrophies( + "/trophy/v1/users/me/npCommunicationIds/{id}/trophyGroups/all/trophies", accessToken, npCommunicationId, + serviceName); + if (answer == null || answer.trophies() == null) { + return List.of(); + } + return answer.trophies().stream().filter(trophy -> trophy.trophyId() != null) + .map(trophy -> new EarnedTrophy(trophy.trophyId(), Boolean.TRUE.equals(trophy.earned()), + instant(trophy.earnedDateTime()), rate(trophy.trophyEarnedRate()))) + .toList(); + } + + private PsnTrophiesResponse fetchTrophies(String path, String accessToken, String npCommunicationId, + String serviceName) { + return get(accessToken, PsnTrophiesResponse.class, + uri -> uri.path(path).queryParam("npServiceName", serviceName).build(npCommunicationId)); + } + + private <T> T get(String accessToken, Class<T> type, Function<UriBuilder, URI> uri) { + RateLimiter.waitForPermission(rateLimiter); + return apiClient.get() + .uri(uri) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .retrieve() + .body(type); + } + + private static TrophySet toSet(PsnTrophyTitlesResponse.TrophyTitle title) { + return new TrophySet(title.npCommunicationId(), title.npServiceName(), title.trophySetVersion(), + title.trophyTitleName(), title.trophyTitleIconUrl(), title.trophyTitlePlatform(), + counts(title.definedTrophies()), counts(title.earnedTrophies()), instant(title.lastUpdatedDateTime()), + Boolean.TRUE.equals(title.hiddenFlag())); + } + + private static Counts counts(PsnTrophyTitlesResponse.Counts counts) { + return counts == null ? new Counts(0, 0, 0, 0) + : new Counts(counts.bronze(), counts.silver(), counts.gold(), counts.platinum()); + } + + private static Double rate(String value) { + if (value == null) { + return null; + } + try { + double rate = Double.parseDouble(value); + return Double.isFinite(rate) ? rate : null; + } catch (NumberFormatException e) { + return null; + } + } + private static Instant instant(String iso) { if (iso == null) { return null; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 78b59bf..bc0d840 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -56,6 +56,9 @@ app: token-key: ${APP_PSN_TOKEN_KEY:} auth-base-url: https://ca.account.sony.com/api/authz/v3/oauth api-base-url: https://m.np.playstation.com/api + # Trophy calls per sync run (title id lookups and trophy lists). The sync counts as stale after 5 minutes and + # the rate limit below is one call per 4 s, so 30 calls take 2 minutes. + trophy-calls-per-run: 30 # Days before the link ends that the inbox gets its "ends soon" note. expiry-warning-days: 2 # The public client of the PlayStation app, as the psn-api project ships it. Not a secret of ours. diff --git a/src/main/resources/db/migration/V40__Psn_Trophies.sql b/src/main/resources/db/migration/V40__Psn_Trophies.sql new file mode 100644 index 0000000..d09be3f --- /dev/null +++ b/src/main/resources/db/migration/V40__Psn_Trophies.sql @@ -0,0 +1,61 @@ +-- PlayStation trophies (#25 slice 3). Own tables, keyed by npCommunicationId: the Steam achievement tables stay +-- untouched, so rewards (Steam only) never see trophies. + +-- Global cache: a gamelist titleId and the trophy set PlayStation names for it. +CREATE TABLE psn_title_trophy_set ( + title_id VARCHAR(16) PRIMARY KEY, + np_communication_id VARCHAR(16), -- NULL: PlayStation knows no set; asked again after 30 days + checked_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Global: one trophy set (the trophies of one game on one console generation). +CREATE TABLE psn_trophy_set ( + np_communication_id VARCHAR(16) PRIMARY KEY, + service_name VARCHAR(8) NOT NULL, -- trophy (PS4) / trophy2 (PS5) + platform VARCHAR(32) NOT NULL, -- trophyTitlePlatform, e.g. PS5 or PS4 + name VARCHAR(512) NOT NULL, + icon_url VARCHAR(1024), + set_version VARCHAR(16), -- trophySetVersion of the last summary + list_version VARCHAR(16), -- version of the stored psn_trophy rows; NULL = never fetched + defined_bronze INTEGER NOT NULL, + defined_silver INTEGER NOT NULL, + defined_gold INTEGER NOT NULL, + defined_platinum INTEGER NOT NULL, + refreshed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Global: the trophies of a set. +CREATE TABLE psn_trophy ( + np_communication_id VARCHAR(16) NOT NULL REFERENCES psn_trophy_set ON DELETE CASCADE, + trophy_id INTEGER NOT NULL, + type VARCHAR(8) NOT NULL CHECK (type IN ('BRONZE', 'SILVER', 'GOLD', 'PLATINUM')), + name VARCHAR(512), + detail VARCHAR(2048), + icon_url VARCHAR(1024), + hidden BOOLEAN NOT NULL DEFAULT FALSE, + group_id VARCHAR(8) NOT NULL DEFAULT 'default', -- 'default' = base game, '001'.. = DLC + earned_rate DOUBLE PRECISION, -- % of players, from the last earned list call + PRIMARY KEY (np_communication_id, trophy_id) +); + +-- A user's progress in a set, from the summary. +CREATE TABLE user_psn_trophy_set ( + user_id BIGINT NOT NULL, + np_communication_id VARCHAR(16) NOT NULL REFERENCES psn_trophy_set, + appid BIGINT NOT NULL, -- library key the set counts for (merge target) + earned_total INTEGER NOT NULL, + platinum_earned BOOLEAN NOT NULL, + last_updated_at TIMESTAMPTZ, -- lastUpdatedDateTime of the last summary + list_updated_at TIMESTAMPTZ, -- lastUpdatedDateTime the stored earned list has; NULL = never + PRIMARY KEY (user_id, np_communication_id) +); +CREATE INDEX idx_upts_user_app ON user_psn_trophy_set (user_id, appid); + +-- The trophies a user earned. +CREATE TABLE user_psn_trophy ( + user_id BIGINT NOT NULL, + np_communication_id VARCHAR(16) NOT NULL, + trophy_id INTEGER NOT NULL, + earned_at TIMESTAMPTZ, + PRIMARY KEY (user_id, np_communication_id, trophy_id) +); diff --git a/src/test/java/core_service/configuration/LocalMocksParseTest.java b/src/test/java/core_service/configuration/LocalMocksParseTest.java index 3d715b5..d873e34 100644 --- a/src/test/java/core_service/configuration/LocalMocksParseTest.java +++ b/src/test/java/core_service/configuration/LocalMocksParseTest.java @@ -2,7 +2,10 @@ package core_service.configuration; import core_service.modules.igdb_adapter.api.dto.response.IgdbResponses; import core_service.modules.psn_adapter.api.dto.response.PsnGameListResponse; +import core_service.modules.psn_adapter.api.dto.response.PsnTitleTrophySetsResponse; import core_service.modules.psn_adapter.api.dto.response.PsnTokenResponse; +import core_service.modules.psn_adapter.api.dto.response.PsnTrophiesResponse; +import core_service.modules.psn_adapter.api.dto.response.PsnTrophyTitlesResponse; import core_service.modules.speedrun_adapter.api.dto.response.SpeedrunResponses; import core_service.modules.steam_adapter.api.dto.response.SteamGlobalAchievementPercentagesResponse; import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse; @@ -52,6 +55,9 @@ class LocalMocksParseTest extends AbstractUnitTest { Map.entry("igdb-game-", IgdbResponses.Game[].class), Map.entry("psn-token", PsnTokenResponse.class), Map.entry("psn-gamelist", PsnGameListResponse.class), + Map.entry("psn-trophy-titles", PsnTrophyTitlesResponse.class), + Map.entry("psn-trophy-title-sets", PsnTitleTrophySetsResponse.class), + Map.entry("psn-trophies-", PsnTrophiesResponse.class), Map.entry("speedrun-games-", SpeedrunResponses.Games.class), Map.entry("speedrun-runs-", SpeedrunResponses.Runs.class)); diff --git a/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java b/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java index 8fea4fe..be86cc5 100644 --- a/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java +++ b/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java @@ -17,7 +17,7 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest { private static final List<String> USER_TABLES = List.of("user_game_status_change", "user_game_achievement", "user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot", - "sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger", + "sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_psn_trophy", "user_psn_trophy_set", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger", "reward_equipped", "user_psn_account", "listed_user", "public_user", "follow", "follow_digest", "inbox_note"); @Autowired @@ -58,6 +58,12 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest { jdbc.update("insert into game_import (user_id, source, status, total) values (?, 'TEXT', 'SAVED', 0)", userId); jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'STARTER', 'STARTER', 20, now())", userId); jdbc.update("insert into user_psn_account (user_id, refresh_token_enc, refresh_expires_at) values (?, '\\x01'::bytea, now())", userId); + jdbc.update("insert into psn_trophy_set (np_communication_id, service_name, platform, name, defined_bronze, " + + "defined_silver, defined_gold, defined_platinum) values ('NPWR1_00', 'trophy', 'PS4', 'Game', 1, 0, 0, 0) " + + "on conflict do nothing"); + jdbc.update("insert into user_psn_trophy_set (user_id, np_communication_id, appid, earned_total, platinum_earned) " + + "values (?, 'NPWR1_00', 220, 1, false)", userId); + jdbc.update("insert into user_psn_trophy (user_id, np_communication_id, trophy_id) values (?, 'NPWR1_00', 1)", userId); jdbc.update("insert into reward_equipped (user_id, slot, item_code) values (?, 'BADGE', 'BADGE_PILE_SLAYER')", userId); } } diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerAchievementsTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerAchievementsTest.java index acedb58..eb88feb 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerAchievementsTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerAchievementsTest.java @@ -1,11 +1,13 @@ package core_service.modules.library.api; import core_service.modules.library.domain.GameAchievementSchemaMeta; +import core_service.modules.library.domain.PsnTrophySet; import core_service.modules.library.domain.SyncTrigger; import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGameAchievementMeta; import core_service.modules.library.repo.GameAchievementSchemaMetaRepository; +import core_service.modules.library.repo.PsnTrophyRepository; import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.UserGameAchievementMetaRepository; import core_service.modules.library.repo.UserGameRepository; @@ -16,6 +18,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.web.servlet.MockMvc; import java.time.Instant; @@ -59,11 +62,24 @@ class LibraryControllerAchievementsTest extends AbstractMockedAdaptersTest { @Autowired private LibrarySyncWorker librarySyncWorker; + @Autowired + private PsnTrophyRepository psnTrophyRepository; + + @Autowired + private JdbcTemplate jdbc; + @BeforeEach void clean() { userGameRepository.deleteAll(); schemaMetaRepository.deleteAll(); userAchievementMetaRepository.deleteAll(); + jdbc.update("delete from user_psn_trophy_set"); + jdbc.update("delete from psn_trophy_set"); + } + + private void trophies(long appid, String setId, String platform, int total, int earned, boolean platinum) { + psnTrophyRepository.saveSet(USER_ID, appid, new PsnTrophySet(setId, "trophy2", "01.00", "Set " + setId, null, + platform, total, 0, 0, 0, earned, platinum, Instant.now())); } /** @@ -130,6 +146,43 @@ class LibraryControllerAchievementsTest extends AbstractMockedAdaptersTest { .andExpect(jsonPath("$.closest[1].imageUrl").doesNotExist()); } + @Test + void summary_TrophiesCount_APlatinumSetIsPerfectAndOneGameCountsOnce() throws Exception { + game(10L, null, null, true); + game(20L, 10, 4, true); + trophies(10L, "NPWR1_00", "PS5", 50, 30, true); + trophies(10L, "NPWR2_00", "PS4", 5, 2, false); + trophies(20L, "NPWR3_00", "PS4", 10, 6, false); + + mockMvc.perform(get("/api/library/achievements/summary")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.unlocked").value(42)) + .andExpect(jsonPath("$.startedGames").value(2)) + .andExpect(jsonPath("$.perfectGames").value(1)) + .andExpect(jsonPath("$.averageCompletion").value(80.0)) + .andExpect(jsonPath("$.closest", hasSize(1))) + .andExpect(jsonPath("$.closest[0].appid").value(20)) + .andExpect(jsonPath("$.closest[0].unlocked").value(6)) + .andExpect(jsonPath("$.closest[0].total").value(10)); + } + + @Test + void games_TheTileShowsTheRealTrophyCount_AndFlagsAPlatinumSetAsPerfect() throws Exception { + game(10L, null, null, true); + game(20L, 10, 4, true); + trophies(10L, "NPWR1_00", "PS5", 50, 30, true); + trophies(20L, "NPWR3_00", "PS4", 10, 2, false); + + mockMvc.perform(get("/api/library/games").param("sort", "appid,asc")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].achievementsUnlocked").value(30)) + .andExpect(jsonPath("$.content[0].achievementsTotal").value(50)) + .andExpect(jsonPath("$.content[0].achievementsPerfect").value(true)) + .andExpect(jsonPath("$.content[1].achievementsUnlocked").value(4)) + .andExpect(jsonPath("$.content[1].achievementsTotal").value(10)) + .andExpect(jsonPath("$.content[1].achievementsPerfect").value(false)); + } + @Test void games_CarryTheAchievementProgress() throws Exception { game(20L, 4, 3, true); diff --git a/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java b/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java index 9712119..8d6e404 100644 --- a/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java +++ b/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java @@ -2,12 +2,14 @@ package core_service.modules.library.api; import core_service.modules.library.domain.GameAchievementSchemaMeta; import core_service.modules.library.domain.GamePriority; +import core_service.modules.library.domain.PsnTrophySet; import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGameAchievementMeta; import core_service.modules.library.repo.GameAchievementSchemaMetaRepository; import core_service.modules.library.repo.GameReleaseRepository; +import core_service.modules.library.repo.PsnTrophyRepository; import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.UserGameAchievementMetaRepository; import core_service.modules.library.repo.UserGameRepository; @@ -17,6 +19,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; @@ -77,12 +80,25 @@ class PublicProfileControllerTest extends AbstractIntegrationTest { @Autowired private GameReleaseRepository gameReleaseRepository; + @Autowired + private PsnTrophyRepository psnTrophyRepository; + + @Autowired + private JdbcTemplate jdbc; + + private void platinum(long appid) { + psnTrophyRepository.saveSet(USER_ID, appid, new PsnTrophySet("NPWR1_00", "trophy2", "01.00", "Set", null, "PS5", + 50, 0, 0, 0, 30, true, Instant.now())); + } + @BeforeEach void seed() { gameReleaseRepository.deleteAll(); userGameRepository.deleteAll(); schemaMetaRepository.deleteAll(); userAchievementMetaRepository.deleteAll(); + jdbc.update("delete from user_psn_trophy_set"); + jdbc.update("delete from psn_trophy_set"); steamGameRepository.saveAll(List.of( new SteamGame(220L, "Half-Life 2", null, Instant.now()), new SteamGame(620L, "Portal 2", null, Instant.now()), @@ -148,6 +164,31 @@ class PublicProfileControllerTest extends AbstractIntegrationTest { .andExpect(jsonPath("$.averageCompletion").value(62.5)); } + @Test + void publicLibrary_APlatinumSetIsPerfect_WhenTheSummaryIsShown() throws Exception { + platinum(220L); + + mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket(USER_ID))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.perfectGames").value(1)) + .andExpect(jsonPath("$.averageCompletion").value(100.0)); + mockMvc.perform(get("/api/public/profiles/library") + .header("X-Profile-Ticket", ticket(USER_ID, List.of(), List.of("PLAYING")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.perfectGames").value(nullValue())) + .andExpect(jsonPath("$.averageCompletion").value(nullValue())); + } + + @Test + void statGames_APlatinumGameIsInThePerfectList() throws Exception { + platinum(220L); + + statGames(ticket(USER_ID, List.of(), List.of("SUMMARY")), "PERFECT", null) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalElements").value(1)) + .andExpect(jsonPath("$.content[0].appid").value(220)); + } + @Test void publicLibrary_TicketWithoutStatGroups_HasNoStats() throws Exception { mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket(USER_ID))) diff --git a/src/test/java/core_service/modules/library/domain/AchievementProgressTest.java b/src/test/java/core_service/modules/library/domain/AchievementProgressTest.java new file mode 100644 index 0000000..793db43 --- /dev/null +++ b/src/test/java/core_service/modules/library/domain/AchievementProgressTest.java @@ -0,0 +1,49 @@ +package core_service.modules.library.domain; + +import core_service.shared.test.AbstractUnitTest; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class AchievementProgressTest extends AbstractUnitTest { + + private static AchievementProgress set(long appid, int total, int unlocked, boolean platinum) { + return new AchievementProgress(appid, total, unlocked, true, platinum); + } + + @Test + void platinum_MakesASetPerfectAndFullPercent_EvenWhenTrophiesAreMissing() { + AchievementProgress platinum = set(1L, 50, 30, true); + + assertThat(platinum.perfect()).isTrue(); + assertThat(platinum.percent()).isEqualTo(100.0); + assertThat(set(1L, 50, 30, false).perfect()).isFalse(); + assertThat(set(1L, 50, 30, false).percent()).isEqualTo(60.0); + assertThat(set(1L, 50, 50, false).perfect()).isTrue(); + } + + @Test + void steamConstructor_HasNoPlatinum() { + assertThat(new AchievementProgress(1L, 10, 10, true).platinum()).isFalse(); + } + + @Test + void perfectAppids_ListsAGameOnce_WhenTwoSetsArePerfect() { + List<AchievementProgress> sets = List.of(set(1L, 10, 10, false), set(1L, 50, 30, true), set(2L, 10, 3, false)); + + assertThat(AchievementProgress.perfectAppids(sets)).containsExactly(1L); + } + + @Test + void perGame_PicksAPerfectSet_ElseTheBestPercent_OnePerGameByAppid() { + AchievementProgress perfect = set(2L, 50, 30, true); + AchievementProgress best = set(1L, 10, 8, false); + + List<AchievementProgress> games = AchievementProgress.perGame(List.of(set(2L, 10, 9, false), perfect, + set(1L, 10, 4, false), best)); + + assertThat(games).containsExactly(best, perfect); + } +} diff --git a/src/test/java/core_service/modules/library/repo/PsnTrophyRepositoryTest.java b/src/test/java/core_service/modules/library/repo/PsnTrophyRepositoryTest.java new file mode 100644 index 0000000..ad23523 --- /dev/null +++ b/src/test/java/core_service/modules/library/repo/PsnTrophyRepositoryTest.java @@ -0,0 +1,276 @@ +package core_service.modules.library.repo; + +import core_service.modules.library.domain.AchievementProgress; +import core_service.modules.library.domain.PsnEarned; +import core_service.modules.library.domain.PsnTrophy; +import core_service.modules.library.domain.PsnTrophySet; +import core_service.modules.library.domain.PsnTrophySetProgress; +import core_service.modules.library.domain.PsnTrophySetState; +import core_service.modules.library.domain.TrophyType; +import core_service.shared.test.AbstractPersistenceTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@Import(PsnTrophyRepository.class) +class PsnTrophyRepositoryTest extends AbstractPersistenceTest { + + private static final long USER = 5L; + private static final Instant UPDATED = Instant.parse("2026-10-01T20:00:00Z"); + + @Autowired + private PsnTrophyRepository repository; + + @Autowired + private JdbcTemplate jdbc; + + @BeforeEach + void clean() { + jdbc.update("delete from user_psn_trophy"); + jdbc.update("delete from user_psn_trophy_set"); + jdbc.update("delete from psn_trophy"); + jdbc.update("delete from psn_trophy_set"); + jdbc.update("delete from psn_title_trophy_set"); + } + + private void game(long userId, long appid) { + jdbc.update("insert into steam_game (appid, name) values (?, 'Game') on conflict do nothing", appid); + jdbc.update("insert into user_game (user_id, appid, status, source) values (?, ?, 'BACKLOG', 'PSN') " + + "on conflict do nothing", userId, appid); + } + + private static PsnTrophySet set(String id, String platform, String name, int earned, boolean platinum, String version) { + return new PsnTrophySet(id, "trophy2", version, name, "https://x/i.png", platform, 3, 1, 1, 1, earned, platinum, + UPDATED); + } + + private static PsnTrophy trophy(String setId, int id, TrophyType type, String group, boolean hidden) { + return new PsnTrophy(setId, id, type, "Trophy " + id, "Detail " + id, "https://x/" + id + ".png", hidden, group, + null, false, null); + } + + @Test + void saveSet_UpsertsTheSummary_AndKeepsTheListVersionAndTheListMarker() { + game(USER, -1L); + repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Ghost", 2, false, "01.00")); + repository.replaceTrophies("NPWR1_00", "01.00", List.of(trophy("NPWR1_00", 1, TrophyType.BRONZE, "default", false))); + repository.replaceEarned(USER, "NPWR1_00", UPDATED, List.of()); + + repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Ghost renamed", 4, true, "01.10")); + + assertThat(repository.findStates(USER, List.of("NPWR1_00"))).containsOnly(Map.entry("NPWR1_00", + new PsnTrophySetState("NPWR1_00", "01.00", 4, UPDATED))); + assertThat(repository.findSets(USER, -1L)).singleElement().satisfies(found -> { + assertThat(found.name()).isEqualTo("Ghost renamed"); + assertThat(found.platinumEarned()).isTrue(); + assertThat(found.total()).isEqualTo(6); + assertThat(found.listed()).isTrue(); + }); + } + + @Test + void findStates_ASetOfAnotherUserHasNoEarnedTotal_AndANeverStoredSetIsMissing() { + game(9L, -1L); + repository.saveSet(9L, -1L, set("NPWR1_00", "PS5", "Ghost", 2, false, "01.00")); + + Map<String, PsnTrophySetState> states = repository.findStates(USER, List.of("NPWR1_00", "NPWR2_00")); + + assertThat(states).containsOnly(Map.entry("NPWR1_00", new PsnTrophySetState("NPWR1_00", null, null, null))); + assertThat(repository.findStates(USER, List.of())).isEmpty(); + } + + @Test + void replaceTrophies_ReplacesTheList_KeepsKnownRates_AndDropsRemovedTrophies() { + game(USER, -1L); + repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Ghost", 1, false, "01.00")); + repository.replaceTrophies("NPWR1_00", "01.00", List.of(trophy("NPWR1_00", 1, TrophyType.BRONZE, "default", false), + trophy("NPWR1_00", 2, TrophyType.GOLD, "default", false))); + repository.replaceEarned(USER, "NPWR1_00", UPDATED, List.of(new PsnEarned(1, true, UPDATED, 12.5), + new PsnEarned(2, false, null, 3.0))); + + repository.replaceTrophies("NPWR1_00", "01.10", List.of(trophy("NPWR1_00", 1, TrophyType.SILVER, "001", true))); + + List<PsnTrophy> trophies = repository.findTrophies(USER, -1L); + assertThat(trophies).singleElement().satisfies(found -> { + assertThat(found.trophyId()).isEqualTo(1); + assertThat(found.type()).isEqualTo(TrophyType.SILVER); + assertThat(found.groupId()).isEqualTo("001"); + assertThat(found.hidden()).isTrue(); + assertThat(found.earnedRate()).isEqualTo(12.5); + assertThat(found.earned()).isTrue(); + assertThat(found.earnedAt()).isEqualTo(UPDATED); + }); + assertThat(repository.findStates(USER, List.of("NPWR1_00")).get("NPWR1_00").listVersion()).isEqualTo("01.10"); + } + + @Test + void replaceEarned_ReplacesTheUsersRows_StoresRatesAndTheMarker() { + game(USER, -1L); + repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Ghost", 1, false, "01.00")); + repository.replaceTrophies("NPWR1_00", "01.00", List.of(trophy("NPWR1_00", 1, TrophyType.BRONZE, "default", false), + trophy("NPWR1_00", 2, TrophyType.GOLD, "default", false))); + repository.replaceEarned(USER, "NPWR1_00", UPDATED, List.of(new PsnEarned(1, true, UPDATED, 12.5))); + + repository.replaceEarned(USER, "NPWR1_00", UPDATED.plusSeconds(60), + List.of(new PsnEarned(1, false, null, 20.0), new PsnEarned(2, true, null, 4.0))); + + assertThat(repository.findTrophies(USER, -1L)).extracting(PsnTrophy::trophyId, PsnTrophy::earned, + PsnTrophy::earnedRate).containsExactly(org.assertj.core.groups.Tuple.tuple(1, false, 20.0), + org.assertj.core.groups.Tuple.tuple(2, true, 4.0)); + assertThat(repository.findStates(USER, List.of("NPWR1_00")).get("NPWR1_00").listUpdatedAt()) + .isEqualTo(UPDATED.plusSeconds(60)); + } + + @Test + void findProgress_HasThePlatinumFlag_AndLeavesOutHiddenAndRemovedGames() { + game(USER, -1L); + game(USER, -2L); + game(USER, -3L); + repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Ghost", 2, true, "01.00")); + repository.saveSet(USER, -2L, set("NPWR2_00", "PS4", "Hidden game", 1, false, "01.00")); + repository.saveSet(USER, -3L, set("NPWR3_00", "PS4", "Removed game", 1, false, "01.00")); + jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = -2", USER); + jdbc.update("update user_game set removed_at = now() where user_id = ? and appid = -3", USER); + + assertThat(repository.findProgress(USER)).containsExactly(new AchievementProgress(-1L, 6, 2, true, true)); + } + + @Test + void findSets_OrdersPs5BeforePs4_ThenByName_AndShowsWhetherTheListIsStored() { + game(USER, -1L); + repository.saveSet(USER, -1L, set("NPWR1_00", "PS4", "B Game", 1, false, "01.00")); + repository.saveSet(USER, -1L, set("NPWR2_00", "PS5", "Z Game", 1, false, "01.00")); + repository.saveSet(USER, -1L, set("NPWR3_00", "PS4", "A Game", 1, false, "01.00")); + repository.replaceTrophies("NPWR3_00", "01.00", List.of()); + + assertThat(repository.findSets(USER, -1L)).containsExactly( + new PsnTrophySetProgress("NPWR2_00", "PS5", "Z Game", 6, 1, false, false), + new PsnTrophySetProgress("NPWR3_00", "PS4", "A Game", 6, 1, false, false), + new PsnTrophySetProgress("NPWR1_00", "PS4", "B Game", 6, 1, false, false)); + } + + @Test + void findSets_NotListedWhileTheUsersEarnedListIsMissing() { + game(9L, -1L); + game(USER, -1L); + repository.saveSet(9L, -1L, set("NPWR1_00", "PS5", "Ghost", 0, false, "01.00")); + repository.replaceTrophies("NPWR1_00", "01.00", List.of(trophy("NPWR1_00", 1, TrophyType.BRONZE, "default", false), + trophy("NPWR1_00", 2, TrophyType.SILVER, "default", false))); + // This user's first sync stored the summary (2 earned), then the budget ran out before the earned list call. + repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Ghost", 2, false, "01.00")); + + assertThat(repository.findTrophies(USER, -1L)).noneMatch(PsnTrophy::earned); + assertThat(repository.findSets(USER, -1L)).singleElement() + .satisfies(found -> assertThat(found.listed()).isFalse()); + } + + @Test + void findSets_ListedWhenTheUserHasNothingEarned_OrWhenBothListsAreStored() { + game(USER, -1L); + repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Zero", 0, false, "01.00")); + repository.replaceTrophies("NPWR1_00", "01.00", List.of()); + repository.saveSet(USER, -1L, set("NPWR2_00", "PS4", "Some", 2, false, "01.00")); + repository.replaceTrophies("NPWR2_00", "01.00", List.of()); + repository.replaceEarned(USER, "NPWR2_00", UPDATED, List.of()); + + assertThat(repository.findSets(USER, -1L)).extracting(PsnTrophySetProgress::listed).containsExactly(true, true); + } + + @Test + void deleteUserSetsExcept_KeepsTheListedSets_AndOtherUsersRows() { + game(USER, -1L); + game(6L, -1L); + repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Keep", 1, false, "01.00")); + repository.saveSet(USER, -1L, set("NPWR2_00", "PS4", "Old account", 1, false, "01.00")); + repository.saveSet(6L, -1L, set("NPWR2_00", "PS4", "Old account", 1, false, "01.00")); + repository.replaceEarned(USER, "NPWR2_00", UPDATED, List.of(new PsnEarned(1, true, UPDATED, null))); + + repository.deleteUserSetsExcept(USER, List.of("NPWR1_00")); + repository.deleteUserSetsExcept(USER, List.of()); + + assertThat(repository.findSets(USER, -1L)).extracting(PsnTrophySetProgress::id).containsExactly("NPWR1_00"); + assertThat(jdbc.queryForObject("select count(*) from user_psn_trophy where user_id = ?", Integer.class, USER)).isZero(); + assertThat(repository.findSets(6L, -1L)).hasSize(1); + } + + @Test + void deleteUser_RemovesEverySetAndTrophyOfTheUser_ForThatUserOnly() { + game(USER, -1L); + game(6L, -1L); + repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Ghost", 1, false, "01.00")); + repository.saveSet(6L, -1L, set("NPWR1_00", "PS5", "Ghost", 1, false, "01.00")); + repository.replaceEarned(USER, "NPWR1_00", UPDATED, List.of(new PsnEarned(1, true, UPDATED, null))); + + repository.deleteUser(USER); + + assertThat(repository.findSets(USER, -1L)).isEmpty(); + assertThat(jdbc.queryForObject("select count(*) from user_psn_trophy where user_id = ?", Integer.class, USER)).isZero(); + assertThat(repository.findSets(6L, -1L)).hasSize(1); + } + + @Test + void findTrophies_OrdersBySetGroupAndId_AndSeesOnlyTheAskedUsersGame() { + game(USER, -1L); + game(USER, -2L); + repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Ghost", 0, false, "01.00")); + repository.saveSet(USER, -2L, set("NPWR2_00", "PS4", "Other", 0, false, "01.00")); + repository.replaceTrophies("NPWR1_00", "01.00", List.of(trophy("NPWR1_00", 2, TrophyType.BRONZE, "001", false), + trophy("NPWR1_00", 1, TrophyType.BRONZE, "default", false), + trophy("NPWR1_00", 3, TrophyType.BRONZE, "default", false))); + repository.replaceTrophies("NPWR2_00", "01.00", List.of(trophy("NPWR2_00", 1, TrophyType.GOLD, "default", false))); + + assertThat(repository.findTrophies(USER, -1L)).extracting(PsnTrophy::trophyId).containsExactly(1, 3, 2); + assertThat(repository.findTrophies(6L, -1L)).isEmpty(); + } + + @Test + void findTitleSets_RecheckWindow_AFoundSetStays_ANoSetAnswerExpires() { + repository.saveTitleSets(List.of("PPSA1_00", "PPSA2_00"), Map.of("PPSA1_00", "NPWR1_00")); + jdbc.update("update psn_title_trophy_set set checked_at = now() - interval '40 days'"); + repository.saveTitleSets(List.of("PPSA3_00"), Map.of()); + + Map<String, String> fresh = repository.findTitleSets(List.of("PPSA1_00", "PPSA2_00", "PPSA3_00", "PPSA4_00"), + Instant.now().minusSeconds(86_400)); + + Map<String, String> expected = new HashMap<>(); + expected.put("PPSA1_00", "NPWR1_00"); + expected.put("PPSA3_00", null); + assertThat(fresh).isEqualTo(expected); + } + + @Test + void saveTitleSets_AskedAgain_OverwritesTheAnswer() { + repository.saveTitleSets(List.of("PPSA1_00"), Map.of()); + + repository.saveTitleSets(List.of("PPSA1_00"), Map.of("PPSA1_00", "NPWR1_00")); + + assertThat(repository.findTitleSets(List.of("PPSA1_00"), Instant.EPOCH)).containsEntry("PPSA1_00", "NPWR1_00"); + } + + @Test + void deleteUserSets_RemovesBothUserTables_ForThatUserOnly() { + game(USER, -1L); + game(6L, -1L); + repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Ghost", 1, false, "01.00")); + repository.saveSet(6L, -1L, set("NPWR1_00", "PS5", "Ghost", 1, false, "01.00")); + repository.replaceEarned(USER, "NPWR1_00", UPDATED, List.of(new PsnEarned(1, true, UPDATED, null))); + repository.replaceEarned(6L, "NPWR1_00", UPDATED, List.of(new PsnEarned(1, true, UPDATED, null))); + + repository.deleteUserSets(USER, List.of("NPWR1_00")); + repository.deleteUserSets(USER, List.of()); + + assertThat(repository.findSets(USER, -1L)).isEmpty(); + assertThat(jdbc.queryForObject("select count(*) from user_psn_trophy where user_id = ?", Integer.class, USER)).isZero(); + assertThat(repository.findSets(6L, -1L)).hasSize(1); + assertThat(jdbc.queryForObject("select count(*) from user_psn_trophy where user_id = ?", Integer.class, 6L)).isEqualTo(1); + } +} diff --git a/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java b/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java index 844483f..723f676 100644 --- a/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java +++ b/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java @@ -1,6 +1,13 @@ package core_service.modules.library.service; +import core_service.modules.library.api.dto.AchievementDto; import core_service.modules.library.api.dto.GameDetailDto; +import core_service.modules.library.domain.AchievementKind; +import core_service.modules.library.domain.PsnEarned; +import core_service.modules.library.domain.PsnTrophy; +import core_service.modules.library.domain.PsnTrophySet; +import core_service.modules.library.domain.TrophyType; +import core_service.modules.library.repo.PsnTrophyRepository; import core_service.modules.library.domain.GameAchievementSchema; import core_service.modules.library.domain.GameAchievementSchemaMeta; import core_service.modules.library.domain.SteamGame; @@ -24,6 +31,7 @@ import core_service.shared.test.AbstractMockedAdaptersTest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.web.client.ResourceAccessException; import java.time.Duration; @@ -75,6 +83,12 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest { @Autowired private GameSpeedrunRepository gameSpeedrunRepository; + @Autowired + private PsnTrophyRepository psnTrophyRepository; + + @Autowired + private JdbcTemplate jdbc; + @BeforeEach void seed() { userGameRepository.deleteAll(); @@ -85,6 +99,10 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest { gameSpeedrunRepository.deleteAll(); gameTimeToBeatRepository.deleteAll(); gameMoodsRepository.deleteAll(); + jdbc.update("delete from user_psn_trophy"); + jdbc.update("delete from user_psn_trophy_set"); + jdbc.update("delete from psn_trophy"); + jdbc.update("delete from psn_trophy_set"); steamGameRepository.save(new SteamGame(620L, "Portal 2", null, Instant.now())); userGameRepository.save(new UserGame(USER_ID, 620L, 60, Instant.now(), Instant.now())); } @@ -215,4 +233,63 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest { assertEquals(1, detail.achievementsUnlocked()); assertTrue(detail.statsVisible()); } + + private void trophySet(String id, String platform, String name, boolean platinumEarned) { + psnTrophyRepository.saveSet(USER_ID, 620L, new PsnTrophySet(id, "trophy2", "01.00", name, null, platform, 2, 0, 0, + 1, 2, platinumEarned, Instant.now())); + psnTrophyRepository.replaceTrophies(id, "01.00", List.of( + new PsnTrophy(id, 0, TrophyType.PLATINUM, "Platinum", "All", "https://x/0.png", false, "default", null, + false, null), + new PsnTrophy(id, 1, TrophyType.BRONZE, "Secret name", "Secret detail", "https://x/1.png", true, + "default", null, false, null), + new PsnTrophy(id, 2, TrophyType.BRONZE, "DLC trophy", "Dlc", "https://x/2.png", false, "001", null, + false, null))); + psnTrophyRepository.replaceEarned(USER_ID, id, Instant.now(), List.of(new PsnEarned(0, true, Instant.now(), 2.5), + new PsnEarned(1, false, null, 1.0), new PsnEarned(2, true, Instant.now(), 12.0))); + } + + @Test + void getGameDetail_PsnOnlyGame_ShowsTheSetsPs5First_AndHidesWhatAHiddenTrophyWouldTell() { + userGameRepository.save(UserGame.manual(USER_ID, 620L, GameSource.PSN, "PS5", GameStatus.PLAYING, 60, Instant.now())); + trophySet("NPWR2_00", "PS4", "Old version", false); + trophySet("NPWR1_00", "PS5", "Ghost", true); + + GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, null, 620L).orElseThrow(); + + assertTrue(detail.hasAchievements()); + assertEquals(0, detail.achievementsTotal()); + assertEquals(List.of("NPWR1_00", "NPWR2_00"), detail.achievementSets().stream().map(set -> set.id()).toList()); + assertTrue(detail.achievementSets().getFirst().perfect()); + assertEquals(AchievementKind.PSN, detail.achievementSets().getFirst().kind()); + AchievementDto hidden = detail.achievements().stream() + .filter(a -> "NPWR1_00".equals(a.setId()) && a.apiName().equals("1")).findFirst().orElseThrow(); + assertEquals(null, hidden.displayName()); + assertEquals(null, hidden.description()); + assertTrue(hidden.hidden()); + AchievementDto dlc = detail.achievements().stream() + .filter(a -> "NPWR1_00".equals(a.setId()) && a.apiName().equals("2")).findFirst().orElseThrow(); + assertEquals("001", dlc.group()); + assertEquals(12.0, dlc.rarity()); + assertTrue(dlc.unlocked()); + assertEquals("Platinum", detail.achievements().getFirst().displayName()); + } + + @Test + void getGameDetail_SteamStatsHidden_StillListsTheTrophySets_AndTheSteamSetIsNotVisible() { + UserGame hiddenGame = userGameRepository.findById(new UserGameId(USER_ID, 620L)).orElseThrow(); + hiddenGame.setHiddenAt(Instant.now()); + userGameRepository.save(hiddenGame); + schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 1, true, Instant.now())); + schemaRepository.save(new GameAchievementSchema(620L, "A", "A", null, "icon", "gray", false)); + userAchievementMetaRepository.save(new UserGameAchievementMeta(USER_ID, 620L, Instant.now(), false, 0, 60)); + trophySet("NPWR1_00", "PS5", "Ghost", false); + + GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L).orElseThrow(); + + assertEquals(List.of("steam", "NPWR1_00"), detail.achievementSets().stream().map(set -> set.id()).toList()); + assertEquals(false, detail.achievementSets().getFirst().visible()); + assertEquals(AchievementKind.STEAM, detail.achievements().getFirst().kind()); + assertEquals(4, detail.achievements().size()); + assertEquals(1, detail.achievementsTotal()); + } } diff --git a/src/test/java/core_service/modules/library/service/PsnAccountServiceTest.java b/src/test/java/core_service/modules/library/service/PsnAccountServiceTest.java index 61bf265..d71c3ce 100644 --- a/src/test/java/core_service/modules/library/service/PsnAccountServiceTest.java +++ b/src/test/java/core_service/modules/library/service/PsnAccountServiceTest.java @@ -3,6 +3,7 @@ package core_service.modules.library.service; import core_service.modules.library.domain.GameImport; import core_service.modules.library.domain.PsnAccount; import core_service.modules.library.repo.GameImportRepository; +import core_service.modules.library.repo.PsnTrophyRepository; import core_service.modules.library.repo.PsnAccountRepository; import core_service.modules.psn_adapter.service.PsnApiService; import core_service.modules.psn_adapter.service.PsnAuthService; @@ -53,13 +54,15 @@ class PsnAccountServiceTest extends AbstractUnitTest { private PsnApiService apiService; @Mock private GameImportRepository importRepository; + @Mock + private PsnTrophyRepository trophyRepository; private final SecretBox box = SecretBox.fromBase64Key(KEY); private PsnAccountService service; @BeforeEach void setUp() { - service = new PsnAccountService(repository, authService, apiService, importRepository, KEY, 2); + service = new PsnAccountService(repository, authService, apiService, importRepository, trophyRepository, KEY, 2); } private PsnAuthService.Tokens tokens(String refresh, Instant refreshExpires) { @@ -113,7 +116,7 @@ class PsnAccountServiceTest extends AbstractUnitTest { @Test void link_WithoutAKey_IsOff_AndCallsNothing() { - PsnAccountService off = new PsnAccountService(repository, authService, apiService, importRepository, "", 2); + PsnAccountService off = new PsnAccountService(repository, authService, apiService, importRepository, trophyRepository, "", 2); assertThat(off.isEnabled()).isFalse(); assertThat(off.link(1L, NPSSO)).isInstanceOf(PsnAccountService.LinkResult.Off.class); @@ -125,7 +128,7 @@ class PsnAccountServiceTest extends AbstractUnitTest { @Test void constructor_RejectsAKeyOfTheWrongLength() { org.assertj.core.api.Assertions.assertThatThrownBy(() -> - new PsnAccountService(repository, authService, apiService, importRepository, "c2hvcnQ=", 2)) + new PsnAccountService(repository, authService, apiService, importRepository, trophyRepository, "c2hvcnQ=", 2)) .isInstanceOf(IllegalArgumentException.class); } @@ -137,7 +140,10 @@ class PsnAccountServiceTest extends AbstractUnitTest { Duration.ofMinutes(90), 7L, null)); when(apiService.getPlayedTitles(ACCESS)).thenReturn(played); - assertThat(service.fetchTitles(1L)).isEqualTo(new PsnAccountService.FetchResult.Titles(played)); + PsnAccountService.FetchResult result = service.fetchTitles(1L); + + assertThat(result).isEqualTo(new PsnAccountService.FetchResult.Titles(played, ACCESS)); + assertThat(result.toString()).doesNotContain(ACCESS); verify(repository, never()).noteExpiry(anyLong(), any()); assertNoSecrets(output); @@ -306,4 +312,11 @@ class PsnAccountServiceTest extends AbstractUnitTest { verify(repository).delete(1L); } + + @Test + void unlink_AlsoDeletesTheTrophiesOfTheUser_SoAnotherAccountStartsFromNothing() { + service.unlink(1L); + + verify(trophyRepository).deleteUser(1L); + } } diff --git a/src/test/java/core_service/modules/library/service/PsnSyncStepTest.java b/src/test/java/core_service/modules/library/service/PsnSyncStepTest.java index 010bd7d..0386d34 100644 --- a/src/test/java/core_service/modules/library/service/PsnSyncStepTest.java +++ b/src/test/java/core_service/modules/library/service/PsnSyncStepTest.java @@ -51,6 +51,8 @@ class PsnSyncStepTest extends AbstractUnitTest { @Mock private PsnAccountService accountService; @Mock + private PsnTrophyStep trophyStep; + @Mock private PsnTitleRepository psnTitleRepository; @Mock private GameImportRepository importRepository; @@ -78,7 +80,7 @@ class PsnSyncStepTest extends AbstractUnitTest { } private void fetches(PsnApiService.Title... titles) { - when(accountService.fetchTitles(USER)).thenReturn(new PsnAccountService.FetchResult.Titles(List.of(titles))); + when(accountService.fetchTitles(USER)).thenReturn(new PsnAccountService.FetchResult.Titles(List.of(titles), "ACCESS")); } private static GameImport psnImport(long id, GameImport.Status status) { @@ -230,7 +232,18 @@ class PsnSyncStepTest extends AbstractUnitTest { assertThat(step.run(USER)).isSameAs(PsnSyncStep.Prepared.NONE); } - verifyNoInteractions(psnTitleRepository, importRepository, manualGameService); + verifyNoInteractions(psnTitleRepository, importRepository, manualGameService, trophyStep); + } + + @Test + void run_TrophyStepGetsTheTokenAndTheTitles_AndItsChangesAreInThePreparedChanges() { + fetches(ghost()); + when(manualGameService.findGames(anyString())).thenReturn(Optional.empty()); + when(trophyStep.run(USER, "ACCESS", List.of(ghost()))).thenReturn(List.of(SyncChange.achievements(-4L, 3))); + + PsnSyncStep.Prepared prepared = step.run(USER); + + assertThat(prepared.changes()).containsExactly(SyncChange.achievements(-4L, 3)); } @Test diff --git a/src/test/java/core_service/modules/library/service/PsnTrophiesTest.java b/src/test/java/core_service/modules/library/service/PsnTrophiesTest.java new file mode 100644 index 0000000..8db63d6 --- /dev/null +++ b/src/test/java/core_service/modules/library/service/PsnTrophiesTest.java @@ -0,0 +1,163 @@ +package core_service.modules.library.service; + +import core_service.modules.library.domain.PsnTitle; +import core_service.modules.library.domain.PsnTrophy; +import core_service.modules.library.domain.TrophyType; +import core_service.modules.psn_adapter.service.PsnApiService; +import core_service.shared.test.AbstractUnitTest; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class PsnTrophiesTest extends AbstractUnitTest { + + private static PsnApiService.Title title(String id, String name, String category, Long concept) { + return new PsnApiService.Title(id, name, category, Duration.ZERO, concept, null); + } + + private static PsnApiService.TrophySet set(String id, String name, String platform) { + return new PsnApiService.TrophySet(id, "trophy", "01.00", name, "https://x/i.png", platform, + new PsnApiService.Counts(1, 0, 0, 0), new PsnApiService.Counts(0, 0, 0, 0), null, false); + } + + private static PsnTitle linked(String key, long appid) { + return new PsnTitle(key, PsnTitle.State.LINKED, appid); + } + + @Test + void setAppids_TitleIdMapsExactly_EvenWhenTheNamesDiffer() { + PsnApiService.Title ghost = title("PPSA1_00", "Ghost of Yotei", "ps5_native_game", 7L); + + Map<String, Long> appids = PsnTrophies.setAppids(List.of(ghost), Map.of("c7", linked("c7", -1L)), + Map.of("PPSA1_00", "NPWR1_00"), List.of(set("NPWR1_00", "Totally Other Name", "PS5"))); + + assertThat(appids).containsOnly(Map.entry("NPWR1_00", -1L)); + } + + @Test + void setAppids_ASteamOwnedTitleCountsForTheOwnedGame() { + PsnApiService.Title ghost = title("PPSA1_00", "Ghost", "ps5_native_game", 7L); + + Map<String, Long> appids = PsnTrophies.setAppids(List.of(ghost), + Map.of("c7", new PsnTitle("c7", PsnTitle.State.STEAM_OWNED, 620L)), Map.of("PPSA1_00", "NPWR1_00"), + List.of(set("NPWR1_00", "Ghost", "PS5"))); + + assertThat(appids).containsOnly(Map.entry("NPWR1_00", 620L)); + } + + @Test + void setAppids_NoAnswerForTheTitleId_FallsBackToCleanNameAndPlatform() { + PsnApiService.Title mega = title("CUSA1_00", "Mega Man Legacy Collection™", "ps4_game", 8L); + + Map<String, Long> appids = PsnTrophies.setAppids(List.of(mega), Map.of("c8", linked("c8", -2L)), Map.of(), + List.of(set("NPWR2_00", "Mega Man Legacy Collection", "PS4"))); + + assertThat(appids).containsOnly(Map.entry("NPWR2_00", -2L)); + } + + @Test + void setAppids_NameMatchOnTheWrongPlatform_IsNotMapped() { + PsnApiService.Title mega = title("CUSA1_00", "Mega Man", "ps4_game", 8L); + + Map<String, Long> appids = PsnTrophies.setAppids(List.of(mega), Map.of("c8", linked("c8", -2L)), Map.of(), + List.of(set("NPWR2_00", "Mega Man", "PS5"))); + + assertThat(appids).isEmpty(); + } + + @Test + void setAppids_ThePlatformListOfASetIsSplit() { + PsnApiService.Title mega = title("CUSA1_00", "Mega Man", "ps4_game", 8L); + + Map<String, Long> appids = PsnTrophies.setAppids(List.of(mega), Map.of("c8", linked("c8", -2L)), Map.of(), + List.of(set("NPWR2_00", "Mega Man", "PS4,PSVITA"))); + + assertThat(appids).containsOnlyKeys("NPWR2_00"); + } + + @Test + void setAppids_SameNameOnTwoGames_IsUnmapped_ButOneGameWithTwoTitlesMaps() { + PsnApiService.Title a = title("CUSA1_00", "Doom", "ps4_game", 1L); + PsnApiService.Title b = title("CUSA2_00", "Doom", "ps4_game", 2L); + List<PsnApiService.TrophySet> sets = List.of(set("NPWR1_00", "Doom", "PS4")); + + assertThat(PsnTrophies.setAppids(List.of(a, b), Map.of("c1", linked("c1", -1L), "c2", linked("c2", -2L)), + Map.of(), sets)).isEmpty(); + assertThat(PsnTrophies.setAppids(List.of(a, b), Map.of("c1", linked("c1", -1L), "c2", linked("c2", -1L)), + Map.of(), sets)).containsOnly(Map.entry("NPWR1_00", -1L)); + } + + @Test + void setAppids_TwoTitleIdsOfOneSetOnTwoGames_LeaveTheSetOut() { + PsnApiService.Title a = title("CUSA1_00", "A", "ps4_game", 1L); + PsnApiService.Title b = title("CUSA2_00", "B", "ps4_game", 2L); + + Map<String, Long> appids = PsnTrophies.setAppids(List.of(a, b), + Map.of("c1", linked("c1", -1L), "c2", linked("c2", -2L)), + Map.of("CUSA1_00", "NPWR1_00", "CUSA2_00", "NPWR1_00"), List.of(set("NPWR1_00", "Other", "PS4"))); + + assertThat(appids).isEmpty(); + } + + @Test + void setAppids_DismissedAndUnknownTitles_MapNothing() { + PsnApiService.Title ghost = title("PPSA1_00", "Ghost", "ps5_native_game", 7L); + List<PsnApiService.TrophySet> sets = List.of(set("NPWR1_00", "Ghost", "PS5")); + Map<String, String> byId = Map.of("PPSA1_00", "NPWR1_00"); + + assertThat(PsnTrophies.setAppids(List.of(ghost), Map.of("c7", new PsnTitle("c7", PsnTitle.State.DISMISSED, null)), + byId, sets)).isEmpty(); + assertThat(PsnTrophies.setAppids(List.of(ghost), Map.of(), byId, sets)).isEmpty(); + } + + @Test + void inScope_Ps4AndPs5AreIn_Ps3OnlyAndHiddenAreOut() { + assertThat(PsnTrophies.inScope(set("N", "A", "PS5"))).isTrue(); + assertThat(PsnTrophies.inScope(set("N", "A", "PS4,PSVITA"))).isTrue(); + assertThat(PsnTrophies.inScope(set("N", "A", "PS3"))).isFalse(); + assertThat(PsnTrophies.inScope(set("N", "A", "PSVITA"))).isFalse(); + assertThat(PsnTrophies.inScope(new PsnApiService.TrophySet("N", "trophy", "1", "A", null, "PS4", + new PsnApiService.Counts(1, 0, 0, 0), new PsnApiService.Counts(0, 0, 0, 0), null, true))).isFalse(); + } + + @Test + void toSet_TakesTheCountsAndPlatinum_AndNormalizesThePlatform() { + PsnApiService.TrophySet source = new PsnApiService.TrophySet("N", "trophy", "01.00", "A", "http://x/i.png", + "PS4,PSVITA", new PsnApiService.Counts(3, 2, 1, 1), new PsnApiService.Counts(2, 1, 0, 1), + Instant.EPOCH, false); + + assertThat(PsnTrophies.toSet(source)).satisfies(set -> { + assertThat(set.platform()).isEqualTo("PS4"); + assertThat(set.earnedTotal()).isEqualTo(4); + assertThat(set.platinumEarned()).isTrue(); + assertThat(List.of(set.definedBronze(), set.definedSilver(), set.definedGold(), set.definedPlatinum())) + .containsExactly(3, 2, 1, 1); + assertThat(set.iconUrl()).isNull(); + }); + } + + @Test + void toTrophy_HttpIconAndTooLongUrlAreNull_TextIsCut_UnknownTypeIsEmpty() { + PsnApiService.Trophy http = new PsnApiService.Trophy(1, "GOLD", "n".repeat(600), "d".repeat(3000), + "http://x/i.png", false, "default"); + PsnApiService.Trophy longUrl = new PsnApiService.Trophy(2, "bronze", "n", "d", "https://x/" + "a".repeat(1100), + false, "default"); + PsnApiService.Trophy ok = new PsnApiService.Trophy(3, "silver", "n", "d", "HTTPS://x/i.png", true, "001"); + + PsnTrophy mapped = PsnTrophies.toTrophy("N", http).orElseThrow(); + assertThat(mapped.type()).isEqualTo(TrophyType.GOLD); + assertThat(mapped.iconUrl()).isNull(); + assertThat(mapped.name()).hasSize(512); + assertThat(mapped.detail()).hasSize(2048); + assertThat(PsnTrophies.toTrophy("N", longUrl).orElseThrow().iconUrl()).isNull(); + assertThat(PsnTrophies.toTrophy("N", ok).orElseThrow().iconUrl()).isEqualTo("HTTPS://x/i.png"); + assertThat(PsnTrophies.toTrophy("N", new PsnApiService.Trophy(4, "legendary", "n", "d", null, false, "default"))) + .isEqualTo(Optional.empty()); + } +} diff --git a/src/test/java/core_service/modules/library/service/PsnTrophyStepTest.java b/src/test/java/core_service/modules/library/service/PsnTrophyStepTest.java new file mode 100644 index 0000000..25bbda5 --- /dev/null +++ b/src/test/java/core_service/modules/library/service/PsnTrophyStepTest.java @@ -0,0 +1,386 @@ +package core_service.modules.library.service; + +import core_service.modules.library.domain.PsnEarned; +import core_service.modules.library.domain.PsnTitle; +import core_service.modules.library.domain.PsnTrophy; +import core_service.modules.library.domain.PsnTrophySet; +import core_service.modules.library.domain.PsnTrophySetState; +import core_service.modules.library.domain.SyncChange; +import core_service.modules.library.repo.GameMergeRepository; +import core_service.modules.library.repo.PsnTitleRepository; +import core_service.modules.library.repo.PsnTrophyRepository; +import core_service.modules.psn_adapter.service.PsnApiService; +import core_service.shared.test.AbstractUnitTest; +import io.github.resilience4j.ratelimiter.RequestNotPermitted; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.http.HttpStatus; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(OutputCaptureExtension.class) +class PsnTrophyStepTest extends AbstractUnitTest { + + private static final long USER = 1L; + private static final String TOKEN = "ACCESS-SECRET"; + private static final Instant UPDATED = Instant.parse("2026-10-01T20:00:00Z"); + + @Mock + private PsnApiService apiService; + @Mock + private PsnTrophyRepository repository; + @Mock + private PsnTitleRepository titleRepository; + @Mock + private GameMergeRepository mergeRepository; + @Mock + private PlatformTransactionManager transactionManager; + + private PsnTrophyStep step; + private final Map<String, PsnTitle> links = new HashMap<>(); + + @BeforeEach + void setUp() { + step = new PsnTrophyStep(apiService, repository, titleRepository, mergeRepository, transactionManager, 30); + lenient().when(titleRepository.findAll(USER)).thenReturn(links); + } + + private static PsnApiService.Title title(int n) { + return new PsnApiService.Title("PPSA" + n + "_00", "Game " + n, "ps5_native_game", Duration.ZERO, (long) n, null); + } + + private static PsnApiService.TrophySet set(int n, int earnedBronze, Instant updated) { + return new PsnApiService.TrophySet("NPWR" + n + "_00", "trophy2", "01.00", "Game " + n, "https://x/i.png", "PS5", + new PsnApiService.Counts(5, 0, 0, 0), new PsnApiService.Counts(earnedBronze, 0, 0, 0), updated, false); + } + + // Title n is linked to appid -n and all its title ids are known to have no set: the name mapping applies. + private List<PsnApiService.Title> titles(int count) { + List<PsnApiService.Title> titles = new ArrayList<>(); + Map<String, String> known = new HashMap<>(); + for (int n = 1; n <= count; n++) { + titles.add(title(n)); + links.put("c" + n, new PsnTitle("c" + n, PsnTitle.State.LINKED, (long) -n)); + known.put("PPSA" + n + "_00", null); + } + when(repository.findTitleSets(anyCollection(), any())).thenReturn(known); + return titles; + } + + private static PsnApiService.TrophySets complete(List<PsnApiService.TrophySet> sets) { + return new PsnApiService.TrophySets(sets, true); + } + + private void state(int n, String listVersion, Integer earned, Instant listUpdatedAt) { + Map<String, PsnTrophySetState> states = new HashMap<>(); + states.put("NPWR" + n + "_00", new PsnTrophySetState("NPWR" + n + "_00", listVersion, earned, listUpdatedAt)); + when(repository.findStates(eq(USER), anyCollection())).thenReturn(states); + } + + @Test + void run_UnchangedSet_FetchesNoListAtAll_ButSavesTheSummary() { + List<PsnApiService.Title> titles = titles(1); + when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 2, UPDATED)))); + state(1, "01.00", 2, UPDATED); + + List<SyncChange> changes = step.run(USER, TOKEN, titles); + + assertThat(changes).isEmpty(); + verify(apiService, never()).getTrophies(anyString(), anyString(), anyString()); + verify(apiService, never()).getEarnedTrophies(anyString(), anyString(), anyString()); + verify(repository).saveSet(eq(USER), eq(-1L), any(PsnTrophySet.class)); + verify(repository, never()).replaceTrophies(anyString(), anyString(), anyList()); + verify(repository, never()).replaceEarned(anyLong(), anyString(), any(), anyList()); + } + + @Test + void run_MoreEarnedTrophies_ReportsTheGain_AndFetchesTheEarnedListOnly() { + List<PsnApiService.Title> titles = titles(1); + when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 5, UPDATED.plusSeconds(60))))); + state(1, "01.00", 2, UPDATED); + when(apiService.getEarnedTrophies(TOKEN, "NPWR1_00", "trophy2")) + .thenReturn(List.of(new PsnApiService.EarnedTrophy(1, true, UPDATED, 10.0))); + + List<SyncChange> changes = step.run(USER, TOKEN, titles); + + assertThat(changes).containsExactly(SyncChange.achievements(-1L, 3)); + verify(apiService, never()).getTrophies(anyString(), anyString(), anyString()); + verify(repository).replaceEarned(USER, "NPWR1_00", UPDATED.plusSeconds(60), + List.of(new PsnEarned(1, true, UPDATED, 10.0))); + } + + @Test + void run_FirstSightOfASet_IsABaseline_AndFetchesBothLists() { + List<PsnApiService.Title> titles = titles(1); + when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 3, UPDATED)))); + when(apiService.getTrophies(TOKEN, "NPWR1_00", "trophy2")) + .thenReturn(new PsnApiService.TrophyList("01.00", List.of( + new PsnApiService.Trophy(1, "bronze", "n", "d", "https://x/1.png", false, "default"), + new PsnApiService.Trophy(2, "weird", "n", "d", null, false, "default")))); + when(apiService.getEarnedTrophies(TOKEN, "NPWR1_00", "trophy2")).thenReturn(List.of()); + + List<SyncChange> changes = step.run(USER, TOKEN, titles); + + assertThat(changes).isEmpty(); + ArgumentCaptor<List<PsnTrophy>> trophies = ArgumentCaptor.captor(); + verify(repository).replaceTrophies(eq("NPWR1_00"), eq("01.00"), trophies.capture()); + assertThat(trophies.getValue()).extracting(PsnTrophy::trophyId).containsExactly(1); + verify(repository).replaceEarned(USER, "NPWR1_00", UPDATED, List.of()); + } + + @Test + void run_StoredSetWithoutUserRow_IsABaselineToo() { + List<PsnApiService.Title> titles = titles(1); + when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 3, UPDATED)))); + state(1, "01.00", null, null); + + assertThat(step.run(USER, TOKEN, titles)).isEmpty(); + } + + @Test + void run_ANewVersion_FetchesTheGlobalListAgain() { + List<PsnApiService.Title> titles = titles(1); + when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 2, UPDATED)))); + state(1, "00.90", 2, UPDATED); + when(apiService.getTrophies(TOKEN, "NPWR1_00", "trophy2")).thenReturn(new PsnApiService.TrophyList("01.00", List.of())); + + step.run(USER, TOKEN, titles); + + verify(apiService).getTrophies(TOKEN, "NPWR1_00", "trophy2"); + verify(apiService, never()).getEarnedTrophies(anyString(), anyString(), anyString()); + verify(repository).replaceTrophies("NPWR1_00", "01.00", List.of()); + } + + @Test + void run_SetWithNothingEarned_NeedsNoEarnedCall() { + List<PsnApiService.Title> titles = titles(1); + when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 0, null)))); + when(apiService.getTrophies(TOKEN, "NPWR1_00", "trophy2")).thenReturn(new PsnApiService.TrophyList("01.00", List.of())); + + step.run(USER, TOKEN, titles); + + verify(apiService, never()).getEarnedTrophies(anyString(), anyString(), anyString()); + } + + @Test + void run_FortySetsWithABudgetOf30_MakesAtMost30ListCalls_ButSavesEverySummary() { + List<PsnApiService.Title> titles = titles(40); + List<PsnApiService.TrophySet> sets = new ArrayList<>(); + for (int n = 1; n <= 40; n++) { + sets.add(set(n, 1, UPDATED.plusSeconds(n))); + } + when(apiService.getTrophySets(TOKEN)).thenReturn(complete(sets)); + when(apiService.getTrophies(anyString(), anyString(), anyString())) + .thenReturn(new PsnApiService.TrophyList("01.00", List.of())); + when(apiService.getEarnedTrophies(anyString(), anyString(), anyString())).thenReturn(List.of()); + + step.run(USER, TOKEN, titles); + + verify(apiService, times(15)).getTrophies(anyString(), anyString(), anyString()); + verify(apiService, times(15)).getEarnedTrophies(anyString(), anyString(), anyString()); + verify(repository, times(40)).saveSet(eq(USER), anyLong(), any(PsnTrophySet.class)); + // newest first: set 40 is served, set 1 is not + verify(apiService).getTrophies(TOKEN, "NPWR40_00", "trophy2"); + verify(apiService, never()).getTrophies(TOKEN, "NPWR1_00", "trophy2"); + } + + @Test + void run_ThreeErrorsInARow_StopTheListCalls_ButSummariesAreSaved() { + List<PsnApiService.Title> titles = titles(6); + List<PsnApiService.TrophySet> sets = new ArrayList<>(); + for (int n = 1; n <= 6; n++) { + sets.add(set(n, 0, UPDATED)); + } + when(apiService.getTrophySets(TOKEN)).thenReturn(complete(sets)); + when(apiService.getTrophies(anyString(), anyString(), anyString())) + .thenThrow(new HttpServerErrorException(HttpStatus.BAD_GATEWAY)); + + step.run(USER, TOKEN, titles); + + verify(apiService, times(PsnTrophyStep.MAX_ERRORS_IN_A_ROW)).getTrophies(anyString(), anyString(), anyString()); + verify(repository, times(6)).saveSet(eq(USER), anyLong(), any(PsnTrophySet.class)); + verify(repository, never()).replaceTrophies(anyString(), anyString(), anyList()); + } + + @Test + void run_ARateLimitedCall_CountsAsAnError_AndDoesNotThrow() { + List<PsnApiService.Title> titles = titles(1); + when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 0, null)))); + when(apiService.getTrophies(anyString(), anyString(), anyString())) + .thenThrow(RequestNotPermitted.createRequestNotPermitted( + io.github.resilience4j.ratelimiter.RateLimiter.ofDefaults("t"))); + + assertThat(step.run(USER, TOKEN, titles)).isEmpty(); + + verify(repository).saveSet(eq(USER), eq(-1L), any(PsnTrophySet.class)); + } + + @Test + void run_SetNowMappedToAnotherGame_IsSavedWithTheNewAppid() { + List<PsnApiService.Title> titles = titles(1); + when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 2, UPDATED)))); + state(1, "01.00", 2, UPDATED); + when(mergeRepository.findTargets(anyCollection())).thenReturn(Map.of(-1L, -77L)); + + step.run(USER, TOKEN, titles); + + verify(repository).saveSet(eq(USER), eq(-77L), any(PsnTrophySet.class)); + } + + @Test + void run_TrophySetsFail_ReturnsNoChanges_AndWritesNothing(CapturedOutput output) { + when(apiService.getTrophySets(TOKEN)).thenThrow(new HttpServerErrorException(HttpStatus.BAD_GATEWAY, "body " + TOKEN)); + + assertThat(step.run(USER, TOKEN, List.of(title(1)))).isEmpty(); + + verifyNoInteractions(repository, mergeRepository); + assertThat(output.getAll()).doesNotContain(TOKEN).contains("status 502"); + } + + @Test + void run_TitleIdsAlreadyKnown_AreNotAskedAgain_UnknownOnesAre() { + List<PsnApiService.Title> titles = new ArrayList<>(List.of(title(1), title(2))); + links.put("c1", new PsnTitle("c1", PsnTitle.State.LINKED, -1L)); + links.put("c2", new PsnTitle("c2", PsnTitle.State.LINKED, -2L)); + Map<String, String> known = new HashMap<>(); + known.put("PPSA1_00", "NPWR1_00"); + when(repository.findTitleSets(anyCollection(), any())).thenReturn(known); + when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 0, null), set(2, 0, null)))); + when(apiService.getTrophySetIds(eq(TOKEN), anyList())).thenReturn(new PsnApiService.TrophySetIds(Map.of("PPSA2_00", "NPWR2_00"), + List.of("PPSA2_00"), 1, null)); + when(apiService.getTrophies(anyString(), anyString(), anyString())) + .thenReturn(new PsnApiService.TrophyList("01.00", List.of())); + + step.run(USER, TOKEN, titles); + + verify(apiService).getTrophySetIds(TOKEN, List.of("PPSA2_00")); + verify(repository).saveTitleSets(List.of("PPSA2_00"), Map.of("PPSA2_00", "NPWR2_00")); + verify(repository).saveSet(eq(USER), eq(-1L), any(PsnTrophySet.class)); + verify(repository).saveSet(eq(USER), eq(-2L), any(PsnTrophySet.class)); + } + + @Test + void run_TheTitleIdEndpointGivesNothing_TheNameFallbackMapsTheSet_AndTheLogSaysWhichPath(CapturedOutput output) { + List<PsnApiService.Title> titles = List.of(title(1)); + links.put("c1", new PsnTitle("c1", PsnTitle.State.LINKED, -1L)); + when(repository.findTitleSets(anyCollection(), any())).thenReturn(new HashMap<>()); + when(apiService.getTrophySetIds(eq(TOKEN), anyList())).thenReturn(new PsnApiService.TrophySetIds(Map.of(), + List.of("PPSA1_00"), 1, null)); + when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 0, null)))); + when(apiService.getTrophies(anyString(), anyString(), anyString())) + .thenReturn(new PsnApiService.TrophyList("01.00", List.of())); + + step.run(USER, TOKEN, titles); + + verify(repository, atLeastOnce()).saveSet(eq(USER), eq(-1L), any(PsnTrophySet.class)); + assertThat(output.getAll()).contains("mapped by title id=0 by name=1").doesNotContain(TOKEN); + } + + @Test + void run_AFailedTitleIdCall_MarksNoTitleAsNoSet_AndTakesBudgetOnlyForTheCallsMade() { + step = new PsnTrophyStep(apiService, repository, titleRepository, mergeRepository, transactionManager, 2); + List<PsnApiService.Title> titles = new ArrayList<>(); + for (int n = 1; n <= 2; n++) { + titles.add(title(n)); + links.put("c" + n, new PsnTitle("c" + n, PsnTitle.State.LINKED, (long) -n)); + } + when(repository.findTitleSets(anyCollection(), any())).thenReturn(new HashMap<>()); + when(apiService.getTrophySetIds(eq(TOKEN), anyList())).thenReturn(new PsnApiService.TrophySetIds(Map.of(), + List.of(), 1, new HttpServerErrorException(HttpStatus.TOO_MANY_REQUESTS))); + when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 0, UPDATED), set(2, 0, null)))); + when(apiService.getTrophies(anyString(), anyString(), anyString())) + .thenReturn(new PsnApiService.TrophyList("01.00", List.of())); + + step.run(USER, TOKEN, titles); + + verify(repository).saveTitleSets(List.of(), Map.of()); + verify(apiService, times(1)).getTrophies(anyString(), anyString(), anyString()); + } + + @Test + void run_ACompleteSummary_RemovesTheSetsOfAnEarlierAccount_AndKeepsTheBaselineOfTheSameAccount() { + List<PsnApiService.Title> titles = titles(1); + when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 2, UPDATED), set(7, 0, null)))); + state(1, "01.00", 2, UPDATED); + + List<SyncChange> changes = step.run(USER, TOKEN, titles); + + assertThat(changes).isEmpty(); + verify(repository).deleteUserSetsExcept(USER, List.of("NPWR1_00", "NPWR7_00")); + } + + @Test + void run_ASummaryThatEndedOnABrokenPage_RemovesNothing_ButStillSavesWhatItHas() { + List<PsnApiService.Title> titles = titles(1); + when(apiService.getTrophySets(TOKEN)).thenReturn(new PsnApiService.TrophySets(List.of(set(1, 2, UPDATED)), false)); + state(1, "01.00", 2, UPDATED); + + step.run(USER, TOKEN, titles); + + verify(repository, never()).deleteUserSetsExcept(anyLong(), anyCollection()); + verify(repository).saveSet(eq(USER), eq(-1L), any(PsnTrophySet.class)); + } + + @Test + void run_AnEmptyOrPartialSummary_RemovesNothing() { + when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of())); + step.run(USER, TOKEN, List.of(title(1))); + List<PsnApiService.TrophySet> capped = new ArrayList<>(); + for (int n = 0; n < PsnApiService.MAX_TITLES; n++) { + capped.add(set(n, 0, null)); + } + when(apiService.getTrophySets(TOKEN)).thenReturn(new PsnApiService.TrophySets(capped, false)); + step.run(USER, TOKEN, List.of(title(1))); + + verify(repository, never()).deleteUserSetsExcept(anyLong(), anyCollection()); + } + + @Test + void run_HiddenSet_HasItsUserRowsDeleted_AndIsNotStored() { + List<PsnApiService.Title> titles = titles(1); + PsnApiService.TrophySet hidden = new PsnApiService.TrophySet("NPWR9_00", "trophy", "1", "Game 1", null, "PS4", + new PsnApiService.Counts(1, 0, 0, 0), new PsnApiService.Counts(1, 0, 0, 0), UPDATED, true); + when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(hidden))); + + step.run(USER, TOKEN, titles); + + verify(repository).deleteUserSets(USER, List.of("NPWR9_00")); + verify(repository, never()).saveSet(anyLong(), anyLong(), any()); + } + + @Test + void run_ARepositoryThrows_NeverThrowsAndReturnsTheChangesSoFar() { + when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 0, null)))); + when(titleRepository.findAll(USER)).thenThrow(new IllegalStateException("database down")); + + assertThat(step.run(USER, TOKEN, List.of(title(1)))).isEmpty(); + } +} diff --git a/src/test/java/core_service/modules/psn_adapter/service/PsnApiServiceTest.java b/src/test/java/core_service/modules/psn_adapter/service/PsnApiServiceTest.java index 0aa01ea..5e48522 100644 --- a/src/test/java/core_service/modules/psn_adapter/service/PsnApiServiceTest.java +++ b/src/test/java/core_service/modules/psn_adapter/service/PsnApiServiceTest.java @@ -4,6 +4,7 @@ import core_service.shared.test.AbstractUnitTest; import io.github.resilience4j.ratelimiter.RateLimiterRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.test.web.client.MockRestServiceServer; import org.springframework.web.client.RestClient; @@ -12,6 +13,7 @@ import org.springframework.web.client.RestClientException; import java.time.Duration; import java.time.Instant; import java.util.List; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -19,6 +21,7 @@ import static org.hamcrest.Matchers.containsString; import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; class PsnApiServiceTest extends AbstractUnitTest { @@ -91,4 +94,156 @@ class PsnApiServiceTest extends AbstractUnitTest { assertThatThrownBy(() -> service.getPlayedTitles("t")).isInstanceOf(RestClientException.class); } + + @Test + void getTrophySets_FollowsNextOffset_ReadsCountsHiddenFlagAndInstant_WithBearer() { + server.expect(requestTo(containsString("/trophy/v1/users/me/trophyTitles?"))) + .andExpect(requestTo(containsString("limit=800"))) + .andExpect(requestTo(containsString("offset=0"))) + .andExpect(header("Authorization", "Bearer ACCESS-1")) + .andRespond(withSuccess(""" + {"trophyTitles":[{"npServiceName":"trophy2","npCommunicationId":"NPWR1_00","trophySetVersion":"01.10", + "trophyTitleName":"Ghost","trophyTitleIconUrl":"https://x/i.png","trophyTitlePlatform":"PS5", + "hiddenFlag":false,"definedTrophies":{"bronze":3,"silver":2,"gold":1,"platinum":1}, + "earnedTrophies":{"bronze":1,"silver":1,"gold":0,"platinum":0}, + "lastUpdatedDateTime":"2026-10-01T20:41:07Z","extra":1}, + {"npCommunicationId":"NPWR2_00"}],"totalItemCount":3,"nextOffset":800}""", + MediaType.APPLICATION_JSON)); + server.expect(requestTo(containsString("offset=800"))) + .andExpect(header("Authorization", "Bearer ACCESS-1")) + .andRespond(withSuccess(""" + {"trophyTitles":[{"npServiceName":"trophy","npCommunicationId":"NPWR3_00","trophyTitleName":"Mega", + "trophyTitlePlatform":"PS4,PSVITA","hiddenFlag":true,"definedTrophies":{"bronze":1,"silver":0, + "gold":0,"platinum":0}}],"nextOffset":null}""", MediaType.APPLICATION_JSON)); + + PsnApiService.TrophySets result = service.getTrophySets("ACCESS-1"); + List<PsnApiService.TrophySet> sets = result.sets(); + + assertThat(sets).extracting(PsnApiService.TrophySet::npCommunicationId).containsExactly("NPWR1_00", "NPWR3_00"); + PsnApiService.TrophySet ghost = sets.getFirst(); + assertThat(ghost.defined().total()).isEqualTo(7); + assertThat(ghost.earned()).isEqualTo(new PsnApiService.Counts(1, 1, 0, 0)); + assertThat(ghost.lastUpdatedAt()).isEqualTo(Instant.parse("2026-10-01T20:41:07Z")); + assertThat(ghost.hidden()).isFalse(); + assertThat(sets.get(1).hidden()).isTrue(); + assertThat(sets.get(1).earned().total()).isZero(); + assertThat(sets.get(1).lastUpdatedAt()).isNull(); + // totalItemCount says 3, one entry had no name: not complete + assertThat(result.complete()).isFalse(); + server.verify(); + } + + @Test + void getTrophySets_CompleteOnlyWhenTheCountEqualsTheTotal_AClearedPageIsPartial() { + server.expect(requestTo(containsString("offset=0"))).andRespond(withSuccess(""" + {"trophyTitles":[{"npCommunicationId":"NPWR1_00","trophyTitleName":"A"}],"totalItemCount":1,"nextOffset":null}""", + MediaType.APPLICATION_JSON)); + assertThat(service.getTrophySets("t").complete()).isTrue(); + server.reset(); + + server.expect(requestTo(containsString("offset=0"))).andRespond(withSuccess(""" + {"trophyTitles":[{"npCommunicationId":"NPWR1_00","trophyTitleName":"A"}],"totalItemCount":3,"nextOffset":1}""", + MediaType.APPLICATION_JSON)); + server.expect(requestTo(containsString("offset=1"))).andRespond(withSuccess("{}", MediaType.APPLICATION_JSON)); + + PsnApiService.TrophySets broken = service.getTrophySets("t"); + + assertThat(broken.sets()).hasSize(1); + assertThat(broken.complete()).isFalse(); + } + + @Test + void getTrophySetIds_SixIds_AreAskedAsFiveAndOne_FirstSetPerTitleWins() { + server.expect(requestTo(containsString("/trophy/v1/users/me/titles/trophyTitles?"))) + .andExpect(requestTo(containsString("npTitleIds=A_00,B_00,C_00,D_00,E_00"))) + .andExpect(header("Authorization", "Bearer ACCESS-1")) + .andRespond(withSuccess(""" + {"titles":[{"npTitleId":"A_00","trophyTitles":[{"npCommunicationId":"NPWR1_00"}, + {"npCommunicationId":"NPWR9_00"}]}, + {"npTitleId":"B_00","trophyTitles":[]}]}""", MediaType.APPLICATION_JSON)); + server.expect(requestTo(containsString("npTitleIds=F_00"))) + .andExpect(header("Authorization", "Bearer ACCESS-1")) + .andRespond(withSuccess(""" + {"titles":[{"npTitleId":"F_00","trophyTitles":[{"npCommunicationId":"NPWR2_00"}]}]}""", + MediaType.APPLICATION_JSON)); + + PsnApiService.TrophySetIds ids = service.getTrophySetIds("ACCESS-1", + List.of("A_00", "B_00", "C_00", "D_00", "E_00", "F_00")); + + assertThat(ids.found()).containsOnly(Map.entry("A_00", "NPWR1_00"), Map.entry("F_00", "NPWR2_00")); + assertThat(ids.answered()).containsExactly("A_00", "B_00", "C_00", "D_00", "E_00", "F_00"); + assertThat(ids.calls()).isEqualTo(2); + assertThat(ids.failure()).isNull(); + server.verify(); + } + + @Test + void getTrophySetIds_AFailedCallEndsTheCalls_OnlyAnsweredTitlesAreAnswered_AndTheFailedCallCounts() { + server.expect(requestTo(containsString("npTitleIds=A_00,B_00,C_00,D_00,E_00"))) + .andRespond(withSuccess(""" + {"titles":[{"npTitleId":"A_00","trophyTitles":[{"npCommunicationId":"NPWR1_00"}]}]}""", + MediaType.APPLICATION_JSON)); + server.expect(requestTo(containsString("npTitleIds=F_00,G_00,H_00,I_00,J_00"))) + .andRespond(withStatus(HttpStatus.TOO_MANY_REQUESTS)); + + PsnApiService.TrophySetIds ids = service.getTrophySetIds("t", List.of("A_00", "B_00", "C_00", "D_00", "E_00", + "F_00", "G_00", "H_00", "I_00", "J_00", "K_00")); + + assertThat(ids.found()).containsOnly(Map.entry("A_00", "NPWR1_00")); + assertThat(ids.answered()).containsExactly("A_00", "B_00", "C_00", "D_00", "E_00"); + assertThat(ids.calls()).isEqualTo(2); + assertThat(ids.failure()).isInstanceOf(RestClientException.class); + server.verify(); + } + + @Test + void getTrophySetIds_ServerErrorIsReturnedAsFailure_NotThrown() { + server.expect(requestTo(containsString("npTitleIds="))).andRespond(withServerError()); + + PsnApiService.TrophySetIds ids = service.getTrophySetIds("t", List.of("A_00")); + + assertThat(ids.answered()).isEmpty(); + assertThat(ids.failure()).isInstanceOf(RestClientException.class); + } + + @Test + void getTrophies_SendsTheServiceName_AndReadsVersionTypeGroupAndHiddenFlag() { + server.expect(requestTo(containsString("/trophy/v1/npCommunicationIds/NPWR1_00/trophyGroups/all/trophies?"))) + .andExpect(requestTo(containsString("npServiceName=trophy2"))) + .andExpect(header("Authorization", "Bearer ACCESS-1")) + .andRespond(withSuccess(""" + {"trophySetVersion":"01.10","trophies":[ + {"trophyId":0,"trophyHidden":false,"trophyType":"platinum","trophyName":"Legend", + "trophyDetail":"All","trophyIconUrl":"https://x/0.png","trophyGroupId":"default"}, + {"trophyId":7,"trophyHidden":true,"trophyType":"bronze","trophyGroupId":"001"}, + {"trophyId":8},{"trophyType":"gold"}]}""", MediaType.APPLICATION_JSON)); + + PsnApiService.TrophyList list = service.getTrophies("ACCESS-1", "NPWR1_00", "trophy2"); + + assertThat(list.version()).isEqualTo("01.10"); + assertThat(list.trophies()).containsExactly( + new PsnApiService.Trophy(0, "platinum", "Legend", "All", "https://x/0.png", false, "default"), + new PsnApiService.Trophy(7, "bronze", null, null, null, true, "001")); + server.verify(); + } + + @Test + void getEarnedTrophies_SendsTheServiceName_ParsesTheRate_AndABadRateIsNull() { + server.expect(requestTo(containsString("/trophy/v1/users/me/npCommunicationIds/NPWR1_00/trophyGroups/all/trophies?"))) + .andExpect(requestTo(containsString("npServiceName=trophy"))) + .andExpect(header("Authorization", "Bearer ACCESS-1")) + .andRespond(withSuccess(""" + {"trophies":[ + {"trophyId":0,"earned":true,"earnedDateTime":"2026-09-20T18:00:00Z","trophyEarnedRate":"12.3"}, + {"trophyId":1,"earned":false,"trophyEarnedRate":"x"}, + {"trophyId":2,"earned":false,"trophyEarnedRate":"NaN"}]}""", MediaType.APPLICATION_JSON)); + + List<PsnApiService.EarnedTrophy> earned = service.getEarnedTrophies("ACCESS-1", "NPWR1_00", "trophy"); + + assertThat(earned).containsExactly( + new PsnApiService.EarnedTrophy(0, true, Instant.parse("2026-09-20T18:00:00Z"), 12.3), + new PsnApiService.EarnedTrophy(1, false, null, null), + new PsnApiService.EarnedTrophy(2, false, null, null)); + server.verify(); + } } diff --git a/src/test/java/core_service/modules/rewards/repo/RewardFactsRepositoryTest.java b/src/test/java/core_service/modules/rewards/repo/RewardFactsRepositoryTest.java index 836013d..e520d9c 100644 --- a/src/test/java/core_service/modules/rewards/repo/RewardFactsRepositoryTest.java +++ b/src/test/java/core_service/modules/rewards/repo/RewardFactsRepositoryTest.java @@ -110,6 +110,19 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest { }); } + @Test + void finishCandidates_APlatinumTrophySetNeverCountsAsAllAchievements() { + game("BACKLOG", 120); + setStatus("COMPLETED"); + jdbc.update("insert into psn_trophy_set (np_communication_id, service_name, platform, name, defined_bronze, " + + "defined_silver, defined_gold, defined_platinum) values ('NPWR1_00', 'trophy', 'PS4', 'Game', 4, 0, 0, 1)"); + jdbc.update("insert into user_psn_trophy_set (user_id, np_communication_id, appid, earned_total, platinum_earned) " + + "values (?, 'NPWR1_00', ?, 5, true)", USER, GAME); + + assertThat(repository.finishCandidates(USER, BERLIN)).singleElement() + .satisfies(f -> assertThat(f.allAchievements()).isFalse()); + } + @Test void finishCandidates_GoneOnceAFinishedRowExistsOrTheGameWentBackToBacklog() { game("BACKLOG", 120);