diff --git a/readme.md b/readme.md index 8eab632..4e1e9c0 100644 --- a/readme.md +++ b/readme.md @@ -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 | | `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 | -| `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 | | `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 | @@ -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/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 | +| `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 | | `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 | @@ -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 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 - 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. 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. @@ -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. 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. @@ -412,6 +414,7 @@ sequenceDiagram participant F as FollowDigestService participant D as Db W->>P: run (titles, trophy summaries, changed lists) + P->>D: save title play (user_psn_title) P-->>W: ADDED, PLAYTIME, ACHIEVEMENTS (trophy gains) W->>D: sync_result, sync_change (ADDED, STATUS, WISHLIST, PSN changes) 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 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 - community stats of the landing page. Card playtime is the counted edition's own; the set total comes with the - detail page. Pile Points pay a set once (see "Pile Points"), and 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 `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` - (size of the set in the user's library, `null` below 2) and `platforms` (`source` + `platform` per edition, for - the icons of the card). + (size of the set in the user's library, `null` below 2), `platforms` (`source` + `platform` per edition, for + the icons of the card) and `totalPlaytimeMinutes`. ```mermaid flowchart LR @@ -577,7 +590,9 @@ flowchart LR Counted --> Member["user_game_entry_member"] Member --> Platform["user_game_entry_platform"] 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)"] Row -->|"update: user_game_set_share"| Row 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 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. + - 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 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). diff --git a/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java b/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java index a6d5d8f..3686d80 100644 --- a/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java +++ b/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java @@ -16,7 +16,7 @@ public record GameDetailDto( int playtime2Weeks, Instant firstSeenAt, Instant lastSeenAt, - // from PlayStation; null for Steam rows + // from PlayStation or Steam; null when not known Instant lastPlayedAt, GameStatus status, Integer rating, @@ -57,7 +57,11 @@ public record GameDetailDto( // see GameDto#comingOn LocalDate comingOn, // Steam's set first, then the PlayStation sets, PS5 before PS4; the counts above are Steam's only - List achievementSets + List achievementSets, + // one line per platform of the entry, Steam first; empty for a hidden game + List platforms, + // the sum of the platform lines; the counts above and playtimeMinutes stay this edition's own + int totalPlaytimeMinutes ) { /** diff --git a/src/main/java/core_service/modules/library/api/dto/GameDto.java b/src/main/java/core_service/modules/library/api/dto/GameDto.java index ddb465a..90768a0 100644 --- a/src/main/java/core_service/modules/library/api/dto/GameDto.java +++ b/src/main/java/core_service/modules/library/api/dto/GameDto.java @@ -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 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 totalPlaytimeMinutes the playtime of every platform of the entry; {@code playtimeMinutes} stays the counted + * edition's own */ public record GameDto( Long appid, @@ -39,5 +41,6 @@ public record GameDto( Integer editions, boolean coming, LocalDate comingOn, - List platforms + List platforms, + int totalPlaytimeMinutes ) {} diff --git a/src/main/java/core_service/modules/library/api/dto/PlatformPlayDto.java b/src/main/java/core_service/modules/library/api/dto/PlatformPlayDto.java new file mode 100644 index 0000000..2afa07f --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/PlatformPlayDto.java @@ -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) {} diff --git a/src/main/java/core_service/modules/library/domain/PlatformPlay.java b/src/main/java/core_service/modules/library/domain/PlatformPlay.java new file mode 100644 index 0000000..8a36015 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/PlatformPlay.java @@ -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 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 lines(List rows, List titles) { + List lines = new ArrayList<>(); + for (UserGame row : rows) { + List 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 lines) { + return (int) Math.min(lines.stream().mapToLong(PlatformPlay::playtimeMinutes).sum(), Integer.MAX_VALUE); + } +} diff --git a/src/main/java/core_service/modules/library/domain/PsnTrophySetProgress.java b/src/main/java/core_service/modules/library/domain/PsnTrophySetProgress.java index 5cf4ce6..57ede28 100644 --- a/src/main/java/core_service/modules/library/domain/PsnTrophySetProgress.java +++ b/src/main/java/core_service/modules/library/domain/PsnTrophySetProgress.java @@ -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 */ 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; + } +} diff --git a/src/main/java/core_service/modules/library/domain/TitlePlay.java b/src/main/java/core_service/modules/library/domain/TitlePlay.java new file mode 100644 index 0000000..9d7ae07 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/TitlePlay.java @@ -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) {} diff --git a/src/main/java/core_service/modules/library/domain/UserGame.java b/src/main/java/core_service/modules/library/domain/UserGame.java index 12fcf60..9f237d3 100644 --- a/src/main/java/core_service/modules/library/domain/UserGame.java +++ b/src/main/java/core_service/modules/library/domain/UserGame.java @@ -91,7 +91,7 @@ public class UserGame { @Column(name = "import_id") private Long importId; - /** From PlayStation; null for Steam rows. */ + /** From PlayStation or Steam; null when not known. */ @Column(name = "last_played_at") private Instant lastPlayedAt; diff --git a/src/main/java/core_service/modules/library/repo/GameLinkRepository.java b/src/main/java/core_service/modules/library/repo/GameLinkRepository.java index bd0e4a7..f84544a 100644 --- a/src/main/java/core_service/modules/library/repo/GameLinkRepository.java +++ b/src/main/java/core_service/modules/library/repo/GameLinkRepository.java @@ -335,6 +335,34 @@ public class GameLinkRepository { 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 entryPlaytimes(long userId, Collection appids) { + Map 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) { return new MapSqlParameterSource("a", appid).addValue("b", otherAppid); } diff --git a/src/main/java/core_service/modules/library/repo/PsnTitleRepository.java b/src/main/java/core_service/modules/library/repo/PsnTitleRepository.java index 4e7ac34..50579e2 100644 --- a/src/main/java/core_service/modules/library/repo/PsnTitleRepository.java +++ b/src/main/java/core_service/modules/library/repo/PsnTitleRepository.java @@ -1,13 +1,17 @@ package core_service.modules.library.repo; import core_service.modules.library.domain.PsnTitle; +import core_service.modules.library.domain.TitlePlay; 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.LinkedHashMap; +import java.util.List; import java.util.Map; /** @@ -65,4 +69,40 @@ public class PsnTitleRepository { psnKeys.stream().map(key -> new MapSqlParameterSource("user", userId).addValue("key", key)) .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 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 findPlays(long userId, Collection 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(); + } } diff --git a/src/main/java/core_service/modules/library/repo/QuestionRepository.java b/src/main/java/core_service/modules/library/repo/QuestionRepository.java index a361f5a..8f857cd 100644 --- a/src/main/java/core_service/modules/library/repo/QuestionRepository.java +++ b/src/main/java/core_service/modules/library/repo/QuestionRepository.java @@ -51,8 +51,9 @@ public class QuestionRepository { left join game_time_to_beat t on t.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, - 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 + 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 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 diff --git a/src/main/java/core_service/modules/library/service/GameDetailService.java b/src/main/java/core_service/modules/library/service/GameDetailService.java index 875e18f..895c5b8 100644 --- a/src/main/java/core_service/modules/library/service/GameDetailService.java +++ b/src/main/java/core_service/modules/library/service/GameDetailService.java @@ -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.FinishedHintDto; 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.AchievementProgress; 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.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.PlatformPlay; 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; +import core_service.modules.library.domain.TitlePlay; import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGameAchievement; 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.GameAchievementSchemaRepository; 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.SteamGameRepository; import core_service.modules.library.repo.UserGameAchievementMetaRepository; @@ -81,6 +86,7 @@ public class GameDetailService { private final GameLinkRepository gameLinkRepository; private final GameMergeRepository mergeRepository; private final PsnTrophyRepository psnTrophyRepository; + private final PsnTitleRepository psnTitleRepository; @Value("${app.library.achievement-ttl-hours:1}") private long achievementTtlHours; @@ -207,7 +213,7 @@ public class GameDetailService { 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())) + set.perfect(), true, set.listed())) .toList(); } @@ -241,6 +247,8 @@ public class GameDetailService { .orElse(null); Optional coming = comingGameRepository.findById(userGame.getAppid()); boolean hasAchievements = total > 0 || !psnSets.isEmpty(); + List linked = gameLinkRepository.linkedEditions(userGame.getUserId(), userGame.getAppid()); + List plays = platformPlays(userGame, linked); List sets = new ArrayList<>(); if (total > 0) { sets.add(new AchievementSetDto("steam", AchievementKind.STEAM, null, null, total, unlocked, @@ -284,12 +292,76 @@ public class GameDetailService { gameCorrectionRepository.findByAppidOrderByField(userGame.getAppid()).stream() .map(c -> new CorrectionMarkDto(c.getField(), c.getCorrectedAt())).toList(), 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())) .toList(), coming.isPresent(), 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 platformPlays(UserGame userGame, List linked) { + Long userId = userGame.getUserId(); + List 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 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 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)); + } } diff --git a/src/main/java/core_service/modules/library/service/LibraryQueryService.java b/src/main/java/core_service/modules/library/service/LibraryQueryService.java index e17d450..8f5b90f 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -288,6 +288,7 @@ public class LibraryQueryService { .collect(Collectors.toMap(UserGameAchievementMeta::getAppid, m -> m)); Map editions = gameLinkRepository.editionCounts(games.getFirst().getUserId(), appids); Map> platforms = gameLinkRepository.entryPlatforms(games.getFirst().getUserId(), appids); + Map totals = gameLinkRepository.entryPlaytimes(games.getFirst().getUserId(), appids); Map coming = comingOf(appids); Map trophies = AchievementProgress .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()), schemas.get(ug.getAppid()), unlocks.get(ug.getAppid()), editions.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(); } @@ -456,7 +457,7 @@ public class LibraryQueryService { private static GameDto toDto(UserGame ug, SteamGame meta, GameTimeToBeat time, GameAchievementSchemaMeta schema, UserGameAchievementMeta unlocks, Integer editions, List platforms, ComingGame coming, - AchievementProgress trophies) { + AchievementProgress trophies, int totalPlaytimeMinutes) { Integer achievementsTotal = ug.getSource() == GameSource.STEAM && schema != null && schema.isHasSchema() && schema.getAchievementsTotal() > 0 ? schema.getAchievementsTotal() : null; Integer achievementsUnlocked = achievementsTotal != null && unlocks != null && unlocks.isStatsVisible() @@ -486,7 +487,8 @@ public class LibraryQueryService { editions, coming != 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. diff --git a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java index 127a9bf..139ead1 100644 --- a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java +++ b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java @@ -310,9 +310,12 @@ public class LibrarySyncWorker { if (existing == null || existing.getRemovedAt() != null) { changes.add(SyncChange.added(appid)); } + Instant lastPlayed = g.rtimeLastPlayed() != null && g.rtimeLastPlayed() > 0 + ? Instant.ofEpochSecond(g.rtimeLastPlayed()) : null; if (existing == null) { UserGame created = new UserGame(userId, appid, playtime, now, now); created.setPlaytime2Weeks(playtime2Weeks); + created.setLastPlayedAt(lastPlayed); userGameRepository.save(created); } else { existing.setPlaytimeMinutes(playtime); @@ -321,6 +324,9 @@ public class LibrarySyncWorker { existing.setRemovedAt(null); existing.setSource(GameSource.STEAM); existing.setPlatform(null); + if (lastPlayed != null) { + existing.setLastPlayedAt(lastPlayed); + } } if (playedOn == null || takesOverManualGame) { return false; diff --git a/src/main/java/core_service/modules/library/service/PsnSyncStep.java b/src/main/java/core_service/modules/library/service/PsnSyncStep.java index 4cc3797..4f9f0f5 100644 --- a/src/main/java/core_service/modules/library/service/PsnSyncStep.java +++ b/src/main/java/core_service/modules/library/service/PsnSyncStep.java @@ -6,6 +6,7 @@ import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.IgdbGame; import core_service.modules.library.domain.PsnTitle; 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.UserGameId; import core_service.modules.library.repo.GameImportRepository; @@ -112,6 +113,8 @@ public class PsnSyncStep { } } 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(); changes.addAll(trophyStep.run(userId, found.get().accessToken(), titles)); log.info("PSN sync user={} added {} titles, {} wait for review, {} games gained trophies", userId, added, diff --git a/src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamOwnedGamesResponse.java b/src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamOwnedGamesResponse.java index 90651b9..6ac0bc5 100644 --- a/src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamOwnedGamesResponse.java +++ b/src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamOwnedGamesResponse.java @@ -21,6 +21,8 @@ public record SteamOwnedGamesResponse( @JsonProperty("playtime_forever") Integer playtimeForever, @JsonProperty("img_icon_url") String imgIconUrl, @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 ) {} } diff --git a/src/main/resources/db/migration/V44__Platform_Play.sql b/src/main/resources/db/migration/V44__Platform_Play.sql new file mode 100644 index 0000000..1982a23 --- /dev/null +++ b/src/main/resources/db/migration/V44__Platform_Play.sql @@ -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; diff --git a/src/test/java/core_service/modules/library/api/GameLinkFlowTest.java b/src/test/java/core_service/modules/library/api/GameLinkFlowTest.java index 76838a5..645f756 100644 --- a/src/test/java/core_service/modules/library/api/GameLinkFlowTest.java +++ b/src/test/java/core_service/modules/library/api/GameLinkFlowTest.java @@ -80,7 +80,7 @@ class GameLinkFlowTest extends AbstractIntegrationTest { @BeforeEach 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); } userGameRepository.deleteAll(); @@ -293,6 +293,19 @@ class GameLinkFlowTest extends AbstractIntegrationTest { .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 void games_AnOldClientsHideDuplicatesParamChangesNothing() throws Exception { linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent()); diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerAchievementsTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerAchievementsTest.java index 0fe0b16..373fb59 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerAchievementsTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerAchievementsTest.java @@ -217,7 +217,7 @@ class LibraryControllerAchievementsTest extends AbstractMockedAdaptersTest { @Test void librarySync_StartsTheAchievementSyncAfterCommit() { 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.getSchemaForGame(anyLong())).thenReturn(Optional.empty()); diff --git a/src/test/java/core_service/modules/library/api/SyncChangesFlowTest.java b/src/test/java/core_service/modules/library/api/SyncChangesFlowTest.java index e69b8be..95f513f 100644 --- a/src/test/java/core_service/modules/library/api/SyncChangesFlowTest.java +++ b/src/test/java/core_service/modules/library/api/SyncChangesFlowTest.java @@ -60,7 +60,7 @@ class SyncChangesFlowTest extends AbstractMockedAdaptersTest { } 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) { diff --git a/src/test/java/core_service/modules/library/domain/PlatformPlayTest.java b/src/test/java/core_service/modules/library/domain/PlatformPlayTest.java new file mode 100644 index 0000000..7aa5091 --- /dev/null +++ b/src/test/java/core_service/modules/library/domain/PlatformPlayTest.java @@ -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 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 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 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 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 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 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 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 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); + } +} diff --git a/src/test/java/core_service/modules/library/repo/GameLinkRepositoryTest.java b/src/test/java/core_service/modules/library/repo/GameLinkRepositoryTest.java index da01b78..85c94b5 100644 --- a/src/test/java/core_service/modules/library/repo/GameLinkRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/GameLinkRepositoryTest.java @@ -1,8 +1,13 @@ package core_service.modules.library.repo; 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.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 org.junit.jupiter.api.BeforeEach; 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.jdbc.core.JdbcTemplate; +import java.time.Instant; import java.util.List; import java.util.Map; import java.util.stream.Stream; @@ -259,6 +265,81 @@ class GameLinkRepositoryTest extends AbstractPersistenceTest { 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 void entryPlatforms_SteamFirstThenPlayStationThenManual_AndAPlayStationTitleWithoutNameIsDropped() { 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(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); + } } diff --git a/src/test/java/core_service/modules/library/repo/PsnTitleRepositoryTest.java b/src/test/java/core_service/modules/library/repo/PsnTitleRepositoryTest.java index 0469496..09bceb5 100644 --- a/src/test/java/core_service/modules/library/repo/PsnTitleRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/PsnTitleRepositoryTest.java @@ -1,16 +1,19 @@ package core_service.modules.library.repo; import core_service.modules.library.domain.PsnTitle; +import core_service.modules.library.domain.TitlePlay; import core_service.shared.test.AbstractPersistenceTest; 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.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; @Import(PsnTitleRepository.class) class PsnTitleRepositoryTest extends AbstractPersistenceTest { @@ -72,4 +75,42 @@ class PsnTitleRepositoryTest extends AbstractPersistenceTest { assertThat(repository.findAll(3L).get("c1").appid()).isEqualTo(-1L); 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 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(); + } } diff --git a/src/test/java/core_service/modules/library/repo/QuestionRepositoryTest.java b/src/test/java/core_service/modules/library/repo/QuestionRepositoryTest.java index d1c7abc..6be4ed7 100644 --- a/src/test/java/core_service/modules/library/repo/QuestionRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/QuestionRepositoryTest.java @@ -155,4 +155,13 @@ class QuestionRepositoryTest extends AbstractPersistenceTest { jdbc.update("delete from user_game where appid = 50"); 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")); + } } diff --git a/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java b/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java index 723f676..cbf86c2 100644 --- a/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java +++ b/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java @@ -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.GameDetailDto; +import core_service.modules.library.api.dto.PlatformPlayDto; import core_service.modules.library.domain.AchievementKind; import core_service.modules.library.domain.PsnEarned; 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.UserGameId; 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.GameAchievementSchemaRepository; 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.web.client.ResourceAccessException; +import java.sql.Timestamp; import java.time.Duration; import java.time.Instant; import java.util.List; @@ -86,6 +89,9 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest { @Autowired private PsnTrophyRepository psnTrophyRepository; + @Autowired + private GameLinkRepository gameLinkRepository; + @Autowired private JdbcTemplate jdbc; @@ -99,6 +105,8 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest { gameSpeedrunRepository.deleteAll(); gameTimeToBeatRepository.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_set"); jdbc.update("delete from psn_trophy"); @@ -292,4 +300,44 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest { assertEquals(4, detail.achievements().size()); 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()); + } } diff --git a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerConcurrencyTest.java b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerConcurrencyTest.java index f0aead2..c11ae36 100644 --- a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerConcurrencyTest.java +++ b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerConcurrencyTest.java @@ -61,7 +61,7 @@ class LibrarySyncWorkerConcurrencyTest extends AbstractMockedAdaptersTest { void runSyncNow_TheFollowUpStoresTheSpeedrunRow() { when(steamApiService.getWishlist(STEAM_ID)).thenReturn(Optional.empty()); 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.findGames("Half-Life 2")).thenReturn(Optional.of(List.of( 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) { - 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); } } diff --git a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java index e5f30b2..e03d78e 100644 --- a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java +++ b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java @@ -68,7 +68,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest { private static final String OTHER_STEAM_ID = "76561198005679790"; 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() { @@ -297,6 +297,44 @@ class LibrarySyncWorkerTest extends AbstractUnitTest { 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 void runSync_PlaytimeGrew_RecordsTheDifferenceForToday() { owned(100); @@ -618,7 +656,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest { void runSync_GameWithoutName_GetsTheAppidAsName() { when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1); 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))); 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(steamGameRepository.findAllById(List.of(2000001L))).thenReturn(List.of(stored)); 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); @@ -673,7 +711,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest { } 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 diff --git a/src/test/java/core_service/modules/library/service/PsnSyncStepTest.java b/src/test/java/core_service/modules/library/service/PsnSyncStepTest.java index 0386d34..22f7a01 100644 --- a/src/test/java/core_service/modules/library/service/PsnSyncStepTest.java +++ b/src/test/java/core_service/modules/library/service/PsnSyncStepTest.java @@ -7,6 +7,7 @@ import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.IgdbGame; import core_service.modules.library.domain.PsnTitle; 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.UserGameId; import core_service.modules.library.repo.GameImportRepository; @@ -195,6 +196,38 @@ class PsnSyncStepTest extends AbstractUnitTest { 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> 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> plays = ArgumentCaptor.captor(); + verify(psnTitleRepository).savePlays(eq(USER), plays.capture()); + assertThat(plays.getValue()).containsExactly(new TitlePlay("c7", null, "PS5", 120, LAST_PLAYED)); + } + @Test void run_DismissedTitleAndTitleOfAnOpenImport_AreNotSearchedNorOffered() { PsnApiService.Title other = new PsnApiService.Title("PPSA2_00", "Other", "ps5_native_game", Duration.ZERO, 8L, null); diff --git a/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceCacheTest.java b/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceCacheTest.java index 8fb9793..ab86010 100644 --- a/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceCacheTest.java +++ b/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceCacheTest.java @@ -109,7 +109,7 @@ class SteamApiServiceCacheTest extends AbstractIntegrationTest { @Test void getOwnedGames_TransientSteamError_IsTriedAgain() { 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)) .thenAnswer(invocation -> { if (steamCalls.incrementAndGet() == 1) { diff --git a/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceTest.java b/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceTest.java index 04de180..47de518 100644 --- a/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceTest.java +++ b/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceTest.java @@ -88,7 +88,8 @@ class SteamApiServiceTest { "name": "Counter-Strike", "playtime_forever": 32, "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.getFirst().name()).isEqualTo("Counter-Strike"); + assertThat(result.getFirst().rtimeLastPlayed()).isEqualTo(1_760_000_000L); } @Test