From 4ed8a341af334cc5977d47be61024b10893d0639 Mon Sep 17 00:00:00 2001 From: lobo Date: Fri, 9 Oct 2026 14:35:07 +0200 Subject: [PATCH] feat(library): use set total playtime in sort and stats Sort, total playtime, most played, genres, moods and the public profile use the set total of an entry, as the library card does. New view user_game_entry_playtime (V45) is the one SQL rule. --- readme.md | 27 ++++--- .../modules/library/domain/PlatformPlay.java | 4 +- .../library/domain/UserGameEntryPlaytime.java | 34 +++++++++ .../library/repo/GameLinkRepository.java | 22 ++---- .../library/repo/UserGameRepository.java | 63 ++++++++++------ .../library/repo/UserStatsRepository.java | 9 ++- .../library/service/LibraryQueryService.java | 14 ++-- .../db/migration/V45__Entry_Playtime.sql | 15 ++++ .../api/PublicProfileControllerTest.java | 26 +++++++ .../library/repo/UserGameRepositoryTest.java | 72 +++++++++++++++++++ .../library/repo/UserStatsRepositoryTest.java | 29 ++++++++ 11 files changed, 253 insertions(+), 62 deletions(-) create mode 100644 src/main/java/core_service/modules/library/domain/UserGameEntryPlaytime.java create mode 100644 src/main/resources/db/migration/V45__Entry_Playtime.sql 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(