feat(psn): sync PlayStation trophies as achievements
Build, Test & Push Core Service / quality-check (push) Successful in 1m30s
Build, Test & Push Core Service / docker (push) Successful in 1m49s

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:
lobo committed 2026-10-08 09:18:15 +02:00
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"
}
}
+42 -9
View File
@@ -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;
};
}
@@ -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) {}
}
@@ -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) {}
}
@@ -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;
+3
View File
@@ -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);