Public Access
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.
This commit is contained in:
1 parent
1ff1e061c7
commit
4ed8a341af
11 files changed
+253
-62
No files matched your search
@@ -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
|
`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,
|
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
|
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
|
(`user_game_entry_member`, V43; its list shows that sum as `minutes`). Every playtime number (most played, genres,
|
||||||
per edition: a Steam + PlayStation set counts on both platforms.
|
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)
|
- **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
|
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
|
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
|
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
|
`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
|
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`
|
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
|
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.
|
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
|
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
|
drill-downs. There is no "Hide duplicates" filter any more; a hidden game is its own row under `hidden=true`. The
|
||||||
friends row of the picker matches a friend's game to the caller's entry through `game_link`. Kept per edition on
|
friends row of the picker matches a friend's game to the caller's entry through `game_link`. Kept per edition on
|
||||||
purpose: the hidden list, the platform stat, the average achievement completion (per edition; the tile's game counts are per entry), `sumPlaytime` and the
|
purpose: the hidden list, the platform stat, the average achievement completion (per edition; the tile's game counts are per entry) 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
|
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.
|
`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`
|
- **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
|
(`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
|
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
|
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
|
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
|
PlayStation line. The total is their sum; the view `user_game_entry_playtime` (V45) is the same rule in SQL, a test
|
||||||
(`GameLinkRepository.entryPlaytimes`) follows the same rule in SQL, a test compares both. Each line carries its own
|
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
|
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
|
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
|
||||||
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.
|
lines are missing and Steam's last played is empty.
|
||||||
- The game detail has `linked` and `editions` (the user's other listed games of the set); a game row has `editions`
|
- The game detail has `linked` and `editions` (the user's other listed games of the set); a game row has `editions`
|
||||||
(size of the set in the user's library, `null` below 2), `platforms` (`source` + `platform` per edition, for
|
(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"]
|
Counted --> Member["user_game_entry_member"]
|
||||||
Member --> Platform["user_game_entry_platform"]
|
Member --> Platform["user_game_entry_platform"]
|
||||||
Platform -->|"platforms[]"| Card["library card, lists"]
|
Platform -->|"platforms[]"| Card["library card, lists"]
|
||||||
Member -->|"totalPlaytimeMinutes"| Card
|
EntryPlaytime -->|"totalPlaytimeMinutes"| Card
|
||||||
Title["user_psn_title play (V44)"] -->|"psn_title_play"| Member
|
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"]
|
Title -->|"platforms[] of the detail"| Detail["game detail"]
|
||||||
Set -->|"join: align_user_game_set"| Row["user_game (status, rating, notes, priority)"]
|
Set -->|"join: align_user_game_set"| Row["user_game (status, rating, notes, priority)"]
|
||||||
Row -->|"update: user_game_set_share"| Row
|
Row -->|"update: user_game_set_share"| Row
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import java.util.Comparator;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One platform's play of a game entry: what the detail lists and the card totals. The SQL of
|
* One platform's play of a game entry: what the detail lists and the card totals. The view
|
||||||
* {@code GameLinkRepository#entryPlaytimes} follows the same rule; a test pins both.
|
* {@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 platform e.g. "PS5"; null when the edition has none
|
||||||
* @param appid the edition the line comes from
|
* @param appid the edition the line comes from
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
* @return per given entry key, the playtime of all its editions: the view of V45, same rule as {@code PlatformPlay#total}
|
||||||
* PlayStation titles on it, except that a PlayStation row with title play, or a manual row with a title of its platform,
|
|
||||||
* counts its titles only (a PlayStation row's own minutes are their sum). Capped at the int range. Same rule as {@code PlatformPlay#total}.
|
|
||||||
*/
|
*/
|
||||||
public Map<Long, Integer> entryPlaytimes(long userId, Collection<Long> appids) {
|
public Map<Long, Integer> entryPlaytimes(long userId, Collection<Long> appids) {
|
||||||
Map<Long, Integer> playtimes = new HashMap<>();
|
Map<Long, Integer> playtimes = new HashMap<>();
|
||||||
if (appids.isEmpty()) {
|
if (appids.isEmpty()) {
|
||||||
return playtimes;
|
return playtimes;
|
||||||
}
|
}
|
||||||
jdbc.query("""
|
jdbc.query("select appid, playtime_minutes from user_game_entry_playtime where user_id = :user "
|
||||||
select e.appid, least(sum(case when (m.source = 'PSN' and t.minutes is not null)
|
+ "and appid in (:appids)", new MapSqlParameterSource("user", userId).addValue("appids", appids),
|
||||||
or (m.source = 'MANUAL' and exists (
|
rs -> { playtimes.put(rs.getLong("appid"), rs.getInt("playtime_minutes")); });
|
||||||
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;
|
return playtimes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,29 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
|||||||
*/
|
*/
|
||||||
String LISTED = "ug.userId = :userId and ug.removedAt is null and ug.hiddenAt is null ";
|
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")
|
@Query("select ug from UserGame ug where " + LISTED + "and ug.source = core_service.modules.library.domain.GameSource.STEAM")
|
||||||
List<UserGame> findListedSteamGames(@Param("userId") Long userId);
|
List<UserGame> findListedSteamGames(@Param("userId") Long userId);
|
||||||
|
|
||||||
@@ -70,24 +93,29 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
|||||||
@Query("select count(c) from CountedUserGame c where c.userId = :userId")
|
@Query("select count(c) from CountedUserGame c where c.userId = :userId")
|
||||||
long countListed(@Param("userId") Long 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);
|
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)")
|
"and exists (select 1 from CountedUserGame c where c.userId = ug.userId and c.appid = ug.appid)")
|
||||||
List<UserGame> findListedByStatusIn(@Param("userId") Long userId, @Param("statuses") Collection<GameStatus> statuses,
|
List<UserGame> findListedByStatusIn(@Param("userId") Long userId, @Param("statuses") Collection<GameStatus> statuses,
|
||||||
Pageable pageable);
|
Pageable pageable);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The entries played in the last two weeks, the most played first: the counted edition with the playtime of every
|
* The entries played in the last two weeks, the most played in them first: the counted edition, the minutes of the
|
||||||
* edition of the set added up, see V43.
|
* 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 " +
|
"sum(m.playtime_2weeks) as playtime2Weeks " +
|
||||||
"from counted_user_game c " +
|
"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 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 " +
|
"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",
|
"having sum(m.playtime_2weeks) > 0 order by sum(m.playtime_2weeks) desc, c.appid limit :limit",
|
||||||
nativeQuery = true)
|
nativeQuery = true)
|
||||||
List<RecentEntry> findEntriesPlayedInTwoWeeks(@Param("userId") Long userId, @Param("limit") int limit);
|
List<RecentEntry> findEntriesPlayedInTwoWeeks(@Param("userId") Long userId, @Param("limit") int limit);
|
||||||
@@ -342,20 +370,11 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
|||||||
* @param sources the sources one platform of the entry must have (a PlayStation title on a Steam row counts as
|
* @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
|
* PSN); never empty, and ignored for the hidden games
|
||||||
*/
|
*/
|
||||||
@Query("select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " +
|
// ponytail: the view is joined on every page, not only for the playtime sort: about +4 ms per page. Upgrade path:
|
||||||
"where ug.userId = :userId and ug.removedAt is null " +
|
// join it only for the playtime sort or the PLAYING status, with a second query.
|
||||||
"and ((:hidden = true and ug.hiddenAt is not null) or (:hidden = false and ug.hiddenAt is null)) " +
|
@Query(value = "select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " +
|
||||||
"and (:status is null or ug.status = :status) " +
|
"left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid " + OWNED_WHERE,
|
||||||
"and (:namePattern is null or (:hidden = true and lower(sg.name) like :namePattern escape '\\') " +
|
countQuery = "select count(ug) from UserGame ug join SteamGame sg on sg.appid = ug.appid " + OWNED_WHERE)
|
||||||
" 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))")
|
|
||||||
Page<UserGame> findOwnedGames(@Param("userId") Long userId,
|
Page<UserGame> findOwnedGames(@Param("userId") Long userId,
|
||||||
@Param("hidden") boolean hidden,
|
@Param("hidden") boolean hidden,
|
||||||
@Param("status") GameStatus status,
|
@Param("status") GameStatus status,
|
||||||
@@ -386,13 +405,15 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
|||||||
/**
|
/**
|
||||||
* Makes a {@link #findOwnedGames} sort stable and meaningful: games without a rating or priority come last
|
* 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
|
* 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) {
|
static Sort querySort(Sort requested) {
|
||||||
Sort result = Sort.unsorted();
|
Sort result = Sort.unsorted();
|
||||||
for (Sort.Order order : requested) {
|
for (Sort.Order order : requested) {
|
||||||
result = result.and(switch (order.getProperty()) {
|
result = result.and(switch (order.getProperty()) {
|
||||||
case "rating" -> Sort.by(order.nullsLast());
|
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 "priority" -> Sort.by(JpaSort.unsafe(order.getDirection(),
|
||||||
"(case ug.priority when 'HIGH' then 3 when 'MEDIUM' then 2 when 'LOW' then 1 end)")
|
"(case ug.priority when 'HIGH' then 3 when 'MEDIUM' then 2 when 'LOW' then 1 end)")
|
||||||
.iterator().next().nullsLast());
|
.iterator().next().nullsLast());
|
||||||
|
|||||||
@@ -60,11 +60,9 @@ public class UserStatsRepository {
|
|||||||
from counted_user_game ug join game_time_to_beat t on t.appid = ug.appid
|
from counted_user_game ug join game_time_to_beat t on t.appid = ug.appid
|
||||||
where %s and ug.status = 'BACKLOG' and %s
|
where %s and ug.status = 'BACKLOG' and %s
|
||||||
""".formatted(OF_USER, UserGameRepository.NOT_COMING);
|
""".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 = """
|
private static final String ENTRY_PLAYTIME = """
|
||||||
(select e.appid, sum(m.playtime_minutes) as playtime_minutes from user_game_entry_member e
|
(select ep.appid, ep.playtime_minutes from user_game_entry_playtime ep where ep.user_id = :userId)""";
|
||||||
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)""";
|
|
||||||
private static final String MOST_PLAYED_FROM = """
|
private static final String MOST_PLAYED_FROM = """
|
||||||
from %s ug join steam_game sg on sg.appid = ug.appid
|
from %s ug join steam_game sg on sg.appid = ug.appid
|
||||||
where ug.playtime_minutes > 0
|
where ug.playtime_minutes > 0
|
||||||
@@ -300,12 +298,13 @@ public class UserStatsRepository {
|
|||||||
*/
|
*/
|
||||||
public List<UserStats.StatGame> games(long userId, StatKey key, Object arg, ZoneId zone, int offset, int limit) {
|
public List<UserStats.StatGame> games(long userId, StatKey key, Object arg, ZoneId zone, int offset, int limit) {
|
||||||
return jdbc.query("""
|
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,
|
s.minutes, s.event_at, s.from_status, s.to_status,
|
||||||
cg.appid is not null as coming, cg.release_date as coming_on
|
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
|
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 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 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
|
order by %s, s.appid limit :limit offset :offset
|
||||||
""".formatted(SETS.get(key), ORDERS.getOrDefault(key, BY_PLAYTIME)),
|
""".formatted(SETS.get(key), ORDERS.getOrDefault(key, BY_PLAYTIME)),
|
||||||
gameParams(userId, arg, zone).addValue("limit", limit).addValue("offset", offset),
|
gameParams(userId, arg, zone).addValue("limit", limit).addValue("offset", offset),
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import org.springframework.data.domain.PageImpl;
|
|||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.domain.JpaSort;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -405,7 +406,8 @@ public class LibraryQueryService {
|
|||||||
? userGameRepository.countEveryStatus(userId) : null;
|
? userGameRepository.countEveryStatus(userId) : null;
|
||||||
List<UserGame> playing = sections.contains(ProfileSection.PLAYING)
|
List<UserGame> playing = sections.contains(ProfileSection.PLAYING)
|
||||||
? userGameRepository.findListedByStatusIn(userId, List.of(GameStatus.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;
|
: null;
|
||||||
List<UserGame> finished = sections.contains(ProfileSection.FINISHED)
|
List<UserGame> finished = sections.contains(ProfileSection.FINISHED)
|
||||||
? userGameRepository.findListedByStatusIn(userId, List.of(GameStatus.FINISHED, GameStatus.COMPLETED),
|
? userGameRepository.findListedByStatusIn(userId, List.of(GameStatus.FINISHED, GameStatus.COMPLETED),
|
||||||
@@ -416,6 +418,7 @@ public class LibraryQueryService {
|
|||||||
Map<Long, SteamGame> meta = steamGameRepository.findAllById(shownAppids)
|
Map<Long, SteamGame> meta = steamGameRepository.findAllById(shownAppids)
|
||||||
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
|
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
|
||||||
Map<Long, ComingGame> coming = comingOf(shownAppids);
|
Map<Long, ComingGame> coming = comingOf(shownAppids);
|
||||||
|
Map<Long, Integer> totals = gameLinkRepository.entryPlaytimes(userId, shownAppids);
|
||||||
List<AchievementProgress> started = summary ? AchievementProgress.started(progress(userId)) : null;
|
List<AchievementProgress> started = summary ? AchievementProgress.started(progress(userId)) : null;
|
||||||
Set<StatGroup> shared = sections.contains(ProfileSection.STATS) ? StatGroup.shared(profileStats) : Set.of();
|
Set<StatGroup> shared = sections.contains(ProfileSection.STATS) ? StatGroup.shared(profileStats) : Set.of();
|
||||||
return new PublicLibraryDto(
|
return new PublicLibraryDto(
|
||||||
@@ -423,7 +426,7 @@ public class LibraryQueryService {
|
|||||||
summary ? userGameRepository.sumPlaytime(userId) : null,
|
summary ? userGameRepository.sumPlaytime(userId) : null,
|
||||||
summary ? byStatus.get(GameStatus.FINISHED) + byStatus.get(GameStatus.COMPLETED) : null,
|
summary ? byStatus.get(GameStatus.FINISHED) + byStatus.get(GameStatus.COMPLETED) : null,
|
||||||
sections.contains(ProfileSection.STATUS) ? byStatus : 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 ? entries(userId, AchievementProgress.perfectAppids(started)) : null,
|
||||||
summary ? averageCompletion(AchievementProgress.perGame(started)) : null,
|
summary ? averageCompletion(AchievementProgress.perGame(started)) : null,
|
||||||
shared.isEmpty() ? null : userStatsService.getStats(userId, shared));
|
shared.isEmpty() ? null : userStatsService.getStats(userId, shared));
|
||||||
@@ -433,15 +436,16 @@ public class LibraryQueryService {
|
|||||||
* @return null for a hidden section
|
* @return null for a hidden section
|
||||||
*/
|
*/
|
||||||
private static List<PublicLibraryDto.Game> toPublicGames(List<UserGame> games, Map<Long, SteamGame> meta,
|
private static List<PublicLibraryDto.Game> toPublicGames(List<UserGame> games, Map<Long, SteamGame> meta,
|
||||||
Map<Long, ComingGame> coming) {
|
Map<Long, ComingGame> coming,
|
||||||
|
Map<Long, Integer> totals) {
|
||||||
if (games == null) {
|
if (games == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return games.stream()
|
return games.stream()
|
||||||
.map(ug -> new PublicLibraryDto.Game(ug.getAppid(),
|
.map(ug -> new PublicLibraryDto.Game(ug.getAppid(),
|
||||||
meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getName() : null,
|
meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getName() : null,
|
||||||
ug.getPlaytimeMinutes(), ug.getStatus(), ug.getRating(), ug.getPlatform(),
|
totals.getOrDefault(ug.getAppid(), ug.getPlaytimeMinutes()), ug.getStatus(), ug.getRating(),
|
||||||
meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getImageUrl() : null,
|
ug.getPlatform(), meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getImageUrl() : null,
|
||||||
coming.containsKey(ug.getAppid()),
|
coming.containsKey(ug.getAppid()),
|
||||||
coming.containsKey(ug.getAppid()) ? coming.get(ug.getAppid()).getReleaseDate() : null))
|
coming.containsKey(ug.getAppid()) ? coming.get(ug.getAppid()).getReleaseDate() : null))
|
||||||
.toList();
|
.toList();
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -3,6 +3,7 @@ package core_service.modules.library.api;
|
|||||||
import core_service.modules.library.domain.GameAchievementSchemaMeta;
|
import core_service.modules.library.domain.GameAchievementSchemaMeta;
|
||||||
import core_service.modules.library.domain.GamePriority;
|
import core_service.modules.library.domain.GamePriority;
|
||||||
import core_service.modules.library.domain.PsnTrophySet;
|
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.GameStatus;
|
||||||
import core_service.modules.library.domain.SteamGame;
|
import core_service.modules.library.domain.SteamGame;
|
||||||
import core_service.modules.library.domain.UserGame;
|
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.SteamGameRepository;
|
||||||
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
|
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
|
||||||
import core_service.modules.library.repo.UserGameRepository;
|
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.AbstractIntegrationTest;
|
||||||
import core_service.shared.test.ContractTokens;
|
import core_service.shared.test.ContractTokens;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
@@ -28,10 +30,12 @@ import java.time.Instant;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
import static org.hamcrest.Matchers.contains;
|
import static org.hamcrest.Matchers.contains;
|
||||||
import static org.hamcrest.Matchers.hasSize;
|
import static org.hamcrest.Matchers.hasSize;
|
||||||
import static org.hamcrest.Matchers.nullValue;
|
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.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
@@ -86,6 +90,9 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private JdbcTemplate jdbc;
|
private JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserStatsService userStatsService;
|
||||||
|
|
||||||
private void platinum(long appid) {
|
private void platinum(long appid) {
|
||||||
psnTrophyRepository.saveSet(USER_ID, appid, new PsnTrophySet("NPWR1_00", "trophy2", "01.00", "Set", null, "PS5",
|
psnTrophyRepository.saveSet(USER_ID, appid, new PsnTrophySet("NPWR1_00", "trophy2", "01.00", "Set", null, "PS5",
|
||||||
50, 0, 0, 0, 30, true, Instant.now()));
|
50, 0, 0, 0, 30, true, Instant.now()));
|
||||||
@@ -211,6 +218,25 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
|
|||||||
.andExpect(jsonPath("$.stats").value(nullValue()));
|
.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
|
@Test
|
||||||
void publicLibrary_OnlyTheSharedStatGroups() throws Exception {
|
void publicLibrary_OnlyTheSharedStatGroups() throws Exception {
|
||||||
mockMvc.perform(get("/api/public/profiles/library")
|
mockMvc.perform(get("/api/public/profiles/library")
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import jakarta.persistence.EntityManager;
|
|||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
@@ -31,10 +32,13 @@ import java.util.EnumSet;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
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.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
@Import(GameLinkRepository.class)
|
||||||
class UserGameRepositoryTest extends AbstractPersistenceTest {
|
class UserGameRepositoryTest extends AbstractPersistenceTest {
|
||||||
|
|
||||||
private static final long USER_ID = 1L;
|
private static final long USER_ID = 1L;
|
||||||
@@ -45,6 +49,9 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private UserGameRepository userGameRepository;
|
private UserGameRepository userGameRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private GameLinkRepository gameLinkRepository;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private JdbcTemplate jdbc;
|
private JdbcTemplate jdbc;
|
||||||
|
|
||||||
@@ -218,6 +225,28 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
|||||||
assertEquals(2, page.getTotalElements());
|
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<Long> sortedAppids(Sort sort) {
|
private List<Long> sortedAppids(Sort sort) {
|
||||||
return userGameRepository.findOwnedGames(USER_ID, false, null, null, null, ALL_SOURCES, PageRequest.of(0, 20, UserGameRepository.querySort(sort)))
|
return userGameRepository.findOwnedGames(USER_ID, false, null, null, null, ALL_SOURCES, PageRequest.of(0, 20, UserGameRepository.querySort(sort)))
|
||||||
.map(UserGame::getAppid).getContent();
|
.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")));
|
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
|
@Test
|
||||||
void querySort_EqualPlaytime_BreaksTiesByAppid() {
|
void querySort_EqualPlaytime_BreaksTiesByAppid() {
|
||||||
assertEquals(List.of(620L, 220L, 380L, 700L), sortedAppids(Sort.by(Sort.Direction.DESC, "playtimeMinutes")));
|
assertEquals(List.of(620L, 220L, 380L, 700L), sortedAppids(Sort.by(Sort.Direction.DESC, "playtimeMinutes")));
|
||||||
|
|||||||
@@ -179,6 +179,35 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
|
|||||||
.containsExactly(620L, 220L);
|
.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
|
@Test
|
||||||
void mostLoyal_MostMonthsWins_MoreMinutesBreakATie() {
|
void mostLoyal_MostMonthsWins_MoreMinutesBreakATie() {
|
||||||
userGameRepository.saveAllAndFlush(List.of(
|
userGameRepository.saveAllAndFlush(List.of(
|
||||||
|
|||||||
Reference in new issue
Block a user