diff --git a/readme.md b/readme.md index 4e1e9c0..9050257 100644 --- a/readme.md +++ b/readme.md @@ -118,8 +118,10 @@ flowchart LR `listed_user_game`, V24). A linked set (see "Editions of one game") counts once: its most advanced edition (status, then playtime, then the older `first_seen_at`; `counted_user_game`, V28), so the set is on the pile only when every owned edition is `BACKLOG`. Most played, most loyal, genres, moods, perfect games and the longest day list one entry per set, with the playtime of all its editions - (`user_game_entry_member`, V43; its list shows that sum as `minutes`). Platforms and playtime sums (`sumPlaytime`) stay - per edition: a Steam + PlayStation set counts on both platforms. + (`user_game_entry_member`, V43; its list shows that sum as `minutes`). Every playtime number (most played, genres, + moods, the total playtime and moon trips, the drill-down lists) uses the set total, the view + `user_game_entry_playtime` (V45), the same as the library card, sort and public profile. Platforms stay per edition: + a Steam + PlayStation set counts on both platforms. - **Drill-down**: every countable number opens the games behind it. A `StatKey` (library), `CommunityKey` (landing page) or a Pile Points track/bonus row names a number; its SQL "game set" is the same text the number counts, so the list and the number cannot drift. 40 games per page (at most page 250), a fixed order per key. A profile visitor @@ -349,7 +351,7 @@ reads `deleted_user` and `blocked_user`, and for the picker `follow`, `public_us The listed rule ("still owned, not hidden") is `UserGameRepository.LISTED` for JPQL and the view `listed_user_game` for native SQL; V24 says what a migration of `user_game` must do with the view. The numbers that count games once per linked set read `counted_user_game` (V28), built on `listed_user_game`: a migration that changes -a column of `user_game` drops the views built on it first: `user_game_entry_platform`, `user_game_entry_member`, `counted_user_game`, then `listed_user_game`. +a column of `user_game` drops the views built on it first: `user_game_entry_playtime`, `user_game_entry_platform`, `user_game_entry_member`, `counted_user_game`, then `listed_user_game`. The two entry views (V43) map an entry (a counted row) to its editions and to their platforms: `user_game_entry_member` is the one place that does it (stats and the library read it); `user_game_entry_platform` adds each edition's source and platform and a PlayStation row for a `LINKED` or `STEAM_OWNED` title on that key. @@ -562,19 +564,20 @@ IGDB id; remakes (8) keep their own id. `null` when IGDB does not know the game. library and its total and badge, the dashboard, the recent games, the public profile lists, stats and their 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. The card playtime is the set total (`entryPlaytimes`, see "Detail per platform"). Pile Points pay a set once (see "Pile Points"), and the + purpose: the hidden list, the platform stat, the average achievement completion (per edition; the tile's game counts are per entry) and the + community stats of the landing page. The playtime of a card, the playtime sort and the stats is the set total (`user_game_entry_playtime`, V45, 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. + The library query joins `user_game_entry_playtime` on every page (about +4 ms per page); upgrade path: join it only for the playtime sort or the PLAYING status, with a second query. - **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 + PlayStation line. The total is their sum; the view `user_game_entry_playtime` (V45) is the same rule in SQL, a test + compares both. The card total (`GameLinkRepository.entryPlaytimes`), the playtime sort of the library, `sumPlaytime` + and the stats read that view; it has no correlated subquery on purpose (it made a library page cost 2 s). 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 + platform: the playtime history (PSN minutes on a Steam row are not in it) 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), `platforms` (`source` + `platform` per edition, for @@ -590,8 +593,10 @@ flowchart LR Counted --> Member["user_game_entry_member"] Member --> Platform["user_game_entry_platform"] Platform -->|"platforms[]"| Card["library card, lists"] - Member -->|"totalPlaytimeMinutes"| Card - Title["user_psn_title play (V44)"] -->|"psn_title_play"| Member + EntryPlaytime -->|"totalPlaytimeMinutes"| Card + EntryPlaytime --> Stats["library sort, stats, public profile"] + Member --> EntryPlaytime["user_game_entry_playtime (V45)"] + Title["user_psn_title play (V44)"] -->|"psn_title_play"| EntryPlaytime 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 diff --git a/src/main/java/core_service/modules/library/domain/PlatformPlay.java b/src/main/java/core_service/modules/library/domain/PlatformPlay.java index 8a36015..88484ce 100644 --- a/src/main/java/core_service/modules/library/domain/PlatformPlay.java +++ b/src/main/java/core_service/modules/library/domain/PlatformPlay.java @@ -6,8 +6,8 @@ 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. + * One platform's play of a game entry: what the detail lists and the card totals. The view + * {@code user_game_entry_playtime} (V45) 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 diff --git a/src/main/java/core_service/modules/library/domain/UserGameEntryPlaytime.java b/src/main/java/core_service/modules/library/domain/UserGameEntryPlaytime.java new file mode 100644 index 0000000..8149a17 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/UserGameEntryPlaytime.java @@ -0,0 +1,34 @@ +package core_service.modules.library.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Immutable; + +/** + * The set total of an entry, see V45; hidden rows have none. + */ +@Entity +@Immutable +@Table(name = "user_game_entry_playtime") +@IdClass(UserGameId.class) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserGameEntryPlaytime { + + @Id + @Column(name = "user_id") + private Long userId; + + @Id + @Column(name = "appid") + private Long appid; + + @Column(name = "playtime_minutes", nullable = false) + private int playtimeMinutes; +} 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 f84544a..eef5d9e 100644 --- a/src/main/java/core_service/modules/library/repo/GameLinkRepository.java +++ b/src/main/java/core_service/modules/library/repo/GameLinkRepository.java @@ -336,30 +336,16 @@ public class GameLinkRepository { } /** - * @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}. + * @return per given entry key, the playtime of all its editions: the view of V45, 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")); }); + jdbc.query("select appid, playtime_minutes from user_game_entry_playtime where user_id = :user " + + "and appid in (:appids)", new MapSqlParameterSource("user", userId).addValue("appids", appids), + rs -> { playtimes.put(rs.getLong("appid"), rs.getInt("playtime_minutes")); }); return playtimes; } diff --git a/src/main/java/core_service/modules/library/repo/UserGameRepository.java b/src/main/java/core_service/modules/library/repo/UserGameRepository.java index 0e74f0e..0fe39cb 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java @@ -42,6 +42,29 @@ public interface UserGameRepository extends JpaRepository */ String LISTED = "ug.userId = :userId and ug.removedAt is null and ug.hiddenAt is null "; + /** + * The filters of {@link #findOwnedGames}, shared by its value and count query. Needs the aliases {@code ug} and {@code sg}. + */ + String OWNED_WHERE = "where ug.userId = :userId and ug.removedAt is null " + + "and ((:hidden = true and ug.hiddenAt is not null) or (:hidden = false and ug.hiddenAt is null)) " + + "and (:status is null or ug.status = :status) " + + "and (:namePattern is null or (:hidden = true and lower(sg.name) like :namePattern escape '\\') " + + " or (:hidden = false and exists (select 1 from UserGameEntryPlatform p " + + " join SteamGame mg on mg.appid = p.memberAppid " + + " where p.userId = ug.userId and p.appid = ug.appid and lower(mg.name) like :namePattern escape '\\'))) " + + "and (:maxMinutes is null or exists (select 1 from GameTimeToBeat t " + + " where t.appid = ug.appid and t.normallyMinutes <= :maxMinutes)) " + + "and (:hidden = true or exists (select 1 from CountedUserGame c " + + " where c.userId = ug.userId and c.appid = ug.appid)) " + + "and (:hidden = true or exists (select 1 from UserGameEntryPlatform p " + + " where p.userId = ug.userId and p.appid = ug.appid and p.source in :sources))"; + + /** + * The sort text of the set total: the own minutes for a hidden row. The query needs + * {@code left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid}. + */ + String ENTRY_PLAYTIME_ORDER = "coalesce(ep.playtimeMinutes, ug.playtimeMinutes)"; + @Query("select ug from UserGame ug where " + LISTED + "and ug.source = core_service.modules.library.domain.GameSource.STEAM") List findListedSteamGames(@Param("userId") Long userId); @@ -70,24 +93,29 @@ public interface UserGameRepository extends JpaRepository @Query("select count(c) from CountedUserGame c where c.userId = :userId") long countListed(@Param("userId") Long userId); - @Query("select coalesce(sum(ug.playtimeMinutes), 0) from UserGame ug where " + LISTED) + /** + * The sum of the set totals of the listed entries, see V45. + */ + @Query("select coalesce(sum(ep.playtimeMinutes), 0) from UserGameEntryPlaytime ep where ep.userId = :userId") long sumPlaytime(@Param("userId") Long userId); - @Query("select ug from UserGame ug where " + LISTED + "and ug.status in :statuses " + + @Query("select ug from UserGame ug left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid " + + "where " + LISTED + "and ug.status in :statuses " + "and exists (select 1 from CountedUserGame c where c.userId = ug.userId and c.appid = ug.appid)") List findListedByStatusIn(@Param("userId") Long userId, @Param("statuses") Collection statuses, Pageable pageable); /** - * The entries played in the last two weeks, the most played first: the counted edition with the playtime of every - * edition of the set added up, see V43. + * The entries played in the last two weeks, the most played in them first: the counted edition, the minutes of the + * last two weeks of every edition of the set added up (Steam only), and the set total as playtime, see V45. */ - @Query(value = "select c.appid as appid, c.status as status, sum(m.playtime_minutes) as playtimeMinutes, " + + @Query(value = "select c.appid as appid, c.status as status, ep.playtime_minutes as playtimeMinutes, " + "sum(m.playtime_2weeks) as playtime2Weeks " + "from counted_user_game c " + + "join user_game_entry_playtime ep on ep.user_id = c.user_id and ep.appid = c.appid " + "join user_game_entry_member e on e.user_id = c.user_id and e.appid = c.appid " + "join listed_user_game m on m.user_id = e.user_id and m.appid = e.member_appid " + - "where c.user_id = :userId group by c.appid, c.status " + + "where c.user_id = :userId group by c.appid, c.status, ep.playtime_minutes " + "having sum(m.playtime_2weeks) > 0 order by sum(m.playtime_2weeks) desc, c.appid limit :limit", nativeQuery = true) List findEntriesPlayedInTwoWeeks(@Param("userId") Long userId, @Param("limit") int limit); @@ -342,20 +370,11 @@ public interface UserGameRepository extends JpaRepository * @param sources the sources one platform of the entry must have (a PlayStation title on a Steam row counts as * PSN); never empty, and ignored for the hidden games */ - @Query("select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " + - "where ug.userId = :userId and ug.removedAt is null " + - "and ((:hidden = true and ug.hiddenAt is not null) or (:hidden = false and ug.hiddenAt is null)) " + - "and (:status is null or ug.status = :status) " + - "and (:namePattern is null or (:hidden = true and lower(sg.name) like :namePattern escape '\\') " + - " or (:hidden = false and exists (select 1 from UserGameEntryPlatform p " + - " join SteamGame mg on mg.appid = p.memberAppid " + - " where p.userId = ug.userId and p.appid = ug.appid and lower(mg.name) like :namePattern escape '\\'))) " + - "and (:maxMinutes is null or exists (select 1 from GameTimeToBeat t " + - " where t.appid = ug.appid and t.normallyMinutes <= :maxMinutes)) " + - "and (:hidden = true or exists (select 1 from CountedUserGame c " + - " where c.userId = ug.userId and c.appid = ug.appid)) " + - "and (:hidden = true or exists (select 1 from UserGameEntryPlatform p " + - " where p.userId = ug.userId and p.appid = ug.appid and p.source in :sources))") + // ponytail: the view is joined on every page, not only for the playtime sort: about +4 ms per page. Upgrade path: + // join it only for the playtime sort or the PLAYING status, with a second query. + @Query(value = "select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " + + "left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid " + OWNED_WHERE, + countQuery = "select count(ug) from UserGame ug join SteamGame sg on sg.appid = ug.appid " + OWNED_WHERE) Page findOwnedGames(@Param("userId") Long userId, @Param("hidden") boolean hidden, @Param("status") GameStatus status, @@ -386,13 +405,15 @@ public interface UserGameRepository extends JpaRepository /** * Makes a {@link #findOwnedGames} sort stable and meaningful: games without a rating or priority come last * in both directions, priority sorts by HIGH, MEDIUM, LOW (the enum names would sort alphabetically), and - * appid breaks every tie so pages do not repeat or skip games. + * appid breaks every tie so pages do not repeat or skip games. Playtime sorts by the set total. */ static Sort querySort(Sort requested) { Sort result = Sort.unsorted(); for (Sort.Order order : requested) { result = result.and(switch (order.getProperty()) { case "rating" -> Sort.by(order.nullsLast()); + case "playtimeMinutes" -> Sort.by(JpaSort.unsafe(order.getDirection(), ENTRY_PLAYTIME_ORDER) + .iterator().next()); case "priority" -> Sort.by(JpaSort.unsafe(order.getDirection(), "(case ug.priority when 'HIGH' then 3 when 'MEDIUM' then 2 when 'LOW' then 1 end)") .iterator().next().nullsLast()); diff --git a/src/main/java/core_service/modules/library/repo/UserStatsRepository.java b/src/main/java/core_service/modules/library/repo/UserStatsRepository.java index 4466d52..1365e3c 100644 --- a/src/main/java/core_service/modules/library/repo/UserStatsRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserStatsRepository.java @@ -60,11 +60,9 @@ public class UserStatsRepository { from counted_user_game ug join game_time_to_beat t on t.appid = ug.appid where %s and ug.status = 'BACKLOG' and %s """.formatted(OF_USER, UserGameRepository.NOT_COMING); - /** Per entry (see V43), the playtime of all its editions; the user's other rows are not in it. */ + /** Per entry, the set total (V45); the user's other rows are not in it. */ private static final String ENTRY_PLAYTIME = """ - (select e.appid, sum(m.playtime_minutes) as playtime_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 - where e.user_id = :userId group by e.appid)"""; + (select ep.appid, ep.playtime_minutes from user_game_entry_playtime ep where ep.user_id = :userId)"""; private static final String MOST_PLAYED_FROM = """ from %s ug join steam_game sg on sg.appid = ug.appid where ug.playtime_minutes > 0 @@ -300,12 +298,13 @@ public class UserStatsRepository { */ public List games(long userId, StatKey key, Object arg, ZoneId zone, int offset, int limit) { return jdbc.query(""" - select s.appid, sg.name, sg.image_url, ug.status, coalesce(ug.playtime_minutes, 0) as playtime, + select s.appid, sg.name, sg.image_url, ug.status, coalesce(ep.playtime_minutes, ug.playtime_minutes, 0) as playtime, s.minutes, s.event_at, s.from_status, s.to_status, cg.appid is not null as coming, cg.release_date as coming_on from (%s) s left join steam_game sg on sg.appid = s.appid left join coming_game cg on cg.appid = s.appid left join user_game ug on ug.user_id = :userId and ug.appid = s.appid + left join user_game_entry_playtime ep on ep.user_id = :userId and ep.appid = s.appid order by %s, s.appid limit :limit offset :offset """.formatted(SETS.get(key), ORDERS.getOrDefault(key, BY_PLAYTIME)), gameParams(userId, arg, zone).addValue("limit", limit).addValue("offset", offset), 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 8f5b90f..8a1d002 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -57,6 +57,7 @@ import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.JpaSort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -405,7 +406,8 @@ public class LibraryQueryService { ? userGameRepository.countEveryStatus(userId) : null; List playing = sections.contains(ProfileSection.PLAYING) ? userGameRepository.findListedByStatusIn(userId, List.of(GameStatus.PLAYING), - PageRequest.of(0, PUBLIC_PLAYING, Sort.by(Sort.Direction.DESC, "playtimeMinutes").and(Sort.by("appid")))) + PageRequest.of(0, PUBLIC_PLAYING, JpaSort.unsafe(Sort.Direction.DESC, + UserGameRepository.ENTRY_PLAYTIME_ORDER).and(Sort.by("appid")))) : null; List finished = sections.contains(ProfileSection.FINISHED) ? userGameRepository.findListedByStatusIn(userId, List.of(GameStatus.FINISHED, GameStatus.COMPLETED), @@ -416,6 +418,7 @@ public class LibraryQueryService { Map meta = steamGameRepository.findAllById(shownAppids) .stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g)); Map coming = comingOf(shownAppids); + Map totals = gameLinkRepository.entryPlaytimes(userId, shownAppids); List started = summary ? AchievementProgress.started(progress(userId)) : null; Set shared = sections.contains(ProfileSection.STATS) ? StatGroup.shared(profileStats) : Set.of(); return new PublicLibraryDto( @@ -423,7 +426,7 @@ public class LibraryQueryService { summary ? userGameRepository.sumPlaytime(userId) : null, summary ? byStatus.get(GameStatus.FINISHED) + byStatus.get(GameStatus.COMPLETED) : null, sections.contains(ProfileSection.STATUS) ? byStatus : null, - toPublicGames(playing, meta, coming), toPublicGames(finished, meta, coming), + toPublicGames(playing, meta, coming, totals), toPublicGames(finished, meta, coming, totals), summary ? entries(userId, AchievementProgress.perfectAppids(started)) : null, summary ? averageCompletion(AchievementProgress.perGame(started)) : null, shared.isEmpty() ? null : userStatsService.getStats(userId, shared)); @@ -433,15 +436,16 @@ public class LibraryQueryService { * @return null for a hidden section */ private static List toPublicGames(List games, Map meta, - Map coming) { + Map coming, + Map totals) { if (games == null) { return null; } return games.stream() .map(ug -> new PublicLibraryDto.Game(ug.getAppid(), meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getName() : null, - ug.getPlaytimeMinutes(), ug.getStatus(), ug.getRating(), ug.getPlatform(), - meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getImageUrl() : null, + totals.getOrDefault(ug.getAppid(), ug.getPlaytimeMinutes()), ug.getStatus(), ug.getRating(), + ug.getPlatform(), meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getImageUrl() : null, coming.containsKey(ug.getAppid()), coming.containsKey(ug.getAppid()) ? coming.get(ug.getAppid()).getReleaseDate() : null)) .toList(); diff --git a/src/main/resources/db/migration/V45__Entry_Playtime.sql b/src/main/resources/db/migration/V45__Entry_Playtime.sql new file mode 100644 index 0000000..da733ed --- /dev/null +++ b/src/main/resources/db/migration/V45__Entry_Playtime.sql @@ -0,0 +1,15 @@ +-- The set total of an entry (Gitea #36), the SQL form of PlatformPlay#total: a row's own minutes plus the minutes of +-- the PlayStation titles on it; a PlayStation row with title play, or a manual row with a title of its platform, counts +-- its titles only. Capped at the int range. Hidden and removed rows have no entry. +-- A migration that changes a user_game column drops this view first (before user_game_entry_platform), then creates +-- it again. No correlated subquery here: it made a library page cost 2 s. +CREATE VIEW user_game_entry_playtime AS +SELECT e.user_id, e.appid, + CAST(LEAST(SUM(CASE WHEN m.source = 'PSN' AND t.minutes IS NOT NULL + OR m.source = 'MANUAL' AND m.platform = ANY (t.platforms) THEN 0 + ELSE m.playtime_minutes END + COALESCE(t.minutes, 0)), 2147483647) AS INTEGER) AS playtime_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 user_id, appid, SUM(playtime_minutes) AS minutes, ARRAY_AGG(platform) AS platforms + FROM psn_title_play GROUP BY user_id, appid) t ON t.user_id = m.user_id AND t.appid = m.appid +GROUP BY e.user_id, e.appid; diff --git a/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java b/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java index 90586d3..337e7d2 100644 --- a/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java +++ b/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java @@ -3,6 +3,7 @@ package core_service.modules.library.api; import core_service.modules.library.domain.GameAchievementSchemaMeta; import core_service.modules.library.domain.GamePriority; import core_service.modules.library.domain.PsnTrophySet; +import core_service.modules.library.domain.StatGroup; import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.UserGame; @@ -13,6 +14,7 @@ import core_service.modules.library.repo.PsnTrophyRepository; import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.UserGameAchievementMetaRepository; import core_service.modules.library.repo.UserGameRepository; +import core_service.modules.library.service.UserStatsService; import core_service.shared.test.AbstractIntegrationTest; import core_service.shared.test.ContractTokens; import org.junit.jupiter.api.BeforeEach; @@ -28,10 +30,12 @@ import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; import java.util.List; +import java.util.Set; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.nullValue; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -86,6 +90,9 @@ class PublicProfileControllerTest extends AbstractIntegrationTest { @Autowired private JdbcTemplate jdbc; + @Autowired + private UserStatsService userStatsService; + private void platinum(long appid) { psnTrophyRepository.saveSet(USER_ID, appid, new PsnTrophySet("NPWR1_00", "trophy2", "01.00", "Set", null, "PS5", 50, 0, 0, 0, 30, true, Instant.now())); @@ -211,6 +218,25 @@ class PublicProfileControllerTest extends AbstractIntegrationTest { .andExpect(jsonPath("$.stats").value(nullValue())); } + @Test + void publicProfile_PlaytimeNumbersEqualTheOwnersStats() throws Exception { + jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes) " + + "values (?, 'c1', 'STEAM_OWNED', 220, 'PS5', 700)", USER_ID); + jdbc.update("update user_game set status = 'PLAYING' where user_id = ? and appid = 620", USER_ID); + var own = userStatsService.getStats(USER_ID, Set.of(StatGroup.OVERVIEW, StatGroup.TOP_GAMES)); + + assertEquals(1000L + 900 + 60, own.overview().totalPlaytimeMinutes()); + mockMvc.perform(get("/api/public/profiles/library") + .header("X-Profile-Ticket", ticket(USER_ID, List.of("TOP_GAMES", "OVERVIEW")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalPlaytimeMinutes").value(own.overview().totalPlaytimeMinutes())) + .andExpect(jsonPath("$.playing", hasSize(2))) + .andExpect(jsonPath("$.playing[0].name").value("Half-Life 2")) + .andExpect(jsonPath("$.playing[0].playtimeMinutes").value(1000)) + .andExpect(jsonPath("$.stats.topGames.mostPlayed[0].appid").value(220)) + .andExpect(jsonPath("$.stats.topGames.mostPlayed[0].minutes").value(1000)); + } + @Test void publicLibrary_OnlyTheSharedStatGroups() throws Exception { mockMvc.perform(get("/api/public/profiles/library") diff --git a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java index 35df79c..6635f53 100644 --- a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java @@ -15,6 +15,7 @@ import jakarta.persistence.EntityManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.jdbc.core.JdbcTemplate; @@ -31,10 +32,13 @@ import java.util.EnumSet; import java.util.List; import java.util.Set; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +@Import(GameLinkRepository.class) class UserGameRepositoryTest extends AbstractPersistenceTest { private static final long USER_ID = 1L; @@ -45,6 +49,9 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { @Autowired private UserGameRepository userGameRepository; + @Autowired + private GameLinkRepository gameLinkRepository; + @Autowired private JdbcTemplate jdbc; @@ -218,6 +225,28 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { assertEquals(2, page.getTotalElements()); } + @Test + void findOwnedGames_CountQueryWithFilters_MatchesTheListedEntries() { + var page = userGameRepository.findOwnedGames(USER_ID, false, GameStatus.PLAYING, null, null, ALL_SOURCES, + PageRequest.of(0, 1, Sort.by("appid"))); + + assertEquals(1, page.getContent().size()); + assertEquals(appids(GameStatus.PLAYING, null).size(), page.getTotalElements()); + } + + @Test + void findOwnedGames_HiddenGames_SortByTheirOwnPlaytime() { + hide(620L, 700L); + titlePlay("c1", 700L, "STEAM_OWNED", "PS5", 2000); + + // hidden games are not in the entry view: they sort by their own playtime + var desc = userGameRepository.findOwnedGames(USER_ID, true, null, null, null, ALL_SOURCES, + PageRequest.of(0, 20, UserGameRepository.querySort(Sort.by(Sort.Direction.DESC, "playtimeMinutes")))); + + assertEquals(List.of(620L, 700L), desc.map(UserGame::getAppid).getContent()); + assertEquals(2, desc.getTotalElements()); + } + private List sortedAppids(Sort sort) { return userGameRepository.findOwnedGames(USER_ID, false, null, null, null, ALL_SOURCES, PageRequest.of(0, 20, UserGameRepository.querySort(sort))) .map(UserGame::getAppid).getContent(); @@ -241,6 +270,49 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { assertEquals(List.of(380L, 700L, 220L, 620L), sortedAppids(Sort.by(Sort.Direction.DESC, "priority"))); } + 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_ID, key, state, appid, platform, minutes); + } + + @Test + void findOwnedGames_SortsByPlaytimeByTheEntryTotal() { + titlePlay("c1", 220L, "STEAM_OWNED", "PS5", 700); + + assertEquals(List.of(220L, 620L, 380L, 700L), sortedAppids(Sort.by(Sort.Direction.DESC, "playtimeMinutes"))); + assertEquals(List.of(380L, 700L, 620L, 220L), sortedAppids(Sort.by(Sort.Direction.ASC, "playtimeMinutes"))); + } + + @Test + void sumPlaytime_IsTheSumOfTheEntryTotals() { + titlePlay("c1", 220L, "STEAM_OWNED", "PS5", 700); + steamGameRepository.saveAllAndFlush(List.of(new SteamGame(800L, "Bloodborne", null, Instant.now()), + new SteamGame(-7L, "Manual", null, Instant.now()))); + jdbc.update("insert into user_game (user_id, appid, status, source, platform, playtime_minutes) " + + "values (?, 800, 'BACKLOG', 'PSN', 'PS5', 50)", USER_ID); + titlePlay("c2", 800L, "LINKED", "PS5", 400); + jdbc.update("insert into user_game (user_id, appid, status, source, platform, playtime_minutes) " + + "values (?, -7, 'BACKLOG', 'MANUAL', 'PS5', 30)", USER_ID); + titlePlay("c3", -7L, "LINKED", "PS5", 90); + + long sum = userGameRepository.sumPlaytime(USER_ID); + + assertEquals(1000 + 900 + 400 + 90, sum); + assertEquals(sum, gameLinkRepository.entryPlaytimes(USER_ID, List.of(220L, 380L, 620L, 700L, 800L, -7L)) + .values().stream().mapToLong(Integer::longValue).sum()); + } + + @Test + void findEntriesPlayedInTwoWeeks_ShowsTheEntryTotal() { + titlePlay("c1", 220L, "STEAM_OWNED", "PS5", 700); + userGameRepository.findById(new UserGameId(USER_ID, 220L)).orElseThrow().setPlaytime2Weeks(30); + userGameRepository.flush(); + + assertThat(userGameRepository.findEntriesPlayedInTwoWeeks(USER_ID, 20)) + .extracting(RecentEntry::getAppid, RecentEntry::getPlaytimeMinutes) + .containsExactly(tuple(220L, 1000L)); + } + @Test void querySort_EqualPlaytime_BreaksTiesByAppid() { assertEquals(List.of(620L, 220L, 380L, 700L), sortedAppids(Sort.by(Sort.Direction.DESC, "playtimeMinutes"))); diff --git a/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java b/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java index a659d66..0b0bba1 100644 --- a/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java @@ -179,6 +179,35 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest { .containsExactly(620L, 220L); } + private void seedEntryTotal() { + userGameRepository.saveAllAndFlush(List.of( + game(220L, 100, GameStatus.PLAYING, Instant.now()), + game(620L, 300, GameStatus.PLAYING, Instant.now()))); + jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes) " + + "values (?, 'c1', 'STEAM_OWNED', 220, 'PS5', 500)", USER_ID); + } + + @Test + void mostPlayed_UsesTheEntryTotal() { + seedEntryTotal(); + + assertThat(repository.mostPlayed(USER_ID, 5)).extracting(UserStats.GameMinutes::appid, UserStats.GameMinutes::minutes) + .containsExactly(tuple(220L, 600L), tuple(620L, 300L)); + } + + @Test + void games_ListsShowTheEntryTotal() { + seedEntryTotal(); + + for (StatKey key : List.of(StatKey.MOST_PLAYED, StatKey.TOTAL)) { + assertThat(repository.games(USER_ID, key, "", BERLIN, 0, 10)) + .extracting(UserStats.StatGame::appid, UserStats.StatGame::playtimeMinutes) + .contains(tuple(220L, 600L)); + } + assertThat(repository.games(USER_ID, StatKey.MOST_PLAYED, "", BERLIN, 0, 10)) + .extracting(UserStats.StatGame::appid).containsExactly(220L, 620L); + } + @Test void mostLoyal_MostMonthsWins_MoreMinutesBreakATie() { userGameRepository.saveAllAndFlush(List.of(