Public Access
feat(library): show play per platform on game detail
Store PSN play per title. A PS title on a Steam row keeps its minutes. Steam rtime_last_played now fills last played. Dropped? questions read the newest PSN last played too.
This commit is contained in:
1 parent
413381d404
commit
1ff1e061c7
30 files changed
+707
-33
No files matched your search
@@ -218,7 +218,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
|
|||||||
| `POST` | `/api/rewards/purchases` (`{item}`; `200` wallet, `409 ALREADY_OWNED` / `NOT_ENOUGH_POINTS` / `NOT_FOR_SALE`) | JWT |
|
| `POST` | `/api/rewards/purchases` (`{item}`; `200` wallet, `409 ALREADY_OWNED` / `NOT_ENOUGH_POINTS` / `NOT_FOR_SALE`) | JWT |
|
||||||
| `GET` | `/api/rewards/progress/{track}/games?page=`, `/api/rewards/history/{kind}/{ref}/games?page=` (the games behind a track or a `MONTH` / `STREAK` bonus; `400 NOT_A_GAME_LIST`) | JWT |
|
| `GET` | `/api/rewards/progress/{track}/games?page=`, `/api/rewards/history/{kind}/{ref}/games?page=` (the games behind a track or a `MONTH` / `STREAK` bonus; `400 NOT_A_GAME_LIST`) | JWT |
|
||||||
| `PUT` | `/api/rewards/equipped/{slot}` (`{item}`, null = take off; `200` wallet, `409 NOT_OWNED`, `400 WRONG_SLOT`) | JWT |
|
| `PUT` | `/api/rewards/equipped/{slot}` (`{item}`, null = take off; `200` wallet, `409 NOT_OWNED`, `400 WRONG_SLOT`) | JWT |
|
||||||
| `GET` | `/api/library/games` (one row per set, the counted edition, with `platforms[]` of all its editions: Steam, PlayStation, then manual; `name` matches any edition; `source=STEAM,PSN` keeps rows with a platform of that source, a PlayStation title on a Steam row counts as `PSN`; ignored with `hidden=true`, which lists every hidden row; `400` on an unknown source; an old `hideDuplicates` param is ignored), `/api/library/games/{appid}` | JWT |
|
| `GET` | `/api/library/games` (one row per set, the counted edition, with `platforms[]` of all its editions: Steam, PlayStation, then manual, and `totalPlaytimeMinutes` (all platforms; `playtimeMinutes` stays the counted edition's own); `name` matches any edition; `source=STEAM,PSN` keeps rows with a platform of that source, a PlayStation title on a Steam row counts as `PSN`; ignored with `hidden=true`, which lists every hidden row; `400` on an unknown source; an old `hideDuplicates` param is ignored), `/api/library/games/{appid}` | JWT |
|
||||||
| `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT |
|
| `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT |
|
||||||
| `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`, `time_to_beat` = `{normallyMinutes, completelyMinutes}`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR |
|
| `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`, `time_to_beat` = `{normallyMinutes, completelyMinutes}`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR |
|
||||||
| `GET` | `/api/library/catalog?q=&page=&size=` (every game some user still has, by name part, `PageDto` of `{appid, name, imageUrl, platforms}`; `q` at most 100 characters; size 20, 40 or 80) | JWT, AUDITOR |
|
| `GET` | `/api/library/catalog?q=&page=&size=` (every game some user still has, by name part, `PageDto` of `{appid, name, imageUrl, platforms}`; `q` at most 100 characters; size 20, 40 or 80) | JWT, AUDITOR |
|
||||||
@@ -244,7 +244,7 @@ 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/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 |
|
| `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 |
|
| `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 |
|
| `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; `platforms[]` = `{source, platform, appid, playtimeMinutes, lastPlayedAt, achievementsUnlocked, achievementsTotal, perfect}` one line per platform of the entry, Steam, PS5, PS4, manual, each with its own progress, `appid` = the edition whose detail lists them; `totalPlaytimeMinutes` = their sum) | JWT |
|
||||||
| `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | 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 |
|
| `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 |
|
| `PUT` | `/api/library/backlog` (`{appids}` = the whole order; `204`) | JWT |
|
||||||
@@ -385,7 +385,8 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain`
|
|||||||
4. The worker loads the wishlist and the owned games from Steam. It updates `steam_game` and `user_game`, adds
|
4. The worker loads the wishlist and the owned games from Steam. It updates `steam_game` and `user_game`, adds
|
||||||
the playtime growth of the day to `user_game_playtime_day`, and soft-deletes Steam games the user no longer
|
the playtime growth of the day to `user_game_playtime_day`, and soft-deletes Steam games the user no longer
|
||||||
owns. The growth counts at most Steam's playtime of the last two weeks, so a game seen for the first time brings
|
owns. The growth counts at most Steam's playtime of the last two weeks, so a game seen for the first time brings
|
||||||
no old playtime, and one game gets at most 24 hours per day. A BACKLOG game with new playtime becomes PLAYING, unless the user chose its status. When Steam returns no
|
no old playtime, and one game gets at most 24 hours per day. Steam's `rtime_last_played` (unix seconds, 0 = never)
|
||||||
|
is stored in `user_game.last_played_at`; a missing or zero value keeps the old one. A BACKLOG game with new playtime becomes PLAYING, unless the user chose its status. When Steam returns no
|
||||||
games, the library stays as it is.
|
games, the library stays as it is.
|
||||||
5. `TimeToBeatService` and `MoodService` ask IGDB for missing or old time-to-beat, mood and genre data, for Steam
|
5. `TimeToBeatService` and `MoodService` ask IGDB for missing or old time-to-beat, mood and genre data, for Steam
|
||||||
games and manual games. Hidden games only get their playtime updated.
|
games and manual games. Hidden games only get their playtime updated.
|
||||||
@@ -394,7 +395,8 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain`
|
|||||||
FINISHED for a game whose ending achievement is unlocked while the user never chose its status.
|
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 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 transaction; its `ADDED`, `PLAYTIME` and trophy `ACHIEVEMENTS` changes join the same `sync_result`. It also saves
|
||||||
|
the play of every title (`PsnTitleRepository.savePlays`, "save title play" in the diagram below).
|
||||||
|
|
||||||
The frontend polls `GET /api/library/sync-status` until the run ends.
|
The frontend polls `GET /api/library/sync-status` until the run ends.
|
||||||
|
|
||||||
@@ -412,6 +414,7 @@ sequenceDiagram
|
|||||||
participant F as FollowDigestService
|
participant F as FollowDigestService
|
||||||
participant D as Db
|
participant D as Db
|
||||||
W->>P: run (titles, trophy summaries, changed lists)
|
W->>P: run (titles, trophy summaries, changed lists)
|
||||||
|
P->>D: save title play (user_psn_title)
|
||||||
P-->>W: ADDED, PLAYTIME, ACHIEVEMENTS (trophy gains)
|
P-->>W: ADDED, PLAYTIME, ACHIEVEMENTS (trophy gains)
|
||||||
W->>D: sync_result, sync_change (ADDED, STATUS, WISHLIST, PSN changes)
|
W->>D: sync_result, sync_change (ADDED, STATUS, WISHLIST, PSN changes)
|
||||||
W-->>A: LibrarySyncedEvent
|
W-->>A: LibrarySyncedEvent
|
||||||
@@ -560,12 +563,22 @@ IGDB id; remakes (8) keep their own id. `null` when IGDB does not know the game.
|
|||||||
drill-downs. There is no "Hide duplicates" filter any more; a hidden game is its own row under `hidden=true`. The
|
drill-downs. There is no "Hide duplicates" filter any more; a hidden game is its own row under `hidden=true`. The
|
||||||
friends row of the picker matches a friend's game to the caller's entry through `game_link`. Kept per edition on
|
friends row of the picker matches a friend's game to the caller's entry through `game_link`. Kept per edition on
|
||||||
purpose: the hidden list, the platform stat, the average achievement completion (per edition; the tile's game counts are per entry), `sumPlaytime` and the
|
purpose: the hidden list, the platform stat, the average achievement completion (per edition; the tile's game counts are per entry), `sumPlaytime` and the
|
||||||
community stats of the landing page. Card playtime is the counted edition's own; the set total comes with the
|
community stats of the landing page. The card playtime is the set total (`entryPlaytimes`, see "Detail per platform"). Pile Points pay a set once (see "Pile Points"), and the
|
||||||
detail page. Pile Points pay a set once (see "Pile Points"), and the
|
|
||||||
`MONTH` bonus reads `backlog_snapshot`, which counts a set once.
|
`MONTH` bonus reads `backlog_snapshot`, which counts a set once.
|
||||||
|
- **Detail per platform** (V44): the PSN step stores platform, minutes and last played per title in `user_psn_title`
|
||||||
|
(`LINKED` and `STEAM_OWNED`, every sync, whatever row the title is on); the view `psn_title_play` resolves a merged
|
||||||
|
key through `game_alias`. `PlatformPlay.lines` is the one rule for the detail lines: a Steam or manual row is one
|
||||||
|
line, a PlayStation row is its titles (or one line from the row before the first sync after V44), a manual row is
|
||||||
|
replaced by the titles of its own platform, and every other title linked to a Steam or manual row adds its
|
||||||
|
PlayStation line. The total is their sum; the card total
|
||||||
|
(`GameLinkRepository.entryPlaytimes`) follows the same rule in SQL, a test compares both. Each line carries its own
|
||||||
|
achievement or trophy progress; the full list stays on the edition's own detail page. Still per row, not per
|
||||||
|
platform: the playtime history (PSN minutes on a Steam row are not in it), the stats sums (most played, total
|
||||||
|
playtime, moon trips), the playtime sort, and rewards (Steam only). Until a user's next sync after V44, PSN-on-Steam
|
||||||
|
lines are missing and Steam's last played is empty.
|
||||||
- The game detail has `linked` and `editions` (the user's other listed games of the set); a game row has `editions`
|
- The game detail has `linked` and `editions` (the user's other listed games of the set); a game row has `editions`
|
||||||
(size of the set in the user's library, `null` below 2) and `platforms` (`source` + `platform` per edition, for
|
(size of the set in the user's library, `null` below 2), `platforms` (`source` + `platform` per edition, for
|
||||||
the icons of the card).
|
the icons of the card) and `totalPlaytimeMinutes`.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
@@ -577,7 +590,9 @@ flowchart LR
|
|||||||
Counted --> Member["user_game_entry_member"]
|
Counted --> Member["user_game_entry_member"]
|
||||||
Member --> Platform["user_game_entry_platform"]
|
Member --> Platform["user_game_entry_platform"]
|
||||||
Platform -->|"platforms[]"| Card["library card, lists"]
|
Platform -->|"platforms[]"| Card["library card, lists"]
|
||||||
Member -->|"summed playtime"| Card
|
Member -->|"totalPlaytimeMinutes"| Card
|
||||||
|
Title["user_psn_title play (V44)"] -->|"psn_title_play"| Member
|
||||||
|
Title -->|"platforms[] of the detail"| Detail["game detail"]
|
||||||
Set -->|"join: align_user_game_set"| Row["user_game (status, rating, notes, priority)"]
|
Set -->|"join: align_user_game_set"| Row["user_game (status, rating, notes, priority)"]
|
||||||
Row -->|"update: user_game_set_share"| Row
|
Row -->|"update: user_game_set_share"| Row
|
||||||
Row -.->|"old values"| Backup["user_game_set_backup"]
|
Row -.->|"old values"| Backup["user_game_set_backup"]
|
||||||
@@ -694,6 +709,8 @@ project ships it; all values in `app.psn.*`). `PsnAccountService` in `library` h
|
|||||||
spelling or platform that the user owns is only linked. Everything else goes to the review: a READY `PSN` import with
|
spelling or platform that the user owns is only linked. Everything else goes to the review: a READY `PSN` import with
|
||||||
the matched rows is created, or the rows are appended to the one that waits (`reviewImportId`). The review page is
|
the matched rows is created, or the rows are appended to the one that waits (`reviewImportId`). The review page is
|
||||||
the import page; saving a PSN import links the checked lines and dismisses the unchecked ones for good.
|
the import page; saving a PSN import links the checked lines and dismisses the unchecked ones for good.
|
||||||
|
- Every `LINKED` or `STEAM_OWNED` title also keeps its own platform, minutes and last played in `user_psn_title` (V44),
|
||||||
|
so the detail shows PlayStation play next to Steam's on a shared row. The Steam row's own playtime stays Steam's.
|
||||||
- A `LINKED` title updates its row (`source = PSN`, not removed): playtime, `last_played_at`, `PLAYTIME` changes and the
|
- A `LINKED` title updates its row (`source = PSN`, not removed): playtime, `last_played_at`, `PLAYTIME` changes and the
|
||||||
playtime history; `playtime_2weeks` is the sum of the history of the last 14 days. A game the user deleted stays
|
playtime history; `playtime_2weeks` is the sum of the history of the last 14 days. A game the user deleted stays
|
||||||
`LINKED` and is not added again. A game also owned on Steam stays one row (the Steam row wins).
|
`LINKED` and is not added again. A game also owned on Steam stays one row (the Steam row wins).
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ public record GameDetailDto(
|
|||||||
int playtime2Weeks,
|
int playtime2Weeks,
|
||||||
Instant firstSeenAt,
|
Instant firstSeenAt,
|
||||||
Instant lastSeenAt,
|
Instant lastSeenAt,
|
||||||
// from PlayStation; null for Steam rows
|
// from PlayStation or Steam; null when not known
|
||||||
Instant lastPlayedAt,
|
Instant lastPlayedAt,
|
||||||
GameStatus status,
|
GameStatus status,
|
||||||
Integer rating,
|
Integer rating,
|
||||||
@@ -57,7 +57,11 @@ public record GameDetailDto(
|
|||||||
// see GameDto#comingOn
|
// 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
|
// Steam's set first, then the PlayStation sets, PS5 before PS4; the counts above are Steam's only
|
||||||
List<AchievementSetDto> achievementSets
|
List<AchievementSetDto> achievementSets,
|
||||||
|
// one line per platform of the entry, Steam first; empty for a hidden game
|
||||||
|
List<PlatformPlayDto> platforms,
|
||||||
|
// the sum of the platform lines; the counts above and playtimeMinutes stay this edition's own
|
||||||
|
int totalPlaytimeMinutes
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import java.util.List;
|
|||||||
* @param coming the game is not released yet and nobody played it; it does not count on the pile
|
* @param coming the game is not released yet and nobody played it; it does not count on the pile
|
||||||
* @param comingOn the release date of a coming game; null for released games and for "coming soon"
|
* @param comingOn the release date of a coming game; null for released games and for "coming soon"
|
||||||
* @param platforms the platforms of every edition of the entry, Steam first; empty for a hidden game
|
* @param platforms the platforms of every edition of the entry, Steam first; empty for a hidden game
|
||||||
|
* @param totalPlaytimeMinutes the playtime of every platform of the entry; {@code playtimeMinutes} stays the counted
|
||||||
|
* edition's own
|
||||||
*/
|
*/
|
||||||
public record GameDto(
|
public record GameDto(
|
||||||
Long appid,
|
Long appid,
|
||||||
@@ -39,5 +41,6 @@ public record GameDto(
|
|||||||
Integer editions,
|
Integer editions,
|
||||||
boolean coming,
|
boolean coming,
|
||||||
LocalDate comingOn,
|
LocalDate comingOn,
|
||||||
List<PlatformDto> platforms
|
List<PlatformDto> platforms,
|
||||||
|
int totalPlaytimeMinutes
|
||||||
) {}
|
) {}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package core_service.modules.library.api.dto;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.GameSource;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One platform line of a game's detail.
|
||||||
|
*
|
||||||
|
* @param platform e.g. "PS5"; null when the edition has none
|
||||||
|
* @param appid the edition the line belongs to; its own detail page lists the achievements
|
||||||
|
* @param lastPlayedAt null when not known
|
||||||
|
* @param achievementsUnlocked null when the platform has no achievements for the user
|
||||||
|
* @param achievementsTotal null when the platform has no achievements for the user
|
||||||
|
* @param perfect every achievement unlocked, or every trophy set perfect
|
||||||
|
*/
|
||||||
|
public record PlatformPlayDto(GameSource source, String platform, long appid, int playtimeMinutes, Instant lastPlayedAt,
|
||||||
|
Integer achievementsUnlocked, Integer achievementsTotal, boolean perfect) {}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package core_service.modules.library.domain;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One platform's play of a game entry: what the detail lists and the card totals. The SQL of
|
||||||
|
* {@code GameLinkRepository#entryPlaytimes} follows the same rule; a test pins both.
|
||||||
|
*
|
||||||
|
* @param platform e.g. "PS5"; null when the edition has none
|
||||||
|
* @param appid the edition the line comes from
|
||||||
|
* @param lastPlayedAt null when not known
|
||||||
|
*/
|
||||||
|
public record PlatformPlay(GameSource source, String platform, long appid, int playtimeMinutes, Instant lastPlayedAt) {
|
||||||
|
|
||||||
|
private static final Comparator<PlatformPlay> ORDER = Comparator
|
||||||
|
.comparingInt((PlatformPlay line) -> line.source() == GameSource.STEAM ? 0
|
||||||
|
: line.source() == GameSource.PSN ? 1 : 2)
|
||||||
|
.thenComparing(PlatformPlay::platform, Comparator.nullsLast(Comparator.reverseOrder()))
|
||||||
|
.thenComparingLong(PlatformPlay::appid);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Steam row is one line; a PlayStation row is its titles, or itself while it has no stored title
|
||||||
|
* play; a manual row is replaced by the titles of its own platform; every title with stored play on any row adds its own PlayStation line.
|
||||||
|
*
|
||||||
|
* @param titles the stored play of the titles linked to the rows; other keys are ignored
|
||||||
|
*/
|
||||||
|
public static List<PlatformPlay> lines(List<UserGame> rows, List<TitlePlay> titles) {
|
||||||
|
List<PlatformPlay> lines = new ArrayList<>();
|
||||||
|
for (UserGame row : rows) {
|
||||||
|
List<TitlePlay> own = titles.stream().filter(title -> row.getAppid().equals(title.appid())).toList();
|
||||||
|
boolean replaced = switch (row.getSource()) {
|
||||||
|
case PSN -> !own.isEmpty();
|
||||||
|
case MANUAL -> own.stream().anyMatch(title -> row.getPlatform() != null
|
||||||
|
&& row.getPlatform().equals(title.platform()));
|
||||||
|
default -> false;
|
||||||
|
};
|
||||||
|
if (!replaced) {
|
||||||
|
lines.add(new PlatformPlay(row.getSource(), row.getPlatform(), row.getAppid(), row.getPlaytimeMinutes(),
|
||||||
|
row.getLastPlayedAt()));
|
||||||
|
}
|
||||||
|
own.forEach(title -> lines.add(new PlatformPlay(GameSource.PSN, title.platform(), row.getAppid(),
|
||||||
|
title.playtimeMinutes(), title.lastPlayedAt())));
|
||||||
|
}
|
||||||
|
lines.sort(ORDER);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int total(List<PlatformPlay> lines) {
|
||||||
|
return (int) Math.min(lines.stream().mapToLong(PlatformPlay::playtimeMinutes).sum(), Integer.MAX_VALUE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,4 +6,9 @@ package core_service.modules.library.domain;
|
|||||||
* @param listed true when the trophy rows are stored; false until a sync has fetched them
|
* @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,
|
public record PsnTrophySetProgress(String id, String platform, String name, int total, int earned,
|
||||||
boolean platinumEarned, boolean listed) {}
|
boolean platinumEarned, boolean listed) {
|
||||||
|
|
||||||
|
public boolean perfect() {
|
||||||
|
return platinumEarned || earned >= total;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package core_service.modules.library.domain;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The play of one PlayStation title (V44).
|
||||||
|
*
|
||||||
|
* @param appid the game key the title is linked to; null when saving, the title is addressed by its key
|
||||||
|
* @param platform "PS5" or "PS4"
|
||||||
|
* @param lastPlayedAt may be null
|
||||||
|
*/
|
||||||
|
public record TitlePlay(String psnKey, Long appid, String platform, int playtimeMinutes, Instant lastPlayedAt) {}
|
||||||
@@ -91,7 +91,7 @@ public class UserGame {
|
|||||||
@Column(name = "import_id")
|
@Column(name = "import_id")
|
||||||
private Long importId;
|
private Long importId;
|
||||||
|
|
||||||
/** From PlayStation; null for Steam rows. */
|
/** From PlayStation or Steam; null when not known. */
|
||||||
@Column(name = "last_played_at")
|
@Column(name = "last_played_at")
|
||||||
private Instant lastPlayedAt;
|
private Instant lastPlayedAt;
|
||||||
|
|
||||||
|
|||||||
@@ -335,6 +335,34 @@ public class GameLinkRepository {
|
|||||||
return platforms;
|
return platforms;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return per given entry key, the playtime of all its editions: a row's own minutes plus the minutes of the
|
||||||
|
* PlayStation titles on it, except that a PlayStation row with title play, or a manual row with a title of its platform,
|
||||||
|
* counts its titles only (a PlayStation row's own minutes are their sum). Capped at the int range. Same rule as {@code PlatformPlay#total}.
|
||||||
|
*/
|
||||||
|
public Map<Long, Integer> entryPlaytimes(long userId, Collection<Long> appids) {
|
||||||
|
Map<Long, Integer> playtimes = new HashMap<>();
|
||||||
|
if (appids.isEmpty()) {
|
||||||
|
return playtimes;
|
||||||
|
}
|
||||||
|
jdbc.query("""
|
||||||
|
select e.appid, least(sum(case when (m.source = 'PSN' and t.minutes is not null)
|
||||||
|
or (m.source = 'MANUAL' and exists (
|
||||||
|
select 1 from psn_title_play x where x.user_id = :user
|
||||||
|
and x.appid = m.appid and x.platform = m.platform)) then 0
|
||||||
|
else m.playtime_minutes end + coalesce(t.minutes, 0)),
|
||||||
|
2147483647) as minutes
|
||||||
|
from user_game_entry_member e
|
||||||
|
join listed_user_game m on m.user_id = e.user_id and m.appid = e.member_appid
|
||||||
|
left join (select appid, sum(playtime_minutes) as minutes from psn_title_play
|
||||||
|
where user_id = :user group by appid) t on t.appid = m.appid
|
||||||
|
where e.user_id = :user and e.appid in (:appids)
|
||||||
|
group by e.appid
|
||||||
|
""", new MapSqlParameterSource("user", userId).addValue("appids", appids),
|
||||||
|
rs -> { playtimes.put(rs.getLong("appid"), rs.getInt("minutes")); });
|
||||||
|
return playtimes;
|
||||||
|
}
|
||||||
|
|
||||||
private static MapSqlParameterSource keys(long appid, long otherAppid) {
|
private static MapSqlParameterSource keys(long appid, long otherAppid) {
|
||||||
return new MapSqlParameterSource("a", appid).addValue("b", otherAppid);
|
return new MapSqlParameterSource("a", appid).addValue("b", otherAppid);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
package core_service.modules.library.repo;
|
package core_service.modules.library.repo;
|
||||||
|
|
||||||
import core_service.modules.library.domain.PsnTitle;
|
import core_service.modules.library.domain.PsnTitle;
|
||||||
|
import core_service.modules.library.domain.TitlePlay;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,4 +69,40 @@ public class PsnTitleRepository {
|
|||||||
psnKeys.stream().map(key -> new MapSqlParameterSource("user", userId).addValue("key", key))
|
psnKeys.stream().map(key -> new MapSqlParameterSource("user", userId).addValue("key", key))
|
||||||
.toArray(MapSqlParameterSource[]::new));
|
.toArray(MapSqlParameterSource[]::new));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the play of the LINKED and STEAM_OWNED titles among {@code plays}, addressed by {@code psnKey}; other
|
||||||
|
* rows are left alone.
|
||||||
|
*/
|
||||||
|
public void savePlays(long userId, List<TitlePlay> plays) {
|
||||||
|
if (plays.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
jdbc.batchUpdate("""
|
||||||
|
update user_psn_title set platform = :platform, playtime_minutes = :minutes, last_played_at = :played
|
||||||
|
where user_id = :user and psn_key = :key and state in ('LINKED', 'STEAM_OWNED')""",
|
||||||
|
plays.stream().map(play -> new MapSqlParameterSource("user", userId).addValue("key", play.psnKey())
|
||||||
|
.addValue("platform", play.platform()).addValue("minutes", play.playtimeMinutes())
|
||||||
|
.addValue("played", play.lastPlayedAt() == null ? null : Timestamp.from(play.lastPlayedAt())))
|
||||||
|
.toArray(MapSqlParameterSource[]::new));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stored play of the titles linked to these keys (V44), a merged key resolved to its kept key.
|
||||||
|
*/
|
||||||
|
public List<TitlePlay> findPlays(long userId, Collection<Long> appids) {
|
||||||
|
if (appids.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return jdbc.query("""
|
||||||
|
select psn_key, appid, platform, playtime_minutes, last_played_at from psn_title_play
|
||||||
|
where user_id = :user and appid in (:appids) order by psn_key""",
|
||||||
|
new MapSqlParameterSource("user", userId).addValue("appids", appids),
|
||||||
|
(rs, row) -> new TitlePlay(rs.getString("psn_key"), rs.getLong("appid"), rs.getString("platform"),
|
||||||
|
rs.getInt("playtime_minutes"), instant(rs.getTimestamp("last_played_at"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Instant instant(Timestamp timestamp) {
|
||||||
|
return timestamp == null ? null : timestamp.toInstant();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -51,8 +51,9 @@ public class QuestionRepository {
|
|||||||
left join game_time_to_beat t on t.appid = ug.appid
|
left join game_time_to_beat t on t.appid = ug.appid
|
||||||
left join game_mood m on m.appid = ug.appid
|
left join game_mood m on m.appid = ug.appid
|
||||||
left join (select m.counted as appid, max(og.playtime_2weeks) as playtime_2weeks,
|
left join (select m.counted as appid, max(og.playtime_2weeks) as playtime_2weeks,
|
||||||
max(og.last_played_at) as last_played_at
|
greatest(max(og.last_played_at), max(p.last_played_at)) as last_played_at
|
||||||
from members m join listed_user_game og on og.user_id = :userId and og.appid = m.member
|
from members m join listed_user_game og on og.user_id = :userId and og.appid = m.member
|
||||||
|
left join psn_title_play p on p.user_id = :userId and p.appid = m.member
|
||||||
group by m.counted) a on a.appid = ug.appid
|
group by m.counted) a on a.appid = ug.appid
|
||||||
left join (select m.counted as appid, max(d.day) as last_day
|
left join (select m.counted as appid, max(d.day) as last_day
|
||||||
from members m join user_game_playtime_day d on d.user_id = :userId and d.appid = m.member
|
from members m join user_game_playtime_day d on d.user_id = :userId and d.appid = m.member
|
||||||
|
|||||||
@@ -6,19 +6,23 @@ import core_service.modules.library.api.dto.CorrectionMarkDto;
|
|||||||
import core_service.modules.library.api.dto.EditionDto;
|
import core_service.modules.library.api.dto.EditionDto;
|
||||||
import core_service.modules.library.api.dto.FinishedHintDto;
|
import core_service.modules.library.api.dto.FinishedHintDto;
|
||||||
import core_service.modules.library.api.dto.GameDetailDto;
|
import core_service.modules.library.api.dto.GameDetailDto;
|
||||||
|
import core_service.modules.library.api.dto.PlatformPlayDto;
|
||||||
import core_service.modules.library.domain.AchievementKind;
|
import core_service.modules.library.domain.AchievementKind;
|
||||||
import core_service.modules.library.domain.AchievementProgress;
|
import core_service.modules.library.domain.AchievementProgress;
|
||||||
import core_service.modules.library.domain.ComingGame;
|
import core_service.modules.library.domain.ComingGame;
|
||||||
|
import core_service.modules.library.domain.Edition;
|
||||||
import core_service.modules.library.domain.GameAchievementSchema;
|
import core_service.modules.library.domain.GameAchievementSchema;
|
||||||
import core_service.modules.library.domain.GameAchievementSchemaMeta;
|
import core_service.modules.library.domain.GameAchievementSchemaMeta;
|
||||||
import core_service.modules.library.domain.FinishedHint;
|
import core_service.modules.library.domain.FinishedHint;
|
||||||
import core_service.modules.library.domain.GameMoods;
|
import core_service.modules.library.domain.GameMoods;
|
||||||
import core_service.modules.library.domain.GameTimeToBeat;
|
import core_service.modules.library.domain.GameTimeToBeat;
|
||||||
|
import core_service.modules.library.domain.PlatformPlay;
|
||||||
import core_service.modules.library.domain.PsnTrophy;
|
import core_service.modules.library.domain.PsnTrophy;
|
||||||
import core_service.modules.library.domain.PsnTrophySetProgress;
|
import core_service.modules.library.domain.PsnTrophySetProgress;
|
||||||
import core_service.modules.library.domain.GameSource;
|
import core_service.modules.library.domain.GameSource;
|
||||||
import core_service.modules.library.domain.GameSpeedrun;
|
import core_service.modules.library.domain.GameSpeedrun;
|
||||||
import core_service.modules.library.domain.SteamGame;
|
import core_service.modules.library.domain.SteamGame;
|
||||||
|
import core_service.modules.library.domain.TitlePlay;
|
||||||
import core_service.modules.library.domain.UserGame;
|
import core_service.modules.library.domain.UserGame;
|
||||||
import core_service.modules.library.domain.UserGameAchievement;
|
import core_service.modules.library.domain.UserGameAchievement;
|
||||||
import core_service.modules.library.domain.UserGameAchievementMeta;
|
import core_service.modules.library.domain.UserGameAchievementMeta;
|
||||||
@@ -32,6 +36,7 @@ import core_service.modules.library.repo.GameMoodsRepository;
|
|||||||
import core_service.modules.library.repo.GameTimeToBeatRepository;
|
import core_service.modules.library.repo.GameTimeToBeatRepository;
|
||||||
import core_service.modules.library.repo.GameAchievementSchemaRepository;
|
import core_service.modules.library.repo.GameAchievementSchemaRepository;
|
||||||
import core_service.modules.library.repo.GameSpeedrunRepository;
|
import core_service.modules.library.repo.GameSpeedrunRepository;
|
||||||
|
import core_service.modules.library.repo.PsnTitleRepository;
|
||||||
import core_service.modules.library.repo.PsnTrophyRepository;
|
import core_service.modules.library.repo.PsnTrophyRepository;
|
||||||
import core_service.modules.library.repo.SteamGameRepository;
|
import core_service.modules.library.repo.SteamGameRepository;
|
||||||
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
|
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
|
||||||
@@ -81,6 +86,7 @@ public class GameDetailService {
|
|||||||
private final GameLinkRepository gameLinkRepository;
|
private final GameLinkRepository gameLinkRepository;
|
||||||
private final GameMergeRepository mergeRepository;
|
private final GameMergeRepository mergeRepository;
|
||||||
private final PsnTrophyRepository psnTrophyRepository;
|
private final PsnTrophyRepository psnTrophyRepository;
|
||||||
|
private final PsnTitleRepository psnTitleRepository;
|
||||||
|
|
||||||
@Value("${app.library.achievement-ttl-hours:1}")
|
@Value("${app.library.achievement-ttl-hours:1}")
|
||||||
private long achievementTtlHours;
|
private long achievementTtlHours;
|
||||||
@@ -207,7 +213,7 @@ public class GameDetailService {
|
|||||||
return psnTrophyRepository.findSets(userGame.getUserId(), userGame.getAppid()).stream()
|
return psnTrophyRepository.findSets(userGame.getUserId(), userGame.getAppid()).stream()
|
||||||
.map(set -> new AchievementSetDto(set.id(), AchievementKind.PSN, set.platform(), set.name(), set.total(),
|
.map(set -> new AchievementSetDto(set.id(), AchievementKind.PSN, set.platform(), set.name(), set.total(),
|
||||||
AchievementProgress.capped(set.earned(), set.total()),
|
AchievementProgress.capped(set.earned(), set.total()),
|
||||||
set.platinumEarned() || set.earned() >= set.total(), true, set.listed()))
|
set.perfect(), true, set.listed()))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,6 +247,8 @@ public class GameDetailService {
|
|||||||
.orElse(null);
|
.orElse(null);
|
||||||
Optional<ComingGame> coming = comingGameRepository.findById(userGame.getAppid());
|
Optional<ComingGame> coming = comingGameRepository.findById(userGame.getAppid());
|
||||||
boolean hasAchievements = total > 0 || !psnSets.isEmpty();
|
boolean hasAchievements = total > 0 || !psnSets.isEmpty();
|
||||||
|
List<Edition> linked = gameLinkRepository.linkedEditions(userGame.getUserId(), userGame.getAppid());
|
||||||
|
List<PlatformPlay> plays = platformPlays(userGame, linked);
|
||||||
List<AchievementSetDto> sets = new ArrayList<>();
|
List<AchievementSetDto> sets = new ArrayList<>();
|
||||||
if (total > 0) {
|
if (total > 0) {
|
||||||
sets.add(new AchievementSetDto("steam", AchievementKind.STEAM, null, null, total, unlocked,
|
sets.add(new AchievementSetDto("steam", AchievementKind.STEAM, null, null, total, unlocked,
|
||||||
@@ -284,12 +292,76 @@ public class GameDetailService {
|
|||||||
gameCorrectionRepository.findByAppidOrderByField(userGame.getAppid()).stream()
|
gameCorrectionRepository.findByAppidOrderByField(userGame.getAppid()).stream()
|
||||||
.map(c -> new CorrectionMarkDto(c.getField(), c.getCorrectedAt())).toList(),
|
.map(c -> new CorrectionMarkDto(c.getField(), c.getCorrectedAt())).toList(),
|
||||||
gameLinkRepository.setOf(userGame.getAppid()).isPresent(),
|
gameLinkRepository.setOf(userGame.getAppid()).isPresent(),
|
||||||
gameLinkRepository.linkedEditions(userGame.getUserId(), userGame.getAppid()).stream()
|
linked.stream()
|
||||||
.map(e -> new EditionDto(e.appid(), e.name(), e.platform(), e.source(), e.status(), e.imageUrl()))
|
.map(e -> new EditionDto(e.appid(), e.name(), e.platform(), e.source(), e.status(), e.imageUrl()))
|
||||||
.toList(),
|
.toList(),
|
||||||
coming.isPresent(),
|
coming.isPresent(),
|
||||||
coming.map(ComingGame::getReleaseDate).orElse(null),
|
coming.map(ComingGame::getReleaseDate).orElse(null),
|
||||||
sets
|
sets,
|
||||||
|
plays.stream().map(line -> toDto(line, userGame, total, unlocked, statsVisible)).toList(),
|
||||||
|
PlatformPlay.total(plays)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A hidden game shows only itself.
|
||||||
|
private List<PlatformPlay> platformPlays(UserGame userGame, List<Edition> linked) {
|
||||||
|
Long userId = userGame.getUserId();
|
||||||
|
List<UserGame> rows = new ArrayList<>(List.of(userGame));
|
||||||
|
if (userGame.getHiddenAt() == null) {
|
||||||
|
rows.addAll(userGameRepository.findAllById(
|
||||||
|
linked.stream().map(e -> new UserGameId(userId, e.appid())).toList()));
|
||||||
|
}
|
||||||
|
List<TitlePlay> titles = psnTitleRepository.findPlays(userId, rows.stream().map(UserGame::getAppid).toList());
|
||||||
|
return PlatformPlay.lines(rows, titles);
|
||||||
|
}
|
||||||
|
|
||||||
|
// total, unlocked and statsVisible are the current game's Steam values, already read.
|
||||||
|
private PlatformPlayDto toDto(PlatformPlay line, UserGame current, int total, int unlocked, boolean statsVisible) {
|
||||||
|
Long userId = current.getUserId();
|
||||||
|
Progress progress = switch (line.source()) {
|
||||||
|
case STEAM -> line.appid() == current.getAppid()
|
||||||
|
? steamProgress(total, unlocked, statsVisible) : steamProgress(userId, line.appid());
|
||||||
|
case PSN -> psnProgress(userId, line);
|
||||||
|
case MANUAL -> Progress.NONE;
|
||||||
|
};
|
||||||
|
return new PlatformPlayDto(line.source(), line.platform(), line.appid(), line.playtimeMinutes(),
|
||||||
|
line.lastPlayedAt(), progress.unlocked(), progress.total(), progress.perfect());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param unlocked null when Steam hides the stats
|
||||||
|
*/
|
||||||
|
private record Progress(Integer unlocked, Integer total, boolean perfect) {
|
||||||
|
static final Progress NONE = new Progress(null, null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Progress steamProgress(int total, int unlocked, boolean statsVisible) {
|
||||||
|
if (total <= 0) {
|
||||||
|
return Progress.NONE;
|
||||||
|
}
|
||||||
|
return new Progress(statsVisible ? unlocked : null, total, statsVisible && unlocked >= total);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Progress steamProgress(Long userId, long appid) {
|
||||||
|
GameAchievementSchemaMeta schema = schemaMetaRepository.findById(appid).orElse(null);
|
||||||
|
if (schema == null || !schema.isHasSchema() || schema.getAchievementsTotal() <= 0) {
|
||||||
|
return Progress.NONE;
|
||||||
|
}
|
||||||
|
int total = schema.getAchievementsTotal();
|
||||||
|
UserGameAchievementMeta meta = userAchievementMetaRepository.findByUserIdAndAppid(userId, appid).orElse(null);
|
||||||
|
return steamProgress(total, meta == null ? 0 : AchievementProgress.capped(meta.getAchievementsUnlocked(), total),
|
||||||
|
meta == null || meta.isStatsVisible());
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sets of the line's platform; every set of the key when the line has none.
|
||||||
|
private Progress psnProgress(Long userId, PlatformPlay line) {
|
||||||
|
List<PsnTrophySetProgress> sets = psnTrophyRepository.findSets(userId, line.appid()).stream()
|
||||||
|
.filter(set -> line.platform() == null || line.platform().equals(set.platform())).toList();
|
||||||
|
if (sets.isEmpty()) {
|
||||||
|
return Progress.NONE;
|
||||||
|
}
|
||||||
|
return new Progress(sets.stream().mapToInt(set -> AchievementProgress.capped(set.earned(), set.total())).sum(),
|
||||||
|
sets.stream().mapToInt(PsnTrophySetProgress::total).sum(),
|
||||||
|
sets.stream().allMatch(PsnTrophySetProgress::perfect));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -288,6 +288,7 @@ public class LibraryQueryService {
|
|||||||
.collect(Collectors.toMap(UserGameAchievementMeta::getAppid, m -> m));
|
.collect(Collectors.toMap(UserGameAchievementMeta::getAppid, m -> m));
|
||||||
Map<Long, Integer> editions = gameLinkRepository.editionCounts(games.getFirst().getUserId(), appids);
|
Map<Long, Integer> editions = gameLinkRepository.editionCounts(games.getFirst().getUserId(), appids);
|
||||||
Map<Long, List<EntryPlatform>> platforms = gameLinkRepository.entryPlatforms(games.getFirst().getUserId(), appids);
|
Map<Long, List<EntryPlatform>> platforms = gameLinkRepository.entryPlatforms(games.getFirst().getUserId(), appids);
|
||||||
|
Map<Long, Integer> totals = gameLinkRepository.entryPlaytimes(games.getFirst().getUserId(), appids);
|
||||||
Map<Long, ComingGame> coming = comingOf(appids);
|
Map<Long, ComingGame> coming = comingOf(appids);
|
||||||
Map<Long, AchievementProgress> trophies = AchievementProgress
|
Map<Long, AchievementProgress> trophies = AchievementProgress
|
||||||
.perGame(psnTrophyRepository.findProgress(games.getFirst().getUserId())).stream()
|
.perGame(psnTrophyRepository.findProgress(games.getFirst().getUserId())).stream()
|
||||||
@@ -296,7 +297,7 @@ public class LibraryQueryService {
|
|||||||
.map(ug -> toDto(ug, meta.get(ug.getAppid()), times.get(ug.getAppid()),
|
.map(ug -> toDto(ug, meta.get(ug.getAppid()), times.get(ug.getAppid()),
|
||||||
schemas.get(ug.getAppid()), unlocks.get(ug.getAppid()), editions.get(ug.getAppid()),
|
schemas.get(ug.getAppid()), unlocks.get(ug.getAppid()), editions.get(ug.getAppid()),
|
||||||
platforms.getOrDefault(ug.getAppid(), List.of()), coming.get(ug.getAppid()),
|
platforms.getOrDefault(ug.getAppid(), List.of()), coming.get(ug.getAppid()),
|
||||||
trophies.get(ug.getAppid())))
|
trophies.get(ug.getAppid()), totals.getOrDefault(ug.getAppid(), ug.getPlaytimeMinutes())))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,7 +457,7 @@ public class LibraryQueryService {
|
|||||||
private static GameDto toDto(UserGame ug, SteamGame meta, GameTimeToBeat time,
|
private static GameDto toDto(UserGame ug, SteamGame meta, GameTimeToBeat time,
|
||||||
GameAchievementSchemaMeta schema, UserGameAchievementMeta unlocks,
|
GameAchievementSchemaMeta schema, UserGameAchievementMeta unlocks,
|
||||||
Integer editions, List<EntryPlatform> platforms, ComingGame coming,
|
Integer editions, List<EntryPlatform> platforms, ComingGame coming,
|
||||||
AchievementProgress trophies) {
|
AchievementProgress trophies, int totalPlaytimeMinutes) {
|
||||||
Integer achievementsTotal = ug.getSource() == GameSource.STEAM && schema != null && schema.isHasSchema()
|
Integer achievementsTotal = ug.getSource() == GameSource.STEAM && schema != null && schema.isHasSchema()
|
||||||
&& schema.getAchievementsTotal() > 0 ? schema.getAchievementsTotal() : null;
|
&& schema.getAchievementsTotal() > 0 ? schema.getAchievementsTotal() : null;
|
||||||
Integer achievementsUnlocked = achievementsTotal != null && unlocks != null && unlocks.isStatsVisible()
|
Integer achievementsUnlocked = achievementsTotal != null && unlocks != null && unlocks.isStatsVisible()
|
||||||
@@ -486,7 +487,8 @@ public class LibraryQueryService {
|
|||||||
editions,
|
editions,
|
||||||
coming != null,
|
coming != null,
|
||||||
coming != null ? coming.getReleaseDate() : null,
|
coming != null ? coming.getReleaseDate() : null,
|
||||||
platforms.stream().map(p -> new PlatformDto(p.source(), p.platform())).toList());
|
platforms.stream().map(p -> new PlatformDto(p.source(), p.platform())).toList(),
|
||||||
|
totalPlaytimeMinutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The tile shows what counts for the game: its best set, Steam's on a tie.
|
// The tile shows what counts for the game: its best set, Steam's on a tie.
|
||||||
|
|||||||
@@ -310,9 +310,12 @@ public class LibrarySyncWorker {
|
|||||||
if (existing == null || existing.getRemovedAt() != null) {
|
if (existing == null || existing.getRemovedAt() != null) {
|
||||||
changes.add(SyncChange.added(appid));
|
changes.add(SyncChange.added(appid));
|
||||||
}
|
}
|
||||||
|
Instant lastPlayed = g.rtimeLastPlayed() != null && g.rtimeLastPlayed() > 0
|
||||||
|
? Instant.ofEpochSecond(g.rtimeLastPlayed()) : null;
|
||||||
if (existing == null) {
|
if (existing == null) {
|
||||||
UserGame created = new UserGame(userId, appid, playtime, now, now);
|
UserGame created = new UserGame(userId, appid, playtime, now, now);
|
||||||
created.setPlaytime2Weeks(playtime2Weeks);
|
created.setPlaytime2Weeks(playtime2Weeks);
|
||||||
|
created.setLastPlayedAt(lastPlayed);
|
||||||
userGameRepository.save(created);
|
userGameRepository.save(created);
|
||||||
} else {
|
} else {
|
||||||
existing.setPlaytimeMinutes(playtime);
|
existing.setPlaytimeMinutes(playtime);
|
||||||
@@ -321,6 +324,9 @@ public class LibrarySyncWorker {
|
|||||||
existing.setRemovedAt(null);
|
existing.setRemovedAt(null);
|
||||||
existing.setSource(GameSource.STEAM);
|
existing.setSource(GameSource.STEAM);
|
||||||
existing.setPlatform(null);
|
existing.setPlatform(null);
|
||||||
|
if (lastPlayed != null) {
|
||||||
|
existing.setLastPlayedAt(lastPlayed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (playedOn == null || takesOverManualGame) {
|
if (playedOn == null || takesOverManualGame) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import core_service.modules.library.domain.GameSource;
|
|||||||
import core_service.modules.library.domain.IgdbGame;
|
import core_service.modules.library.domain.IgdbGame;
|
||||||
import core_service.modules.library.domain.PsnTitle;
|
import core_service.modules.library.domain.PsnTitle;
|
||||||
import core_service.modules.library.domain.SyncChange;
|
import core_service.modules.library.domain.SyncChange;
|
||||||
|
import core_service.modules.library.domain.TitlePlay;
|
||||||
import core_service.modules.library.domain.UserGame;
|
import core_service.modules.library.domain.UserGame;
|
||||||
import core_service.modules.library.domain.UserGameId;
|
import core_service.modules.library.domain.UserGameId;
|
||||||
import core_service.modules.library.repo.GameImportRepository;
|
import core_service.modules.library.repo.GameImportRepository;
|
||||||
@@ -112,6 +113,8 @@ public class PsnSyncStep {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
saveReview(userId, review);
|
saveReview(userId, review);
|
||||||
|
psnTitleRepository.savePlays(userId, lines.stream().map(line -> new TitlePlay(line.psnKey(), null,
|
||||||
|
line.platform(), line.playtimeMinutes(), lastPlayed.get(line.psnKey()))).toList());
|
||||||
int added = changes.size();
|
int added = changes.size();
|
||||||
changes.addAll(trophyStep.run(userId, found.get().accessToken(), titles));
|
changes.addAll(trophyStep.run(userId, found.get().accessToken(), titles));
|
||||||
log.info("PSN sync user={} added {} titles, {} wait for review, {} games gained trophies", userId, added,
|
log.info("PSN sync user={} added {} titles, {} wait for review, {} games gained trophies", userId, added,
|
||||||
|
|||||||
+3
-1
@@ -21,6 +21,8 @@ public record SteamOwnedGamesResponse(
|
|||||||
@JsonProperty("playtime_forever") Integer playtimeForever,
|
@JsonProperty("playtime_forever") Integer playtimeForever,
|
||||||
@JsonProperty("img_icon_url") String imgIconUrl,
|
@JsonProperty("img_icon_url") String imgIconUrl,
|
||||||
@JsonProperty("has_community_visible_stats") Boolean hasCommunityVisibleStats,
|
@JsonProperty("has_community_visible_stats") Boolean hasCommunityVisibleStats,
|
||||||
@JsonProperty("playtime_2weeks") Integer playtime2Weeks
|
@JsonProperty("playtime_2weeks") Integer playtime2Weeks,
|
||||||
|
// unix seconds; 0 or absent when never played
|
||||||
|
@JsonProperty("rtime_last_played") Long rtimeLastPlayed
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- One entry per game across platforms (Gitea #34, slice 3). PlayStation play per title, so a title linked to a Steam
|
||||||
|
-- or manual row keeps its own platform, minutes and last played. Null until the next sync of the user.
|
||||||
|
ALTER TABLE user_psn_title
|
||||||
|
ADD COLUMN platform VARCHAR(8),
|
||||||
|
ADD COLUMN playtime_minutes INT,
|
||||||
|
ADD COLUMN last_played_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- The play of the user's linked titles per game key. A merge does not move user_psn_title.appid (readers map it
|
||||||
|
-- through game_alias), so the view resolves the alias. user_game columns are unchanged: no view rebuild.
|
||||||
|
CREATE VIEW psn_title_play AS
|
||||||
|
SELECT t.user_id, t.psn_key, COALESCE(a.to_appid, t.appid) AS appid, t.platform, t.playtime_minutes, t.last_played_at
|
||||||
|
FROM user_psn_title t
|
||||||
|
LEFT JOIN game_alias a ON a.from_appid = t.appid
|
||||||
|
WHERE t.state IN ('LINKED', 'STEAM_OWNED') AND t.playtime_minutes IS NOT NULL;
|
||||||
|
|
||||||
|
-- The PlayStation row of an entry platform resolves the alias too (V43 joined user_psn_title.appid directly, so a
|
||||||
|
-- title on a merged key lost its icon). Same columns as V43: replace in place.
|
||||||
|
CREATE OR REPLACE VIEW user_game_entry_platform AS
|
||||||
|
SELECT m.user_id, m.appid, m.member_appid, ug.source, COALESCE(ug.platform, '') AS platform
|
||||||
|
FROM user_game_entry_member m
|
||||||
|
JOIN user_game ug ON ug.user_id = m.user_id AND ug.appid = m.member_appid
|
||||||
|
UNION
|
||||||
|
SELECT m.user_id, m.appid, m.member_appid, 'PSN', ''
|
||||||
|
FROM user_game_entry_member m
|
||||||
|
JOIN user_psn_title t ON t.user_id = m.user_id AND t.state IN ('LINKED', 'STEAM_OWNED')
|
||||||
|
LEFT JOIN game_alias a ON a.from_appid = t.appid
|
||||||
|
WHERE COALESCE(a.to_appid, t.appid) = m.member_appid;
|
||||||
@@ -80,7 +80,7 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
|
|||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void seed() {
|
void seed() {
|
||||||
for (String table : List.of("game_link_request", "game_link_pair", "game_link", "game_edition", "admin_audit")) {
|
for (String table : List.of("user_psn_title", "game_link_request", "game_link_pair", "game_link", "game_edition", "admin_audit")) {
|
||||||
jdbc.update("delete from " + table);
|
jdbc.update("delete from " + table);
|
||||||
}
|
}
|
||||||
userGameRepository.deleteAll();
|
userGameRepository.deleteAll();
|
||||||
@@ -293,6 +293,19 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
|
|||||||
.andExpect(jsonPath("$.content[0].platforms[1].platform").value("Switch"));
|
.andExpect(jsonPath("$.content[0].platforms[1].platform").value("Switch"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void games_ASetRowCarriesTheTotalPlaytimeOfAllItsPlatforms() throws Exception {
|
||||||
|
jdbc.update("update user_game set playtime_minutes = 500 where user_id = ? and appid = ?", USER_A, GAME);
|
||||||
|
jdbc.update("update user_game set source = 'PSN', platform = 'PS5', playtime_minutes = 300 where user_id = ? and appid = ?",
|
||||||
|
USER_A, GAME_2);
|
||||||
|
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes) "
|
||||||
|
+ "values (?, 'c9', 'LINKED', ?, 'PS5', 300)", USER_A, GAME_2);
|
||||||
|
linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
mockMvc.perform(as(get("/api/library/games"), USER_A, Role.USER)).andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.content[0].totalPlaytimeMinutes").value(800));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void games_AnOldClientsHideDuplicatesParamChangesNothing() throws Exception {
|
void games_AnOldClientsHideDuplicatesParamChangesNothing() throws Exception {
|
||||||
linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent());
|
linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent());
|
||||||
|
|||||||
+1
-1
@@ -217,7 +217,7 @@ class LibraryControllerAchievementsTest extends AbstractMockedAdaptersTest {
|
|||||||
@Test
|
@Test
|
||||||
void librarySync_StartsTheAchievementSyncAfterCommit() {
|
void librarySync_StartsTheAchievementSyncAfterCommit() {
|
||||||
when(steamApiService.getOwnedGames("76561198000000001"))
|
when(steamApiService.getOwnedGames("76561198000000001"))
|
||||||
.thenReturn(List.of(new SteamOwnedGamesResponse.Game(620, "Portal 2", 90, null, true, 0)));
|
.thenReturn(List.of(new SteamOwnedGamesResponse.Game(620, "Portal 2", 90, null, true, 0, null)));
|
||||||
when(steamApiService.getWishlist(anyString())).thenReturn(Optional.of(List.of()));
|
when(steamApiService.getWishlist(anyString())).thenReturn(Optional.of(List.of()));
|
||||||
when(steamApiService.getSchemaForGame(anyLong())).thenReturn(Optional.empty());
|
when(steamApiService.getSchemaForGame(anyLong())).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class SyncChangesFlowTest extends AbstractMockedAdaptersTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static SteamOwnedGamesResponse.Game owned(int appid, int playtime) {
|
private static SteamOwnedGamesResponse.Game owned(int appid, int playtime) {
|
||||||
return new SteamOwnedGamesResponse.Game(appid, "Game " + appid, playtime, "icon", true, playtime);
|
return new SteamOwnedGamesResponse.Game(appid, "Game " + appid, playtime, "icon", true, playtime, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sync(SyncTrigger trigger, SteamOwnedGamesResponse.Game... games) {
|
private void sync(SyncTrigger trigger, SteamOwnedGamesResponse.Game... games) {
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package core_service.modules.library.domain;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class PlatformPlayTest {
|
||||||
|
|
||||||
|
private static final long USER = 1L;
|
||||||
|
private static final Instant STEAM_PLAYED = Instant.parse("2026-10-01T10:00:00Z");
|
||||||
|
private static final Instant PSN_PLAYED = Instant.parse("2026-10-05T18:00:00Z");
|
||||||
|
|
||||||
|
private static UserGame row(long appid, GameSource source, String platform, int minutes, Instant lastPlayedAt) {
|
||||||
|
UserGame game = UserGame.manual(USER, appid, source, platform, GameStatus.PLAYING, minutes, Instant.EPOCH);
|
||||||
|
game.setLastPlayedAt(lastPlayedAt);
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TitlePlay title(String key, long appid, String platform, int minutes) {
|
||||||
|
return new TitlePlay(key, appid, platform, minutes, PSN_PLAYED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void lines_ASteamRowWithAnOwnedPlayStationTitleIsTwoLines_AndTheTotalIsTheirSum() {
|
||||||
|
List<PlatformPlay> lines = PlatformPlay.lines(List.of(row(100, GameSource.STEAM, null, 500, STEAM_PLAYED)),
|
||||||
|
List.of(title("c1", 100, "PS5", 120)));
|
||||||
|
|
||||||
|
assertThat(lines).containsExactly(
|
||||||
|
new PlatformPlay(GameSource.STEAM, null, 100, 500, STEAM_PLAYED),
|
||||||
|
new PlatformPlay(GameSource.PSN, "PS5", 100, 120, PSN_PLAYED));
|
||||||
|
assertThat(PlatformPlay.total(lines)).isEqualTo(620);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void lines_APlayStationRowWithTwoTitlesIsTheTitleLines_TheRowSumIsNotAddedOnTop() {
|
||||||
|
List<PlatformPlay> lines = PlatformPlay.lines(List.of(row(200, GameSource.PSN, "PS5", 300, PSN_PLAYED)),
|
||||||
|
List.of(title("c4", 200, "PS4", 100), title("c5", 200, "PS5", 200)));
|
||||||
|
|
||||||
|
assertThat(lines).extracting(PlatformPlay::platform).containsExactly("PS5", "PS4");
|
||||||
|
assertThat(lines).extracting(PlatformPlay::playtimeMinutes).containsExactly(200, 100);
|
||||||
|
assertThat(PlatformPlay.total(lines)).isEqualTo(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void lines_APlayStationRowWithoutStoredTitlePlayIsOneLineFromTheRow() {
|
||||||
|
List<PlatformPlay> lines = PlatformPlay.lines(List.of(row(200, GameSource.PSN, "PS5", 300, PSN_PLAYED)), List.of());
|
||||||
|
|
||||||
|
assertThat(lines).containsExactly(new PlatformPlay(GameSource.PSN, "PS5", 200, 300, PSN_PLAYED));
|
||||||
|
assertThat(PlatformPlay.total(lines)).isEqualTo(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void lines_AManualRowWithALinkedTitleIsTheRowLineAndTheTitleLine() {
|
||||||
|
List<PlatformPlay> lines = PlatformPlay.lines(List.of(row(-7, GameSource.MANUAL, "Switch", 60, null)),
|
||||||
|
List.of(title("c1", -7, "PS4", 30)));
|
||||||
|
|
||||||
|
assertThat(lines).containsExactly(
|
||||||
|
new PlatformPlay(GameSource.PSN, "PS4", -7, 30, PSN_PLAYED),
|
||||||
|
new PlatformPlay(GameSource.MANUAL, "Switch", -7, 60, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void lines_ARowTheSteamSyncTookOverIsASteamLineAndTheTitleLine() {
|
||||||
|
List<PlatformPlay> lines = PlatformPlay.lines(List.of(row(100, GameSource.STEAM, null, 40, STEAM_PLAYED)),
|
||||||
|
List.of(title("c1", 100, "PS5", 900)));
|
||||||
|
|
||||||
|
assertThat(lines).extracting(PlatformPlay::source).containsExactly(GameSource.STEAM, GameSource.PSN);
|
||||||
|
assertThat(PlatformPlay.total(lines)).isEqualTo(940);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void lines_OrderIsSteamThenPs5ThenPs4ThenManual_AndTitlesOfOtherRowsAreIgnored() {
|
||||||
|
List<PlatformPlay> lines = PlatformPlay.lines(List.of(
|
||||||
|
row(-7, GameSource.MANUAL, "Switch", 60, null),
|
||||||
|
row(200, GameSource.PSN, "PS4", 10, null),
|
||||||
|
row(100, GameSource.STEAM, null, 500, STEAM_PLAYED)),
|
||||||
|
List.of(title("c1", 200, "PS4", 10), title("c2", 100, "PS5", 20), title("c3", 999, "PS5", 7777)));
|
||||||
|
|
||||||
|
assertThat(lines).extracting(l -> l.source() + ":" + l.platform())
|
||||||
|
.containsExactly("STEAM:null", "PSN:PS5", "PSN:PS4", "MANUAL:Switch");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void total_IsCappedAtIntegerMax() {
|
||||||
|
List<PlatformPlay> lines = List.of(new PlatformPlay(GameSource.STEAM, null, 1, Integer.MAX_VALUE, null),
|
||||||
|
new PlatformPlay(GameSource.PSN, "PS5", 1, 5, null));
|
||||||
|
|
||||||
|
assertThat(PlatformPlay.total(lines)).isEqualTo(Integer.MAX_VALUE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void lines_AManualRowWithALinkedTitleOfItsPlatformIsOneLine() {
|
||||||
|
List<PlatformPlay> lines = PlatformPlay.lines(List.of(row(-7, GameSource.MANUAL, "PS5", 1200, null)),
|
||||||
|
List.of(title("c1", -7, "PS5", 1300)));
|
||||||
|
|
||||||
|
assertThat(lines).containsExactly(new PlatformPlay(GameSource.PSN, "PS5", -7, 1300, PSN_PLAYED));
|
||||||
|
assertThat(PlatformPlay.total(lines)).isEqualTo(1300);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
package core_service.modules.library.repo;
|
package core_service.modules.library.repo;
|
||||||
|
|
||||||
import core_service.modules.library.domain.Edition;
|
import core_service.modules.library.domain.Edition;
|
||||||
|
import core_service.modules.library.domain.EntryPlatform;
|
||||||
|
import core_service.modules.library.domain.GameSource;
|
||||||
import core_service.modules.library.domain.GameStatus;
|
import core_service.modules.library.domain.GameStatus;
|
||||||
import core_service.modules.library.domain.LinkPair;
|
import core_service.modules.library.domain.LinkPair;
|
||||||
|
import core_service.modules.library.domain.PlatformPlay;
|
||||||
|
import core_service.modules.library.domain.TitlePlay;
|
||||||
|
import core_service.modules.library.domain.UserGame;
|
||||||
import core_service.shared.test.AbstractPersistenceTest;
|
import core_service.shared.test.AbstractPersistenceTest;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -10,6 +15,7 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|||||||
import org.springframework.context.annotation.Import;
|
import org.springframework.context.annotation.Import;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
@@ -259,6 +265,81 @@ class GameLinkRepositoryTest extends AbstractPersistenceTest {
|
|||||||
USER, appid, source, platform);
|
USER, appid, source, platform);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void playtime(long appid, int minutes) {
|
||||||
|
jdbc.update("update user_game set playtime_minutes = ? where user_id = ? and appid = ?", minutes, USER, appid);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void titlePlay(String key, long appid, String state, String platform, int minutes) {
|
||||||
|
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes) "
|
||||||
|
+ "values (?, ?, ?, ?, ?, ?)", USER, key, state, appid, platform, minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UserGame row(long appid, GameSource source, String platform, int minutes) {
|
||||||
|
return UserGame.manual(USER, appid, source, platform, GameStatus.BACKLOG, minutes, Instant.EPOCH);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void entryPlaytimes_ASteamAndAnOwnedTitleSet_EqualsThePlatformPlayTotal() {
|
||||||
|
ownOn(100, "STEAM", null);
|
||||||
|
playtime(100, 500);
|
||||||
|
titlePlay("c1", 100, "STEAM_OWNED", "PS5", 120);
|
||||||
|
|
||||||
|
var playtimes = repository.entryPlaytimes(USER, List.of(100L));
|
||||||
|
|
||||||
|
int expected = PlatformPlay.total(PlatformPlay.lines(List.of(row(100, GameSource.STEAM, null, 500)),
|
||||||
|
List.of(new TitlePlay("c1", 100L, "PS5", 120, null))));
|
||||||
|
assertThat(expected).isEqualTo(620);
|
||||||
|
assertThat(playtimes).containsOnly(Map.entry(100L, expected));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void entryPlaytimes_ASteamAndAPlayStationRowSet_EqualsThePlatformPlayTotal_TheRowSumIsNotAddedOnTop() {
|
||||||
|
repository.link(100, 200);
|
||||||
|
ownOn(100, "STEAM", null);
|
||||||
|
ownOn(200, "PSN", "PS5");
|
||||||
|
playtime(100, 500);
|
||||||
|
playtime(200, 300);
|
||||||
|
titlePlay("c4", 200, "LINKED", "PS4", 100);
|
||||||
|
titlePlay("c5", 200, "LINKED", "PS5", 200);
|
||||||
|
// A title without stored play counts nothing.
|
||||||
|
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c6', 'LINKED', 200)", USER);
|
||||||
|
|
||||||
|
var playtimes = repository.entryPlaytimes(USER, List.of(100L, 200L));
|
||||||
|
|
||||||
|
int expected = PlatformPlay.total(PlatformPlay.lines(
|
||||||
|
List.of(row(100, GameSource.STEAM, null, 500), row(200, GameSource.PSN, "PS5", 300)),
|
||||||
|
List.of(new TitlePlay("c4", 200L, "PS4", 100, null), new TitlePlay("c5", 200L, "PS5", 200, null))));
|
||||||
|
assertThat(expected).isEqualTo(800);
|
||||||
|
assertThat(playtimes.values()).containsExactly(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void entryPlaytimes_AManualRowWithATitleOfItsPlatform_EqualsThePlatformPlayTotal() {
|
||||||
|
repository.link(100, -7);
|
||||||
|
ownOn(100, "STEAM", null);
|
||||||
|
ownOn(-7, "MANUAL", "PS5");
|
||||||
|
playtime(100, 500);
|
||||||
|
playtime(-7, 1200);
|
||||||
|
titlePlay("c1", -7, "LINKED", "PS5", 1300);
|
||||||
|
titlePlay("c2", -7, "LINKED", "PS4", 60);
|
||||||
|
|
||||||
|
int expected = PlatformPlay.total(PlatformPlay.lines(
|
||||||
|
List.of(row(100, GameSource.STEAM, null, 500), row(-7, GameSource.MANUAL, "PS5", 1200)),
|
||||||
|
List.of(new TitlePlay("c1", -7L, "PS5", 1300, null), new TitlePlay("c2", -7L, "PS4", 60, null))));
|
||||||
|
|
||||||
|
assertThat(expected).isEqualTo(1860);
|
||||||
|
assertThat(repository.entryPlaytimes(USER, List.of(100L, -7L)).values()).containsExactly(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void entryPlaytimes_APlayStationRowWithoutTitlePlay_IsItsOwnMinutes() {
|
||||||
|
ownOn(200, "PSN", "PS5");
|
||||||
|
playtime(200, 300);
|
||||||
|
|
||||||
|
assertThat(repository.entryPlaytimes(USER, List.of(200L))).containsOnly(Map.entry(200L, 300));
|
||||||
|
assertThat(repository.entryPlaytimes(USER, List.of())).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void entryPlatforms_SteamFirstThenPlayStationThenManual_AndAPlayStationTitleWithoutNameIsDropped() {
|
void entryPlatforms_SteamFirstThenPlayStationThenManual_AndAPlayStationTitleWithoutNameIsDropped() {
|
||||||
repository.link(100, 200);
|
repository.link(100, 200);
|
||||||
@@ -367,4 +448,17 @@ class GameLinkRepositoryTest extends AbstractPersistenceTest {
|
|||||||
assertThat(repository.editionCounts(USER, List.of(100L, 200L, -7L))).isEqualTo(Map.of(100L, 2, 200L, 2));
|
assertThat(repository.editionCounts(USER, List.of(100L, 200L, -7L))).isEqualTo(Map.of(100L, 2, 200L, 2));
|
||||||
assertThat(repository.editionCounts(OTHER_USER, List.of(100L, -7L))).isEqualTo(Map.of(100L, 2));
|
assertThat(repository.editionCounts(OTHER_USER, List.of(100L, -7L))).isEqualTo(Map.of(100L, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void entryPlatforms_ATitleOnAMergedKeyStillGivesThePlayStationIcon() {
|
||||||
|
jdbc.update("insert into steam_game (appid, name) values (-9, 'Old key') on conflict do nothing");
|
||||||
|
jdbc.update("insert into game_alias (from_appid, to_appid) values (-9, 100) on conflict do nothing");
|
||||||
|
ownOn(100, "STEAM", null);
|
||||||
|
playtime(100, 500);
|
||||||
|
titlePlay("c1", -9, "STEAM_OWNED", "PS5", 120);
|
||||||
|
|
||||||
|
assertThat(repository.entryPlaytimes(USER, List.of(100L))).containsOnly(Map.entry(100L, 620));
|
||||||
|
assertThat(repository.entryPlatforms(USER, List.of(100L)).get(100L)).extracting(EntryPlatform::source)
|
||||||
|
.contains(GameSource.PSN);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
package core_service.modules.library.repo;
|
package core_service.modules.library.repo;
|
||||||
|
|
||||||
import core_service.modules.library.domain.PsnTitle;
|
import core_service.modules.library.domain.PsnTitle;
|
||||||
|
import core_service.modules.library.domain.TitlePlay;
|
||||||
import core_service.shared.test.AbstractPersistenceTest;
|
import core_service.shared.test.AbstractPersistenceTest;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.context.annotation.Import;
|
import org.springframework.context.annotation.Import;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.tuple;
|
||||||
|
|
||||||
@Import(PsnTitleRepository.class)
|
@Import(PsnTitleRepository.class)
|
||||||
class PsnTitleRepositoryTest extends AbstractPersistenceTest {
|
class PsnTitleRepositoryTest extends AbstractPersistenceTest {
|
||||||
@@ -72,4 +75,42 @@ class PsnTitleRepositoryTest extends AbstractPersistenceTest {
|
|||||||
assertThat(repository.findAll(3L).get("c1").appid()).isEqualTo(-1L);
|
assertThat(repository.findAll(3L).get("c1").appid()).isEqualTo(-1L);
|
||||||
assertThat(repository.findAll(5L)).isEmpty();
|
assertThat(repository.findAll(5L)).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void savePlays_UpdatesLinkedAndSteamOwnedTitles_NotADismissedOne() {
|
||||||
|
Instant played = Instant.parse("2026-10-05T18:00:00Z");
|
||||||
|
repository.link(6L, "c1", -1L);
|
||||||
|
repository.linkOwned(6L, "c2", -2L);
|
||||||
|
repository.dismiss(6L, List.of("c3"));
|
||||||
|
|
||||||
|
repository.savePlays(6L, List.of(new TitlePlay("c1", null, "PS5", 120, played),
|
||||||
|
new TitlePlay("c2", null, "PS4", 30, null), new TitlePlay("c3", null, "PS5", 999, played)));
|
||||||
|
|
||||||
|
assertThat(jdbc.queryForObject("select count(*) from user_psn_title where user_id = 6 and playtime_minutes is not null",
|
||||||
|
Integer.class)).isEqualTo(2);
|
||||||
|
assertThat(jdbc.queryForObject("select playtime_minutes from user_psn_title where user_id = 6 and psn_key = 'c1'",
|
||||||
|
Integer.class)).isEqualTo(120);
|
||||||
|
assertThat(jdbc.queryForObject("select platform from user_psn_title where user_id = 6 and psn_key = 'c2'",
|
||||||
|
String.class)).isEqualTo("PS4");
|
||||||
|
assertThat(jdbc.queryForObject("select playtime_minutes from user_psn_title where user_id = 6 and psn_key = 'c3'",
|
||||||
|
Integer.class)).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findPlays_MapsAMergedKeyThroughTheAlias_AndLeavesOutTitlesWithoutPlay() {
|
||||||
|
game(7L, -1L, "STEAM");
|
||||||
|
jdbc.update("insert into steam_game (appid, name) values (-9, 'Old') on conflict do nothing");
|
||||||
|
jdbc.update("insert into game_alias (from_appid, to_appid) values (-9, -1) on conflict do nothing");
|
||||||
|
repository.link(7L, "c1", -9L);
|
||||||
|
repository.linkOwned(7L, "c2", -1L);
|
||||||
|
repository.link(7L, "c3", -1L);
|
||||||
|
repository.savePlays(7L, List.of(new TitlePlay("c1", null, "PS5", 120, null),
|
||||||
|
new TitlePlay("c2", null, "PS4", 30, null)));
|
||||||
|
|
||||||
|
List<TitlePlay> plays = repository.findPlays(7L, List.of(-1L));
|
||||||
|
|
||||||
|
assertThat(plays).extracting(TitlePlay::psnKey, TitlePlay::appid, TitlePlay::platform, TitlePlay::playtimeMinutes)
|
||||||
|
.containsExactlyInAnyOrder(tuple("c1", -1L, "PS5", 120), tuple("c2", -1L, "PS4", 30));
|
||||||
|
assertThat(repository.findPlays(7L, List.of())).isEmpty();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -155,4 +155,13 @@ class QuestionRepositoryTest extends AbstractPersistenceTest {
|
|||||||
jdbc.update("delete from user_game where appid = 50");
|
jdbc.update("delete from user_game where appid = 50");
|
||||||
assertThat(jdbc.queryForObject("select count(*) from user_game_question", Integer.class)).isZero();
|
assertThat(jdbc.queryForObject("select count(*) from user_game_question", Integer.class)).isZero();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void facts_ThePlayStationLastPlayedOfATitleOnASteamRowCounts() {
|
||||||
|
jdbc.update("delete from user_psn_title");
|
||||||
|
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes, last_played_at) "
|
||||||
|
+ "values (?, 'c1', 'STEAM_OWNED', 10, 'PS5', 600, '2026-10-08T20:00:00Z')", USER);
|
||||||
|
|
||||||
|
assertThat(only(10).lastPlayedAt()).isEqualTo(java.time.Instant.parse("2026-10-08T20:00:00Z"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ package core_service.modules.library.service;
|
|||||||
|
|
||||||
import core_service.modules.library.api.dto.AchievementDto;
|
import core_service.modules.library.api.dto.AchievementDto;
|
||||||
import core_service.modules.library.api.dto.GameDetailDto;
|
import core_service.modules.library.api.dto.GameDetailDto;
|
||||||
|
import core_service.modules.library.api.dto.PlatformPlayDto;
|
||||||
import core_service.modules.library.domain.AchievementKind;
|
import core_service.modules.library.domain.AchievementKind;
|
||||||
import core_service.modules.library.domain.PsnEarned;
|
import core_service.modules.library.domain.PsnEarned;
|
||||||
import core_service.modules.library.domain.PsnTrophy;
|
import core_service.modules.library.domain.PsnTrophy;
|
||||||
@@ -17,6 +18,7 @@ import core_service.modules.library.domain.UserGame;
|
|||||||
import core_service.modules.library.domain.UserGameAchievementMeta;
|
import core_service.modules.library.domain.UserGameAchievementMeta;
|
||||||
import core_service.modules.library.domain.UserGameId;
|
import core_service.modules.library.domain.UserGameId;
|
||||||
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
|
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
|
||||||
|
import core_service.modules.library.repo.GameLinkRepository;
|
||||||
import core_service.modules.library.repo.SteamGameRepository;
|
import core_service.modules.library.repo.SteamGameRepository;
|
||||||
import core_service.modules.library.repo.GameAchievementSchemaRepository;
|
import core_service.modules.library.repo.GameAchievementSchemaRepository;
|
||||||
import core_service.modules.library.repo.GameSpeedrunRepository;
|
import core_service.modules.library.repo.GameSpeedrunRepository;
|
||||||
@@ -34,6 +36,7 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.web.client.ResourceAccessException;
|
import org.springframework.web.client.ResourceAccessException;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -86,6 +89,9 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private PsnTrophyRepository psnTrophyRepository;
|
private PsnTrophyRepository psnTrophyRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private GameLinkRepository gameLinkRepository;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private JdbcTemplate jdbc;
|
private JdbcTemplate jdbc;
|
||||||
|
|
||||||
@@ -99,6 +105,8 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest {
|
|||||||
gameSpeedrunRepository.deleteAll();
|
gameSpeedrunRepository.deleteAll();
|
||||||
gameTimeToBeatRepository.deleteAll();
|
gameTimeToBeatRepository.deleteAll();
|
||||||
gameMoodsRepository.deleteAll();
|
gameMoodsRepository.deleteAll();
|
||||||
|
jdbc.update("delete from user_psn_title");
|
||||||
|
jdbc.update("delete from game_link");
|
||||||
jdbc.update("delete from user_psn_trophy");
|
jdbc.update("delete from user_psn_trophy");
|
||||||
jdbc.update("delete from user_psn_trophy_set");
|
jdbc.update("delete from user_psn_trophy_set");
|
||||||
jdbc.update("delete from psn_trophy");
|
jdbc.update("delete from psn_trophy");
|
||||||
@@ -292,4 +300,44 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest {
|
|||||||
assertEquals(4, detail.achievements().size());
|
assertEquals(4, detail.achievements().size());
|
||||||
assertEquals(1, detail.achievementsTotal());
|
assertEquals(1, detail.achievementsTotal());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getGameDetail_ASteamAndAPlayStationEdition_HasOneLinePerPlatformWithItsOwnPlayAndProgress() {
|
||||||
|
Instant steamPlayed = Instant.parse("2026-10-01T10:00:00Z");
|
||||||
|
Instant psnPlayed = Instant.parse("2026-10-05T18:00:00Z");
|
||||||
|
UserGame steam = userGameRepository.findById(new UserGameId(USER_ID, 620L)).orElseThrow();
|
||||||
|
steam.setPlaytimeMinutes(500);
|
||||||
|
steam.setLastPlayedAt(steamPlayed);
|
||||||
|
userGameRepository.save(steam);
|
||||||
|
schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 3, true, Instant.now()));
|
||||||
|
for (String name : List.of("A", "B", "C")) {
|
||||||
|
schemaRepository.save(new GameAchievementSchema(620L, name, name, null, null, null, false));
|
||||||
|
}
|
||||||
|
userAchievementMetaRepository.save(new UserGameAchievementMeta(USER_ID, 620L, Instant.now(), true, 2, 60));
|
||||||
|
steamGameRepository.save(new SteamGame(621L, "Portal 2 (PS5)", null, Instant.now()));
|
||||||
|
userGameRepository.save(UserGame.manual(USER_ID, 621L, GameSource.PSN, "PS5", GameStatus.PLAYING, 300, Instant.now()));
|
||||||
|
gameLinkRepository.link(620L, 621L);
|
||||||
|
psnTrophyRepository.saveSet(USER_ID, 621L, new PsnTrophySet("NPWR9_00", "trophy2", "01.00", "Portal 2", null, "PS5",
|
||||||
|
2, 0, 0, 1, 2, false, Instant.now()));
|
||||||
|
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes, last_played_at) "
|
||||||
|
+ "values (?, 'c9', 'LINKED', 621, 'PS5', 300, ?)", USER_ID, Timestamp.from(psnPlayed));
|
||||||
|
|
||||||
|
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, null, 620L).orElseThrow();
|
||||||
|
|
||||||
|
assertEquals(List.of(
|
||||||
|
new PlatformPlayDto(GameSource.STEAM, null, 620L, 500, steamPlayed, 2, 3, false),
|
||||||
|
new PlatformPlayDto(GameSource.PSN, "PS5", 621L, 300, psnPlayed, 2, 3, false)), detail.platforms());
|
||||||
|
assertEquals(800, detail.totalPlaytimeMinutes());
|
||||||
|
assertEquals(500, detail.playtimeMinutes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getGameDetail_AGameOfOnePlatform_HasOneLineAndItsOwnMinutesAsTotal() {
|
||||||
|
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, null, 620L).orElseThrow();
|
||||||
|
|
||||||
|
assertEquals(1, detail.platforms().size());
|
||||||
|
assertEquals(GameSource.STEAM, detail.platforms().getFirst().source());
|
||||||
|
assertEquals(60, detail.platforms().getFirst().playtimeMinutes());
|
||||||
|
assertEquals(60, detail.totalPlaytimeMinutes());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -61,7 +61,7 @@ class LibrarySyncWorkerConcurrencyTest extends AbstractMockedAdaptersTest {
|
|||||||
void runSyncNow_TheFollowUpStoresTheSpeedrunRow() {
|
void runSyncNow_TheFollowUpStoresTheSpeedrunRow() {
|
||||||
when(steamApiService.getWishlist(STEAM_ID)).thenReturn(Optional.empty());
|
when(steamApiService.getWishlist(STEAM_ID)).thenReturn(Optional.empty());
|
||||||
when(steamApiService.getOwnedGames(STEAM_ID))
|
when(steamApiService.getOwnedGames(STEAM_ID))
|
||||||
.thenReturn(List.of(new SteamOwnedGamesResponse.Game((int) APPID, "Half-Life 2", 0, "icon", true, 0)));
|
.thenReturn(List.of(new SteamOwnedGamesResponse.Game((int) APPID, "Half-Life 2", 0, "icon", true, 0, null)));
|
||||||
when(speedrunApiService.isEnabled()).thenReturn(true);
|
when(speedrunApiService.isEnabled()).thenReturn(true);
|
||||||
when(speedrunApiService.findGames("Half-Life 2")).thenReturn(Optional.of(List.of(
|
when(speedrunApiService.findGames("Half-Life 2")).thenReturn(Optional.of(List.of(
|
||||||
new SpeedrunApiService.Game("hl2", "Half-Life 2", 2004, "https://www.speedrun.com/hl2"))));
|
new SpeedrunApiService.Game("hl2", "Half-Life 2", 2004, "https://www.speedrun.com/hl2"))));
|
||||||
@@ -153,6 +153,6 @@ class LibrarySyncWorkerConcurrencyTest extends AbstractMockedAdaptersTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static SteamOwnedGamesResponse.Game game(String iconUrl) {
|
private static SteamOwnedGamesResponse.Game game(String iconUrl) {
|
||||||
return new SteamOwnedGamesResponse.Game((int) APPID, "Half-Life 2", 60, iconUrl, true, 0);
|
return new SteamOwnedGamesResponse.Game((int) APPID, "Half-Life 2", 60, iconUrl, true, 0, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,7 +68,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
|||||||
private static final String OTHER_STEAM_ID = "76561198005679790";
|
private static final String OTHER_STEAM_ID = "76561198005679790";
|
||||||
|
|
||||||
private static SteamOwnedGamesResponse.Game game(int playtime, int playtime2Weeks) {
|
private static SteamOwnedGamesResponse.Game game(int playtime, int playtime2Weeks) {
|
||||||
return new SteamOwnedGamesResponse.Game(220, "Half-Life 2", playtime, "icon", true, playtime2Weeks);
|
return new SteamOwnedGamesResponse.Game(220, "Half-Life 2", playtime, "icon", true, playtime2Weeks, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static LocalDate today() {
|
private static LocalDate today() {
|
||||||
@@ -297,6 +297,44 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
|||||||
verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 120);
|
verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 120);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static SteamOwnedGamesResponse.Game gameLastPlayed(Long rtimeLastPlayed) {
|
||||||
|
return new SteamOwnedGamesResponse.Game(220, "Half-Life 2", 100, "icon", true, 0, rtimeLastPlayed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runSync_StoresSteamsLastPlayed() {
|
||||||
|
UserGame existing = owned(100);
|
||||||
|
steamReturns(gameLastPlayed(1_760_000_000L), STEAM_ID);
|
||||||
|
|
||||||
|
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
|
||||||
|
|
||||||
|
assertEquals(Instant.ofEpochSecond(1_760_000_000L), existing.getLastPlayedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runSync_ZeroOrAbsentLastPlayed_KeepsTheOldValue() {
|
||||||
|
Instant old = Instant.parse("2026-10-05T18:00:00Z");
|
||||||
|
for (Long none : new Long[]{0L, null}) {
|
||||||
|
UserGame existing = owned(100);
|
||||||
|
existing.setLastPlayedAt(old);
|
||||||
|
steamReturns(gameLastPlayed(none), STEAM_ID);
|
||||||
|
|
||||||
|
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
|
||||||
|
|
||||||
|
assertEquals(old, existing.getLastPlayedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runSync_NewSteamGame_GetsLastPlayedFromSteam() {
|
||||||
|
steamReturns(gameLastPlayed(1_760_000_000L), null);
|
||||||
|
|
||||||
|
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
|
||||||
|
|
||||||
|
verify(userGameRepository).save(argThat((UserGame created) ->
|
||||||
|
Instant.ofEpochSecond(1_760_000_000L).equals(created.getLastPlayedAt())));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void runSync_PlaytimeGrew_RecordsTheDifferenceForToday() {
|
void runSync_PlaytimeGrew_RecordsTheDifferenceForToday() {
|
||||||
owned(100);
|
owned(100);
|
||||||
@@ -618,7 +656,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
|||||||
void runSync_GameWithoutName_GetsTheAppidAsName() {
|
void runSync_GameWithoutName_GetsTheAppidAsName() {
|
||||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
|
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
|
||||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
|
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
|
||||||
new SteamOwnedGamesResponse.Game(2000001, null, 12, null, null, null),
|
new SteamOwnedGamesResponse.Game(2000001, null, 12, null, null, null, null),
|
||||||
game(90, 0)));
|
game(90, 0)));
|
||||||
|
|
||||||
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
|
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
|
||||||
@@ -633,7 +671,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
|||||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
|
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
|
||||||
when(steamGameRepository.findAllById(List.of(2000001L))).thenReturn(List.of(stored));
|
when(steamGameRepository.findAllById(List.of(2000001L))).thenReturn(List.of(stored));
|
||||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
|
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
|
||||||
new SteamOwnedGamesResponse.Game(2000001, " ", 12, null, null, null)));
|
new SteamOwnedGamesResponse.Game(2000001, " ", 12, null, null, null, null)));
|
||||||
|
|
||||||
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
|
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
|
||||||
|
|
||||||
@@ -673,7 +711,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static SteamOwnedGamesResponse.Game gameOf(int appid, int playtime) {
|
private static SteamOwnedGamesResponse.Game gameOf(int appid, int playtime) {
|
||||||
return new SteamOwnedGamesResponse.Game(appid, "Game " + appid, playtime, "icon", true, playtime);
|
return new SteamOwnedGamesResponse.Game(appid, "Game " + appid, playtime, "icon", true, playtime, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import core_service.modules.library.domain.GameStatus;
|
|||||||
import core_service.modules.library.domain.IgdbGame;
|
import core_service.modules.library.domain.IgdbGame;
|
||||||
import core_service.modules.library.domain.PsnTitle;
|
import core_service.modules.library.domain.PsnTitle;
|
||||||
import core_service.modules.library.domain.SyncChange;
|
import core_service.modules.library.domain.SyncChange;
|
||||||
|
import core_service.modules.library.domain.TitlePlay;
|
||||||
import core_service.modules.library.domain.UserGame;
|
import core_service.modules.library.domain.UserGame;
|
||||||
import core_service.modules.library.domain.UserGameId;
|
import core_service.modules.library.domain.UserGameId;
|
||||||
import core_service.modules.library.repo.GameImportRepository;
|
import core_service.modules.library.repo.GameImportRepository;
|
||||||
@@ -195,6 +196,38 @@ class PsnSyncStepTest extends AbstractUnitTest {
|
|||||||
verifyNoInteractions(manualGameService);
|
verifyNoInteractions(manualGameService);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void run_SavesThePlayOfALinkedAndASteamOwnedTitle_AndGivesNoPlaytimeToTheSteamRow() {
|
||||||
|
PsnApiService.Title other = new PsnApiService.Title("PPSA2_00", "Other", "ps5_native_game", Duration.ofMinutes(30), 8L, null);
|
||||||
|
fetches(ghost(), other);
|
||||||
|
when(psnTitleRepository.findAll(USER)).thenReturn(Map.of(
|
||||||
|
"c7", new PsnTitle("c7", PsnTitle.State.LINKED, -1L),
|
||||||
|
"c8", new PsnTitle("c8", PsnTitle.State.STEAM_OWNED, 100L)));
|
||||||
|
|
||||||
|
PsnSyncStep.Prepared prepared = step.run(USER);
|
||||||
|
|
||||||
|
ArgumentCaptor<List<TitlePlay>> plays = ArgumentCaptor.captor();
|
||||||
|
verify(psnTitleRepository).savePlays(eq(USER), plays.capture());
|
||||||
|
assertThat(plays.getValue()).containsExactlyInAnyOrder(new TitlePlay("c7", null, "PS5", 120, LAST_PLAYED),
|
||||||
|
new TitlePlay("c8", null, "PS5", 30, null));
|
||||||
|
assertThat(prepared.playtimes()).extracting(PsnSyncStep.Playtime::appid).containsExactly(-1L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void run_ATitleLinkedInThisRunIsAlsoInTheSavedPlay() {
|
||||||
|
fetches(ghost());
|
||||||
|
IgdbGame game = igdb(1, "Ghost of Yotei", "PS5");
|
||||||
|
when(manualGameService.findGames("Ghost of Yotei")).thenReturn(Optional.of(List.of(game)));
|
||||||
|
when(manualGameService.addGame(eq(USER), eq(game), eq("PS5"), eq(GameStatus.PLAYING), eq(120),
|
||||||
|
eq(GameSource.PSN), isNull())).thenReturn(new ManualGameService.AddResult.Added(-1L));
|
||||||
|
|
||||||
|
step.run(USER);
|
||||||
|
|
||||||
|
ArgumentCaptor<List<TitlePlay>> plays = ArgumentCaptor.captor();
|
||||||
|
verify(psnTitleRepository).savePlays(eq(USER), plays.capture());
|
||||||
|
assertThat(plays.getValue()).containsExactly(new TitlePlay("c7", null, "PS5", 120, LAST_PLAYED));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void run_DismissedTitleAndTitleOfAnOpenImport_AreNotSearchedNorOffered() {
|
void run_DismissedTitleAndTitleOfAnOpenImport_AreNotSearchedNorOffered() {
|
||||||
PsnApiService.Title other = new PsnApiService.Title("PPSA2_00", "Other", "ps5_native_game", Duration.ZERO, 8L, null);
|
PsnApiService.Title other = new PsnApiService.Title("PPSA2_00", "Other", "ps5_native_game", Duration.ZERO, 8L, null);
|
||||||
|
|||||||
+1
-1
@@ -109,7 +109,7 @@ class SteamApiServiceCacheTest extends AbstractIntegrationTest {
|
|||||||
@Test
|
@Test
|
||||||
void getOwnedGames_TransientSteamError_IsTriedAgain() {
|
void getOwnedGames_TransientSteamError_IsTriedAgain() {
|
||||||
SteamOwnedGamesResponse games = new SteamOwnedGamesResponse(new SteamOwnedGamesResponse.OwnedGamesResponse(
|
SteamOwnedGamesResponse games = new SteamOwnedGamesResponse(new SteamOwnedGamesResponse.OwnedGamesResponse(
|
||||||
List.of(new SteamOwnedGamesResponse.Game(220, "Half-Life 2", 60, null, true, 0))));
|
List.of(new SteamOwnedGamesResponse.Game(220, "Half-Life 2", 60, null, true, 0, null))));
|
||||||
when(steamRestClient.get().uri(any(Function.class)).retrieve().body(SteamOwnedGamesResponse.class))
|
when(steamRestClient.get().uri(any(Function.class)).retrieve().body(SteamOwnedGamesResponse.class))
|
||||||
.thenAnswer(invocation -> {
|
.thenAnswer(invocation -> {
|
||||||
if (steamCalls.incrementAndGet() == 1) {
|
if (steamCalls.incrementAndGet() == 1) {
|
||||||
|
|||||||
@@ -88,7 +88,8 @@ class SteamApiServiceTest {
|
|||||||
"name": "Counter-Strike",
|
"name": "Counter-Strike",
|
||||||
"playtime_forever": 32,
|
"playtime_forever": 32,
|
||||||
"img_icon_url": "icon_url",
|
"img_icon_url": "icon_url",
|
||||||
"has_community_visible_stats": true
|
"has_community_visible_stats": true,
|
||||||
|
"rtime_last_played": 1760000000
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -104,6 +105,7 @@ class SteamApiServiceTest {
|
|||||||
|
|
||||||
assertThat(result).hasSize(1);
|
assertThat(result).hasSize(1);
|
||||||
assertThat(result.getFirst().name()).isEqualTo("Counter-Strike");
|
assertThat(result.getFirst().name()).isEqualTo("Counter-Strike");
|
||||||
|
assertThat(result.getFirst().rtimeLastPlayed()).isEqualTo(1_760_000_000L);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in new issue
Block a user