Public Access
feat(psn): sync PlayStation trophies as achievements
Sets map by exact title id, else by name and platform. Trophy gains count as achievements; platinum or all earned is perfect. Max 30 PSN calls per run; trophy lists load only when changed. Hidden trophies stay hidden (name and detail) until earned.
This commit is contained in:
1 parent
f5ca7237a9
commit
0ddb345a41
54 files changed
+2937
-68
No files matched your search
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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<String> 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.
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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<AchievementSetDto> achievementSets
|
||||
) {
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
package core_service.modules.library.domain;
|
||||
|
||||
public enum AchievementKind { STEAM, PSN }
|
||||
@@ -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<Long> perfectAppids(List<AchievementProgress> 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<AchievementProgress> perGame(List<AchievementProgress> sets) {
|
||||
Comparator<AchievementProgress> best = Comparator.comparing(AchievementProgress::perfect)
|
||||
.thenComparingDouble(AchievementProgress::percent);
|
||||
Map<Long, AchievementProgress> 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();
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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) {}
|
||||
@@ -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) {}
|
||||
@@ -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) {}
|
||||
@@ -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) {}
|
||||
@@ -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<TrophyType> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> findTitleSets(Collection<String> titleIds, Instant recheckBefore) {
|
||||
Map<String, String> 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<String> asked, Map<String, String> 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<String, PsnTrophySetState> findStates(long userId, Collection<String> setIds) {
|
||||
Map<String, PsnTrophySetState> 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<PsnTrophy> 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<Integer> 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<PsnEarned> 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<String> 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<String> 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<AchievementProgress> 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<PsnTrophySetProgress> 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<PsnTrophy> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<AchievementSetDto> psnSets = psnSets(userGame);
|
||||
List<AchievementDto> 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<AchievementSetDto> 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<AchievementDto> 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<AchievementDto> achievements, List<GameAchievementSchema> schema) {
|
||||
List<AchievementDto> achievements, List<GameAchievementSchema> schema,
|
||||
List<AchievementSetDto> psnSets, List<AchievementDto> psnTrophies) {
|
||||
Optional<GameTimeToBeat> timeToBeat = gameTimeToBeatRepository.findById(userGame.getAppid());
|
||||
Set<String> 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<ComingGame> coming = comingGameRepository.findById(userGame.getAppid());
|
||||
boolean hasAchievements = total > 0;
|
||||
boolean hasAchievements = total > 0 || !psnSets.isEmpty();
|
||||
List<AchievementSetDto> 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Long, Integer> editions = gameLinkRepository.editionCounts(games.getFirst().getUserId(), appids);
|
||||
Map<Long, ComingGame> coming = comingOf(appids);
|
||||
Map<Long, AchievementProgress> 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<AchievementProgress> games = userGameRepository.findAchievementProgress(userId);
|
||||
List<AchievementProgress> games = progress(userId);
|
||||
List<AchievementProgress> started = AchievementProgress.started(games);
|
||||
List<AchievementProgress> closest = started.stream()
|
||||
.filter(g -> g.unlockedCapped() < g.total())
|
||||
.sorted(Comparator.comparingDouble((AchievementProgress g) -> (double) g.unlockedCapped() / g.total())
|
||||
List<AchievementProgress> perGame = AchievementProgress.perGame(started);
|
||||
List<AchievementProgress> 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<AchievementProgress> 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<AchievementProgress> progress(Long userId) {
|
||||
return Stream.concat(userGameRepository.findAchievementProgress(userId).stream(),
|
||||
psnTrophyRepository.findProgress(userId).stream()).toList();
|
||||
}
|
||||
|
||||
private static Double averageCompletion(List<AchievementProgress> 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<Long, SteamGame> meta = steamGameRepository.findAllById(shownAppids)
|
||||
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
|
||||
Map<Long, ComingGame> coming = comingOf(shownAppids);
|
||||
List<AchievementProgress> started = summary ? AchievementProgress.started(userGameRepository.findAchievementProgress(userId)) : null;
|
||||
List<AchievementProgress> started = summary ? AchievementProgress.started(progress(userId)) : null;
|
||||
Set<StatGroup> 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<UserSteamSync> getSyncStatus(Long userId) {
|
||||
return userSteamSyncRepository.findById(userId);
|
||||
}
|
||||
|
||||
@@ -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<PsnApiService.Title> titles) implements FetchResult {}
|
||||
/**
|
||||
* @param accessToken valid for about an hour; the sync reuses it for the trophy calls
|
||||
*/
|
||||
record Titles(List<PsnApiService.Title> 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<Long> 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();
|
||||
|
||||
@@ -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<Playtime> playtimes = new ArrayList<>();
|
||||
boolean baseline = false;
|
||||
try {
|
||||
Optional<List<PsnApiService.Title>> titles = fetch(userId);
|
||||
if (titles.isEmpty()) {
|
||||
Optional<PsnAccountService.FetchResult.Titles> found = fetch(userId);
|
||||
if (found.isEmpty()) {
|
||||
return Prepared.NONE;
|
||||
}
|
||||
List<GameImport.Line> lines = PsnTitles.toLines(titles.get());
|
||||
Map<String, Instant> lastPlayed = PsnTitles.lastPlayedByKey(titles.get());
|
||||
List<PsnApiService.Title> titles = found.get().titles();
|
||||
List<GameImport.Line> lines = PsnTitles.toLines(titles);
|
||||
Map<String, Instant> lastPlayed = PsnTitles.lastPlayedByKey(titles);
|
||||
Map<String, PsnTitle> known = psnTitleRepository.findAll(userId);
|
||||
baseline = known.isEmpty();
|
||||
Set<String> 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<List<PsnApiService.Title>> fetch(Long userId) {
|
||||
private Optional<PsnAccountService.FetchResult.Titles> 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);
|
||||
|
||||
@@ -74,9 +74,15 @@ final class PsnTitles {
|
||||
private static Map<String, List<PsnApiService.Title>> group(List<PsnApiService.Title> titles) {
|
||||
Map<String, List<PsnApiService.Title>> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<String, Long> setAppids(List<PsnApiService.Title> titles, Map<String, PsnTitle> links,
|
||||
Map<String, String> setIdByTitleId, List<PsnApiService.TrophySet> sets) {
|
||||
Map<String, Long> 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<String, Long> byTitleId(List<PsnApiService.Title> titles, Map<String, PsnTitle> links,
|
||||
Map<String, String> setIdByTitleId) {
|
||||
Map<String, Long> mapped = new HashMap<>();
|
||||
Set<String> 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<String, Long> byName(List<PsnApiService.Title> titles, Map<String, PsnTitle> links,
|
||||
List<PsnApiService.TrophySet> sets, Set<String> skip) {
|
||||
Map<String, Long> mapped = new HashMap<>();
|
||||
Set<String> 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<PsnTrophy> 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<String, PsnTitle> 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<String, Long> mapped, Set<String> conflicts, String setId, long appid) {
|
||||
Long previous = mapped.putIfAbsent(setId, appid);
|
||||
if (previous != null && previous != appid) {
|
||||
conflicts.add(setId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<SyncChange> run(Long userId, String accessToken, List<PsnApiService.Title> titles) {
|
||||
Map<Long, Integer> gains = new LinkedHashMap<>();
|
||||
try {
|
||||
PsnApiService.TrophySets summary = apiService.getTrophySets(accessToken);
|
||||
List<PsnApiService.TrophySet> 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<PsnApiService.TrophySet> inScope = sets.stream().filter(PsnTrophies::inScope).toList();
|
||||
Budget budget = new Budget(callsPerRun);
|
||||
Map<String, Long> 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<String, Long> mapSets(Long userId, String accessToken, List<PsnApiService.Title> titles,
|
||||
List<PsnApiService.TrophySet> sets, Budget budget) {
|
||||
Map<String, PsnTitle> links = titleRepository.findAll(userId);
|
||||
Set<String> linkedTitleIds = titles.stream().filter(title -> PsnTrophies.linkedAppid(links, title) != null)
|
||||
.map(PsnApiService.Title::titleId).collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
Map<String, String> setIdByTitleId = new HashMap<>(
|
||||
repository.findTitleSets(linkedTitleIds, Instant.now().minus(RECHECK_NO_SET)));
|
||||
List<String> 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<String, Long> mapped = PsnTrophies.byTitleId(titles, links, setIdByTitleId);
|
||||
Set<String> 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<Long, Long> targets = mergeRepository.findTargets(mapped.values());
|
||||
mapped.replaceAll((setId, appid) -> targets.getOrDefault(appid, appid));
|
||||
return mapped;
|
||||
}
|
||||
|
||||
private void saveSets(Long userId, String accessToken, List<PsnApiService.TrophySet> inScope,
|
||||
Map<String, Long> appids, Budget budget, Map<Long, Integer> gains) {
|
||||
List<PsnApiService.TrophySet> mapped = inScope.stream().filter(set -> appids.containsKey(set.npCommunicationId()))
|
||||
.sorted(Comparator.comparing(PsnApiService.TrophySet::lastUpdatedAt,
|
||||
Comparator.nullsLast(Comparator.reverseOrder())))
|
||||
.toList();
|
||||
Map<String, PsnTrophySetState> 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<PsnTrophy> trophies = needList ? fetchTrophies(accessToken, set, budget) : null;
|
||||
List<PsnEarned> 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<PsnTrophy> 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<PsnEarned> 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> T call(Supplier<T> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
+15
@@ -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<Title> titles) {
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record Title(String npTitleId, List<PsnTrophyTitlesResponse.TrophyTitle> trophyTitles) {}
|
||||
}
|
||||
+18
@@ -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) {}
|
||||
}
|
||||
+22
@@ -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) {}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user