feat(library): show one entry per game in every list

Gitea #34 slice 2 of 3. Lists and stats count a set once.
V43 views map an entry to its editions and platforms.
Remove the hideDuplicates param; GameDto gets platforms[].
This commit is contained in:
lobo committed 2026-10-09 11:31:11 +02:00
1 parent 82d3b59165
commit 413381d404
23 files changed
+676 -87

No files matched your search

+24 -9
View File
@@ -116,8 +116,10 @@ flowchart LR
status history); games and playtime per platform (`PLATFORMS`: Steam games as "Steam", manual games by platform, trimmed and ignoring case, the most used spelling is the label); the Pile of Shame trend (90 days of `backlog_snapshot`, plus "this month: added / done", where the status history); games and playtime per platform (`PLATFORMS`: Steam games as "Steam", manual games by platform, trimmed and ignoring case, the most used spelling is the label); the Pile of Shame trend (90 days of `backlog_snapshot`, plus "this month: added / done", where the
first sync of a Steam account is no "added", see `user_steam_sync.history_since`). Every number counts only listed games (still owned, not hidden: the view first sync of a Steam account is no "added", see `user_steam_sync.history_since`). Every number counts only listed games (still owned, not hidden: the view
`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`. Playtime 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,
sums, most played, genres, moods and platforms still use every listed game. 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.
- **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
@@ -164,7 +166,7 @@ flowchart LR
sets** of the matched games: they count as achievements (platinum = perfect game) in the detail page, stats, tiles, sets** of the matched games: they count as achievements (platinum = perfect game) in the detail page, stats, tiles,
public profile, sync banner and follower digest. See "PlayStation link". Off without `APP_PSN_TOKEN_KEY`. public profile, sync banner and follower digest. See "PlayStation link". Off without `APP_PSN_TOKEN_KEY`.
- **Public profile data**: totals, status counts, games in progress and finished games with rating, perfect games - **Public profile data**: totals, status counts, games in progress and finished games with rating, perfect games
and average completion (the numbers count a linked set once; the lists keep every edition). Readable only and average completion (the numbers and the lists count a linked set once, as its counted edition). Readable only
with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID. `stats` holds only the stat with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID. `stats` holds only the stat
groups the user shares (the ticket's `profileStats` claim, derived in Auth from the profile tiles); none by groups the user shares (the ticket's `profileStats` claim, derived in Auth from the profile tiles); none by
default. A section the user default. A section the user
@@ -216,7 +218,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `POST` | `/api/rewards/purchases` (`{item}`; `200` wallet, `409 ALREADY_OWNED` / `NOT_ENOUGH_POINTS` / `NOT_FOR_SALE`) | JWT | | `POST` | `/api/rewards/purchases` (`{item}`; `200` wallet, `409 ALREADY_OWNED` / `NOT_ENOUGH_POINTS` / `NOT_FOR_SALE`) | JWT |
| `GET` | `/api/rewards/progress/{track}/games?page=`, `/api/rewards/history/{kind}/{ref}/games?page=` (the games behind a track or a `MONTH` / `STREAK` bonus; `400 NOT_A_GAME_LIST`) | JWT | | `GET` | `/api/rewards/progress/{track}/games?page=`, `/api/rewards/history/{kind}/{ref}/games?page=` (the games behind a track or a `MONTH` / `STREAK` bonus; `400 NOT_A_GAME_LIST`) | JWT |
| `PUT` | `/api/rewards/equipped/{slot}` (`{item}`, null = take off; `200` wallet, `409 NOT_OWNED`, `400 WRONG_SLOT`) | JWT | | `PUT` | `/api/rewards/equipped/{slot}` (`{item}`, null = take off; `200` wallet, `409 NOT_OWNED`, `400 WRONG_SLOT`) | JWT |
| `GET` | `/api/library/games` (`hideDuplicates=true` leaves out the lesser editions of a linked set; `source=STEAM,PSN` keeps rows whose own source or a listed linked edition's source is selected; ignored with `hidden=true`; `400` on an unknown source), `/api/library/games/{appid}` | JWT | | `GET` | `/api/library/games` (one row per set, the counted edition, with `platforms[]` of all its editions: Steam, PlayStation, then manual; `name` matches any edition; `source=STEAM,PSN` keeps rows with a platform of that source, a PlayStation title on a Steam row counts as `PSN`; ignored with `hidden=true`, which lists every hidden row; `400` on an unknown source; an old `hideDuplicates` param is ignored), `/api/library/games/{appid}` | JWT |
| `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT | | `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT |
| `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`, `time_to_beat` = `{normallyMinutes, completelyMinutes}`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR | | `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`, `time_to_beat` = `{normallyMinutes, completelyMinutes}`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR |
| `GET` | `/api/library/catalog?q=&page=&size=` (every game some user still has, by name part, `PageDto` of `{appid, name, imageUrl, platforms}`; `q` at most 100 characters; size 20, 40 or 80) | JWT, AUDITOR | | `GET` | `/api/library/catalog?q=&page=&size=` (every game some user still has, by name part, `PageDto` of `{appid, name, imageUrl, platforms}`; `q` at most 100 characters; size 20, 40 or 80) | JWT, AUDITOR |
@@ -339,7 +341,7 @@ flowchart LR
`user_game` and `user_game_status_change` in SQL; the sync tells `follow` through `shared.event.LibraryActivityEvent`. `user_game` and `user_game_status_change` in SQL; the sync tells `follow` through `shared.event.LibraryActivityEvent`.
**One shared schema.** All modules use one database `tbp_core`, and some read or write another module's tables in **One shared schema.** All modules use one database `tbp_core`, and some read or write another module's tables in
SQL, which ArchUnit cannot see: `stats` reads `listed_user`, `account_name`, `game_alias`, `listed_user_game`, `counted_user_game`, `steam_game`, `game_mood`, `coming_game`, `user_game_status_change`, `deleted_user` and `blocked_user`; `account` SQL, which ArchUnit cannot see: `stats` reads `listed_user`, `account_name`, `game_alias`, `listed_user_game`, `counted_user_game`, `user_game_entry_member`, `steam_game`, `game_mood`, `coming_game`, `user_game_status_change`, `deleted_user` and `blocked_user`; `account`
deletes the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes deletes the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes
into its list) and writes `user_seen_at` and `last_synced_at` of `user_steam_sync` (`AccountStateRepository`); `library` into its list) and writes `user_seen_at` and `last_synced_at` of `user_steam_sync` (`AccountStateRepository`); `library`
reads `deleted_user` and `blocked_user`, and for the picker `follow`, `public_user` and `account_name`; `rewards` reads reads `deleted_user` and `blocked_user`, and for the picker `follow`, `public_user` and `account_name`; `rewards` reads
@@ -347,7 +349,10 @@ 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 this view first, then `listed_user_game`. 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`.
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.
Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain` (JPA entities) and `repo` Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain` (JPA entities) and `repo`
(repositories). `src/test/java/core_service/architecture/ArchitectureTest.java` checks these rules on every build: (repositories). `src/test/java/core_service/architecture/ArchitectureTest.java` checks these rules on every build:
@@ -550,11 +555,17 @@ IGDB id; remakes (8) keep their own id. `null` when IGDB does not know the game.
- `game_link_pair` is the auditor queue (`OPEN`, `DISMISSED`, `LINKED`). IGDB pairs are inserted when the queue is read. A row in any state keeps the pair out for good. An unlink dismisses the pairs of that key with the former - `game_link_pair` is the auditor queue (`OPEN`, `DISMISSED`, `LINKED`). IGDB pairs are inserted when the queue is read. A row in any state keeps the pair out for good. An unlink dismisses the pairs of that key with the former
members. `game_link_request` holds who asked for a pair (the queue shows only the count). A user asks for a pair of members. `game_link_request` holds who asked for a pair (the queue shows only the count). A user asks for a pair of
two games of their own library; at most 20 open requests per user (a per-user advisory lock makes the cap hold for parallel calls), a dismissed pair answers `409 PAIR_DISMISSED`. two games of their own library; at most 20 open requests per user (a per-user advisory lock makes the cap hold for parallel calls), a dismissed pair answers `409 PAIR_DISMISSED`.
- Pile, stats, snapshots and the dashboard count a set once (`counted_user_game`, see "Stats"); the library - Every list of the user's own games shows a set once, as its counted edition (`counted_user_game`, see "Stats"): the
filter `hideDuplicates=true` hides the lesser editions. Pile Points pay a set once (see "Pile Points"), and the 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. Card playtime is the counted edition's own; the set total comes with the
detail page. Pile Points pay a set once (see "Pile Points"), and the
`MONTH` bonus reads `backlog_snapshot`, which counts a set once. `MONTH` bonus reads `backlog_snapshot`, which counts a set once.
- 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). (size of the set in the user's library, `null` below 2) and `platforms` (`source` + `platform` per edition, for
the icons of the card).
```mermaid ```mermaid
flowchart LR flowchart LR
@@ -563,6 +574,10 @@ flowchart LR
Link --> Set["user_game_set"] Link --> Set["user_game_set"]
Split["user_game_split (user splits)"] -->|"minus"| Set Split["user_game_split (user splits)"] -->|"minus"| Set
Set --> Counted["counted_user_game"] Set --> Counted["counted_user_game"]
Counted --> Member["user_game_entry_member"]
Member --> Platform["user_game_entry_platform"]
Platform -->|"platforms[]"| Card["library card, lists"]
Member -->|"summed playtime"| Card
Set -->|"join: align_user_game_set"| Row["user_game (status, rating, notes, priority)"] Set -->|"join: align_user_game_set"| Row["user_game (status, rating, notes, priority)"]
Row -->|"update: user_game_set_share"| Row Row -->|"update: user_game_set_share"| Row
Row -.->|"old values"| Backup["user_game_set_backup"] Row -.->|"old values"| Backup["user_game_set_backup"]
@@ -136,7 +136,6 @@ public class LibraryController {
@RequestParam(required = false) String q, @RequestParam(required = false) String q,
@RequestParam(required = false) Integer maxMinutes, @RequestParam(required = false) Integer maxMinutes,
@RequestParam(defaultValue = "false") boolean hidden, @RequestParam(defaultValue = "false") boolean hidden,
@RequestParam(defaultValue = "false") boolean hideDuplicates,
@RequestParam(required = false) List<GameSource> source @RequestParam(required = false) List<GameSource> source
) { ) {
Long userId = principal.getIdAsLong(); Long userId = principal.getIdAsLong();
@@ -145,7 +144,7 @@ public class LibraryController {
int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE; int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE;
int safePage = Math.max(0, page); int safePage = Math.max(0, page);
Sort safeSort = parseSort(sort); Sort safeSort = parseSort(sort);
var dtoPage = queryService.listOwnedGames(userId, hidden, status, q, maxMinutes != null && maxMinutes > 0 ? maxMinutes : null, hideDuplicates, hidden ? null : source, PageRequest.of(safePage, safeSize, safeSort)); var dtoPage = queryService.listOwnedGames(userId, hidden, status, q, maxMinutes != null && maxMinutes > 0 ? maxMinutes : null, hidden ? null : source, PageRequest.of(safePage, safeSize, safeSort));
return ResponseEntity.ok(PageDto.of(dtoPage)); return ResponseEntity.ok(PageDto.of(dtoPage));
} }
@@ -5,6 +5,7 @@ import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List;
/** /**
* @param timeToBeatMinutes IGDB's normal time to beat, null when unknown * @param timeToBeatMinutes IGDB's normal time to beat, null when unknown
@@ -17,6 +18,7 @@ import java.time.LocalDate;
* @param editions how many games of the user are in this game's linked set, null below 2 * @param editions how many games of the user are in this game's linked set, null below 2
* @param coming the game is not released yet and nobody played it; it does not count on the pile * @param coming the game is not released yet and nobody played it; it does not count on the pile
* @param comingOn the release date of a coming game; null for released games and for "coming soon" * @param comingOn the release date of a coming game; null for released games and for "coming soon"
* @param platforms the platforms of every edition of the entry, Steam first; empty for a hidden game
*/ */
public record GameDto( public record GameDto(
Long appid, Long appid,
@@ -36,5 +38,6 @@ public record GameDto(
boolean hidden, boolean hidden,
Integer editions, Integer editions,
boolean coming, boolean coming,
LocalDate comingOn LocalDate comingOn,
List<PlatformDto> platforms
) {} ) {}
@@ -0,0 +1,8 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.GameSource;
/**
* @param platform e.g. "PS5"; null when the edition has none
*/
public record PlatformDto(GameSource source, String platform) {}
@@ -0,0 +1,8 @@
package core_service.modules.library.domain;
/**
* A platform a library entry is on, e.g. {@code PSN} + "PS5".
*
* @param platform null when the edition has none, e.g. a Steam game
*/
public record EntryPlatform(GameSource source, String platform) {}
@@ -0,0 +1,15 @@
package core_service.modules.library.domain;
/**
* A library entry played in the last two weeks; the playtimes add up every edition of the entry.
*/
public interface RecentEntry {
Long getAppid();
String getStatus();
Long getPlaytimeMinutes();
Long getPlaytime2Weeks();
}
@@ -0,0 +1,51 @@
package core_service.modules.library.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
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;
import java.io.Serializable;
/**
* A platform of a library entry, per edition of the entry: read-only view, see V43. {@code platform} is empty when the
* edition has none.
*/
@Entity
@Immutable
@Table(name = "user_game_entry_platform")
@IdClass(UserGameEntryPlatform.Key.class)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserGameEntryPlatform {
public record Key(Long userId, Long appid, Long memberAppid, GameSource source, String platform) implements Serializable {}
@Id
@Column(name = "user_id")
private Long userId;
@Id
@Column(name = "appid")
private Long appid;
@Id
@Column(name = "member_appid")
private Long memberAppid;
@Id
@Enumerated(EnumType.STRING)
@Column(name = "source")
private GameSource source;
@Id
@Column(name = "platform")
private String platform;
}
@@ -12,7 +12,8 @@ import java.util.List;
/** /**
* What the users the caller follows played of the caller's backlog. Plain SQL: it reads the follow module's tables * What the users the caller follows played of the caller's backlog. Plain SQL: it reads the follow module's tables
* so the privacy rules and the data come from one statement. * so the privacy rules and the data come from one statement. A friend's game matches the caller's entry by key or
* through the global game_link set, so another edition of the same game counts.
*/ */
@Repository @Repository
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -34,8 +35,11 @@ public class FriendPlayRepository {
join public_user p on p.user_id = f.followed_user_id join public_user p on p.user_id = f.followed_user_id
join account_name a on a.user_id = f.followed_user_id join account_name a on a.user_id = f.followed_user_id
join counted_user_game fg on fg.user_id = f.followed_user_id join counted_user_game fg on fg.user_id = f.followed_user_id
join counted_user_game ug on ug.user_id = f.user_id and ug.appid = fg.appid left join game_link lf on lf.appid = fg.appid
join counted_user_game ug on ug.user_id = f.user_id
left join game_link lu on lu.appid = ug.appid
where f.user_id = :userId where f.user_id = :userId
and coalesce(lf.set_id::text, 'a' || fg.appid) = coalesce(lu.set_id::text, 'a' || ug.appid)
and fg.status <> 'DROPPED' and fg.status <> 'DROPPED'
and not exists (select 1 from blocked_user b where b.user_id = f.followed_user_id) and not exists (select 1 from blocked_user b where b.user_id = f.followed_user_id)
and not exists (select 1 from deleted_user d where d.user_id = f.followed_user_id) and not exists (select 1 from deleted_user d where d.user_id = f.followed_user_id)
@@ -2,6 +2,7 @@ package core_service.modules.library.repo;
import core_service.modules.library.domain.CatalogGame; import core_service.modules.library.domain.CatalogGame;
import core_service.modules.library.domain.Edition; import core_service.modules.library.domain.Edition;
import core_service.modules.library.domain.EntryPlatform;
import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.LinkPair; import core_service.modules.library.domain.LinkPair;
@@ -11,6 +12,7 @@ import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
@@ -305,6 +307,34 @@ public class GameLinkRepository {
return counts; return counts;
} }
/**
* @return per given entry key, the platforms of its editions (see V43): Steam, PlayStation, then manual, by
* platform name; a PlayStation platform without a name is left out when the entry has a named PlayStation one, or its own member row has a manual platform
*/
public Map<Long, List<EntryPlatform>> entryPlatforms(long userId, Collection<Long> appids) {
Map<Long, List<EntryPlatform>> platforms = new HashMap<>();
if (appids.isEmpty()) {
return platforms;
}
jdbc.query("""
select appid, source, platform
from user_game_entry_platform p
where user_id = :user and appid in (:appids)
and not (source = 'PSN' and platform = '' and exists (
select 1 from user_game_entry_platform n
where n.user_id = p.user_id and n.appid = p.appid and n.platform <> ''
and (n.source = 'PSN' or (n.source = 'MANUAL' and n.member_appid = p.member_appid))))
group by appid, source, platform
order by appid, case source when 'STEAM' then 0 when 'PSN' then 1 else 2 end, platform
""", new MapSqlParameterSource("user", userId).addValue("appids", appids),
rs -> {
String platform = rs.getString("platform");
platforms.computeIfAbsent(rs.getLong("appid"), k -> new ArrayList<>()).add(new EntryPlatform(
GameSource.valueOf(rs.getString("source")), platform.isEmpty() ? null : platform));
});
return platforms;
}
private static MapSqlParameterSource keys(long appid, long otherAppid) { private static MapSqlParameterSource keys(long appid, long otherAppid) {
return new MapSqlParameterSource("a", appid).addValue("b", otherAppid); return new MapSqlParameterSource("a", appid).addValue("b", otherAppid);
} }
@@ -5,6 +5,7 @@ import core_service.modules.library.domain.FinishedWithoutDate;
import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.Mood; import core_service.modules.library.domain.Mood;
import core_service.modules.library.domain.RecentEntry;
import core_service.modules.library.domain.StatusCount; import core_service.modules.library.domain.StatusCount;
import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId; import core_service.modules.library.domain.UserGameId;
@@ -63,18 +64,33 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
@Query("select ug.appid from UserGame ug where ug.userId = :userId and ug.removedAt is null and ug.hiddenAt is not null") @Query("select ug.appid from UserGame ug where ug.userId = :userId and ug.removedAt is null and ug.hiddenAt is not null")
List<Long> findHiddenAppids(@Param("userId") Long userId); List<Long> findHiddenAppids(@Param("userId") Long userId);
@Query("select count(ug) from UserGame ug where " + LISTED) /**
* The entries of the user's lists: a set counts once, see V43.
*/
@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) @Query("select coalesce(sum(ug.playtimeMinutes), 0) from UserGame ug where " + LISTED)
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 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<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);
@Query("select ug from UserGame ug where " + LISTED + "and ug.playtime2Weeks > 0 order by ug.playtime2Weeks desc") /**
List<UserGame> findListedPlayedInTwoWeeks(@Param("userId") Long userId, 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.
*/
@Query(value = "select c.appid as appid, c.status as status, sum(m.playtime_minutes) as playtimeMinutes, " +
"sum(m.playtime_2weeks) as playtime2Weeks " +
"from counted_user_game c " +
"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 " +
"having sum(m.playtime_2weeks) > 0 order by sum(m.playtime_2weeks) desc, c.appid limit :limit",
nativeQuery = true)
List<RecentEntry> findEntriesPlayedInTwoWeeks(@Param("userId") Long userId, @Param("limit") int limit);
@Query("select count(c) from CountedUserGame c where c.userId = :userId and c.playtimeMinutes = 0") @Query("select count(c) from CountedUserGame c where c.userId = :userId and c.playtimeMinutes = 0")
long countListedNeverPlayed(@Param("userId") Long userId); long countListedNeverPlayed(@Param("userId") Long userId);
@@ -316,32 +332,35 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
} }
/** /**
* One row per entry (see V43): a listed game is the counted edition of its set; a hidden game is a row of its own.
*
* @param hidden true for only the hidden games, false for only the listed ones * @param hidden true for only the hidden games, false for only the listed ones
* @param status null for every status * @param status null for every status
* @param namePattern a {@link #containsPattern} result, null for every name * @param namePattern a {@link #containsPattern} result, null for every name; a listed entry matches by the name of
* any of its editions
* @param maxMinutes only games whose normal time to beat is known and at most this; null for every game * @param maxMinutes only games whose normal time to beat is known and at most this; null for every game
* @param hideDuplicates only the games a linked set counts (see V28), not its lesser editions * @param sources the sources one platform of the entry must have (a PlayStation title on a Steam row counts as
* @param sources the sources a row or one of its listed editions in the user's set (see V42) must have; never empty * PSN); never empty, and ignored for the hidden games
*/ */
@Query("select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " + @Query("select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " +
"where ug.userId = :userId and ug.removedAt is null " + "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 ((: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 (:status is null or ug.status = :status) " +
"and (:namePattern is null or lower(sg.name) like :namePattern escape '\\') " + "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 " + "and (:maxMinutes is null or exists (select 1 from GameTimeToBeat t " +
" where t.appid = ug.appid and t.normallyMinutes <= :maxMinutes)) " + " where t.appid = ug.appid and t.normallyMinutes <= :maxMinutes)) " +
"and (:hideDuplicates = false or exists (select 1 from CountedUserGame c " + "and (:hidden = true or exists (select 1 from CountedUserGame c " +
" where c.userId = ug.userId and c.appid = ug.appid)) " + " where c.userId = ug.userId and c.appid = ug.appid)) " +
"and (ug.source in :sources or exists (select 1 from UserGameSetMember l " + "and (:hidden = true or exists (select 1 from UserGameEntryPlatform p " +
" join UserGameSetMember o on o.userId = l.userId and o.setId = l.setId and o.appid <> l.appid " + " where p.userId = ug.userId and p.appid = ug.appid and p.source in :sources))")
" join UserGame og on og.userId = ug.userId and og.appid = o.appid " +
" where l.userId = ug.userId and l.appid = ug.appid and og.removedAt is null and og.hiddenAt is null and og.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,
@Param("namePattern") String namePattern, @Param("namePattern") String namePattern,
@Param("maxMinutes") Integer maxMinutes, @Param("maxMinutes") Integer maxMinutes,
@Param("hideDuplicates") boolean hideDuplicates,
@Param("sources") Collection<GameSource> sources, @Param("sources") Collection<GameSource> sources,
Pageable pageable); Pageable pageable);
@@ -39,11 +39,16 @@ public class UserStatsRepository {
where sc.user_id = :userId and %s where sc.user_id = :userId and %s
group by sc.appid group by sc.appid
""".formatted(FINISH_CHANGE); """.formatted(FINISH_CHANGE);
private static final String PLAYTIME_DAYS_NOT_HIDDEN = """ private static final String DAY_NOT_HIDDEN = """
from user_game_playtime_day p
where p.user_id = :userId and not exists (select 1 from user_game ug where ug.user_id = p.user_id where p.user_id = :userId and not exists (select 1 from user_game ug where ug.user_id = p.user_id
and ug.appid = p.appid and ug.hidden_at is not null) and ug.appid = p.appid and ug.hidden_at is not null)
"""; """;
private static final String PLAYTIME_DAYS_NOT_HIDDEN = "from user_game_playtime_day p\n" + DAY_NOT_HIDDEN;
/** The playtime days like above, with the entry (see V43) of each day; a removed game is its own entry. */
private static final String ENTRY_DAYS = """
from user_game_playtime_day p
left join user_game_entry_member e on e.user_id = p.user_id and e.member_appid = p.appid
""" + DAY_NOT_HIDDEN;
/** The columns of a game set a set has no value for; the list query reads the same columns from every set. */ /** The columns of a game set a set has no value for; the list query reads the same columns from every set. */
private static final String NO_MINUTES = "cast(null as bigint) as minutes"; private static final String NO_MINUTES = "cast(null as bigint) as minutes";
@@ -55,10 +60,15 @@ 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. */
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)""";
private static final String MOST_PLAYED_FROM = """ private static final String MOST_PLAYED_FROM = """
from listed_user_game ug join steam_game sg on sg.appid = ug.appid from %s ug join steam_game sg on sg.appid = ug.appid
where %s and ug.playtime_minutes > 0 where ug.playtime_minutes > 0
""".formatted(OF_USER); """.formatted(ENTRY_PLAYTIME);
private static final String PLATFORM_FROM = """ private static final String PLATFORM_FROM = """
from (select ug.appid, case when ug.source = 'STEAM' then 'Steam' else trim(ug.platform) end as name, from (select ug.appid, case when ug.source = 'STEAM' then 'Steam' else trim(ug.platform) end as name,
ug.playtime_minutes ug.playtime_minutes
@@ -95,8 +105,8 @@ public class UserStatsRepository {
private static final String PILE_TTB_SET = "select ug.appid, t.normally_minutes as minutes, " + NO_EVENT + ", " private static final String PILE_TTB_SET = "select ug.appid, t.normally_minutes as minutes, " + NO_EVENT + ", "
+ NO_MOVE + " " + PILE_TTB_FROM + "and t.normally_minutes is not null"; + NO_MOVE + " " + PILE_TTB_FROM + "and t.normally_minutes is not null";
private static final String LONGEST_DAY_SET = "select p.appid, sum(p.minutes) as minutes, " + NO_EVENT + ", " private static final String LONGEST_DAY_SET = "select coalesce(e.appid, p.appid) as appid, sum(p.minutes) as minutes, "
+ NO_MOVE + " " + PLAYTIME_DAYS_NOT_HIDDEN + "and p.day = :arg group by p.appid"; + NO_EVENT + ", " + NO_MOVE + " " + ENTRY_DAYS + "and p.day = :arg group by coalesce(e.appid, p.appid)";
private static final String FINISHED_YEAR_SET = "select ug.appid, " + NO_MINUTES + ", " + FINISHED_AT private static final String FINISHED_YEAR_SET = "select ug.appid, " + NO_MINUTES + ", " + FINISHED_AT
+ " as event_at, " + NO_MOVE + " " + FINISHED_FROM + "and " + FINISHED_YEAR + " = :arg"; + " as event_at, " + NO_MOVE + " " + FINISHED_FROM + "and " + FINISHED_YEAR + " = :arg";
private static final String MOVES_SET = "select sc.appid, " + NO_MINUTES + ", sc.changed_at as event_at, " private static final String MOVES_SET = "select sc.appid, " + NO_MINUTES + ", sc.changed_at as event_at, "
@@ -113,13 +123,14 @@ public class UserStatsRepository {
Map.entry(StatKey.UNDER_ONE_HOUR, counted(UNDER_ONE_HOUR)), Map.entry(StatKey.UNDER_ONE_HOUR, counted(UNDER_ONE_HOUR)),
Map.entry(StatKey.FINISHED_UNTRACKED, counted(UNTRACKED)), Map.entry(StatKey.FINISHED_UNTRACKED, counted(UNTRACKED)),
Map.entry(StatKey.PILE_TIME_TO_BEAT, PILE_TTB_SET), Map.entry(StatKey.PILE_TIME_TO_BEAT, PILE_TTB_SET),
Map.entry(StatKey.MOST_PLAYED, "select ug.appid, " + NO_STAT + " " + MOST_PLAYED_FROM), Map.entry(StatKey.MOST_PLAYED, "select ug.appid, ug.playtime_minutes as minutes, " + NO_EVENT + ", " + NO_MOVE
+ " " + MOST_PLAYED_FROM),
Map.entry(StatKey.GENRE, tagSet("genres")), Map.entry(StatKey.GENRE, tagSet("genres")),
Map.entry(StatKey.MOOD, tagSet("moods")), Map.entry(StatKey.MOOD, tagSet("moods")),
Map.entry(StatKey.PLATFORM, "select g.appid, " + NO_STAT + " " + PLATFORM_FROM Map.entry(StatKey.PLATFORM, "select g.appid, " + NO_STAT + " " + PLATFORM_FROM
+ "and lower(g.name) = lower(trim(:arg))"), + "and lower(g.name) = lower(trim(:arg))"),
Map.entry(StatKey.PERFECT, "select ug.appid, " + NO_STAT + " from listed_user_game ug where " + OF_USER Map.entry(StatKey.PERFECT, "select distinct e.appid, " + NO_STAT + " from user_game_entry_member e "
+ "and ug.appid in (:arg)"), + "where e.user_id = :userId and e.member_appid in (:arg)"),
Map.entry(StatKey.LONGEST_DAY, LONGEST_DAY_SET), Map.entry(StatKey.LONGEST_DAY, LONGEST_DAY_SET),
Map.entry(StatKey.FINISHED_YEAR, FINISHED_YEAR_SET), Map.entry(StatKey.FINISHED_YEAR, FINISHED_YEAR_SET),
Map.entry(StatKey.TREND_ADDED, movedSet(ADDED)), Map.entry(StatKey.TREND_ADDED, movedSet(ADDED)),
@@ -127,11 +138,13 @@ public class UserStatsRepository {
Map.entry(StatKey.TREND_MOVES, MOVES_SET)); Map.entry(StatKey.TREND_MOVES, MOVES_SET));
/** The list order; a key not here goes by playtime. */ /** The list order; a key not here goes by playtime. */
private static final Map<StatKey, String> ORDERS = Map.of( private static final Map<StatKey, String> ORDERS = Map.ofEntries(
StatKey.NEVER_STARTED, BY_NAME, StatKey.PILE, BY_NAME, StatKey.FINISHED_UNTRACKED, BY_NAME, Map.entry(StatKey.NEVER_STARTED, BY_NAME), Map.entry(StatKey.PILE, BY_NAME),
StatKey.PILE_TIME_TO_BEAT, BY_MINUTES, StatKey.LONGEST_DAY, BY_MINUTES, Map.entry(StatKey.FINISHED_UNTRACKED, BY_NAME), Map.entry(StatKey.PILE_TIME_TO_BEAT, BY_MINUTES),
StatKey.FINISHED_YEAR, BY_DATE, StatKey.TREND_ADDED, BY_DATE, StatKey.TREND_DONE, BY_DATE, Map.entry(StatKey.LONGEST_DAY, BY_MINUTES), Map.entry(StatKey.MOST_PLAYED, BY_MINUTES),
StatKey.TREND_MOVES, BY_DATE); Map.entry(StatKey.GENRE, BY_MINUTES), Map.entry(StatKey.MOOD, BY_MINUTES),
Map.entry(StatKey.FINISHED_YEAR, BY_DATE), Map.entry(StatKey.TREND_ADDED, BY_DATE),
Map.entry(StatKey.TREND_DONE, BY_DATE), Map.entry(StatKey.TREND_MOVES, BY_DATE));
private static final RowMapper<UserStats.TagCount> TAG_COUNT = private static final RowMapper<UserStats.TagCount> TAG_COUNT =
(rs, i) -> new UserStats.TagCount(rs.getString("name"), rs.getLong("games"), rs.getLong("minutes")); (rs, i) -> new UserStats.TagCount(rs.getString("name"), rs.getLong("games"), rs.getLong("minutes"));
@@ -175,17 +188,18 @@ public class UserStatsRepository {
} }
/** /**
* The listed game with playtime in the most months; more minutes break a tie. * The listed entry with playtime in the most months, all editions of its set together; more minutes break a tie.
*/ */
public Optional<UserStats.LoyalGame> mostLoyal(long userId) { public Optional<UserStats.LoyalGame> mostLoyal(long userId) {
return jdbc.query(""" return jdbc.query("""
select l.appid, sg.name, sg.image_url, l.months, l.minutes select l.appid, sg.name, sg.image_url, l.months, l.minutes
from (select p.appid, count(distinct date_trunc('month', p.day)) as months, sum(p.minutes) as minutes from (select coalesce(e.appid, p.appid) as appid, count(distinct date_trunc('month', p.day)) as months,
%s group by p.appid) l sum(p.minutes) as minutes
%s group by coalesce(e.appid, p.appid)) l
join listed_user_game ug on ug.user_id = :userId and ug.appid = l.appid join listed_user_game ug on ug.user_id = :userId and ug.appid = l.appid
join steam_game sg on sg.appid = l.appid join steam_game sg on sg.appid = l.appid
order by l.months desc, l.minutes desc, l.appid limit 1 order by l.months desc, l.minutes desc, l.appid limit 1
""".formatted(PLAYTIME_DAYS_NOT_HIDDEN), user(userId), """.formatted(ENTRY_DAYS), user(userId),
(rs, i) -> new UserStats.LoyalGame(rs.getLong("appid"), rs.getString("name"), (rs, i) -> new UserStats.LoyalGame(rs.getLong("appid"), rs.getString("name"),
rs.getString("image_url"), rs.getLong("months"), rs.getLong("minutes"))) rs.getString("image_url"), rs.getLong("months"), rs.getLong("minutes")))
.stream().findFirst(); .stream().findFirst();
@@ -201,13 +215,14 @@ public class UserStatsRepository {
private static String tagFrom(String column) { private static String tagFrom(String column) {
return """ return """
from listed_user_game ug join game_mood m on m.appid = ug.appid cross join unnest(m.%s) as tag(name) from counted_user_game ug join %s t on t.appid = ug.appid
where %s""".formatted(column, OF_USER); join game_mood m on m.appid = ug.appid cross join unnest(m.%s) as tag(name)
where %s""".formatted(ENTRY_PLAYTIME, column, OF_USER);
} }
private List<UserStats.TagCount> tagCounts(long userId, String column, int limit) { private List<UserStats.TagCount> tagCounts(long userId, String column, int limit) {
return jdbc.query(""" return jdbc.query("""
select tag.name, count(*) as games, coalesce(sum(ug.playtime_minutes), 0) as minutes select tag.name, count(*) as games, coalesce(sum(t.playtime_minutes), 0) as minutes
%s %s
group by tag.name order by minutes desc, games desc, tag.name limit :limit group by tag.name order by minutes desc, games desc, tag.name limit :limit
""".formatted(tagFrom(column)), user(userId).addValue("limit", limit), TAG_COUNT); """.formatted(tagFrom(column)), user(userId).addValue("limit", limit), TAG_COUNT);
@@ -312,7 +327,8 @@ public class UserStatsRepository {
} }
private static String tagSet(String column) { private static String tagSet(String column) {
return "select ug.appid, " + NO_STAT + " " + tagFrom(column) + " and tag.name = :arg"; return "select ug.appid, t.playtime_minutes as minutes, " + NO_EVENT + ", " + NO_MOVE + " " + tagFrom(column)
+ " and tag.name = :arg";
} }
/** A game once, with its last change. */ /** A game once, with its last change. */
@@ -9,12 +9,14 @@ import core_service.modules.library.api.dto.PickReasonDto;
import core_service.modules.library.api.dto.PickedGameDto; import core_service.modules.library.api.dto.PickedGameDto;
import core_service.modules.library.api.dto.PickerCategoryDto; import core_service.modules.library.api.dto.PickerCategoryDto;
import core_service.modules.library.api.dto.PickerDto; import core_service.modules.library.api.dto.PickerDto;
import core_service.modules.library.api.dto.PlatformDto;
import core_service.modules.library.api.dto.PublicLibraryDto; import core_service.modules.library.api.dto.PublicLibraryDto;
import core_service.modules.library.api.dto.RecentGameDto; import core_service.modules.library.api.dto.RecentGameDto;
import core_service.modules.library.api.dto.WishlistDto; import core_service.modules.library.api.dto.WishlistDto;
import core_service.modules.library.domain.AchievementProgress; import core_service.modules.library.domain.AchievementProgress;
import core_service.modules.library.domain.ComingGame; import core_service.modules.library.domain.ComingGame;
import core_service.modules.library.domain.DayMinutes; import core_service.modules.library.domain.DayMinutes;
import core_service.modules.library.domain.EntryPlatform;
import core_service.modules.library.domain.FriendPlay; import core_service.modules.library.domain.FriendPlay;
import core_service.modules.library.domain.GameAchievementSchemaMeta; import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.GameSource;
@@ -23,7 +25,9 @@ import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.Mood; import core_service.modules.library.domain.Mood;
import core_service.modules.library.domain.PickSource; import core_service.modules.library.domain.PickSource;
import core_service.modules.library.domain.ProfileSection; import core_service.modules.library.domain.ProfileSection;
import core_service.modules.library.domain.RecentEntry;
import core_service.modules.library.domain.StatGroup; import core_service.modules.library.domain.StatGroup;
import core_service.modules.library.domain.StatKey;
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;
import core_service.modules.library.domain.UserGameAchievementMeta; import core_service.modules.library.domain.UserGameAchievementMeta;
@@ -40,6 +44,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.repo.UserStatsRepository;
import core_service.modules.library.repo.UserSteamSyncRepository; import core_service.modules.library.repo.UserSteamSyncRepository;
import core_service.modules.library.repo.WishlistGameRepository; import core_service.modules.library.repo.WishlistGameRepository;
import core_service.shared.security.Public; import core_service.shared.security.Public;
@@ -96,6 +101,7 @@ public class LibraryQueryService {
private final GameAchievementSchemaMetaRepository schemaMetaRepository; private final GameAchievementSchemaMetaRepository schemaMetaRepository;
private final UserGameAchievementMetaRepository userAchievementMetaRepository; private final UserGameAchievementMetaRepository userAchievementMetaRepository;
private final UserStatsService userStatsService; private final UserStatsService userStatsService;
private final UserStatsRepository userStatsRepository;
private final MoodService moodService; private final MoodService moodService;
private final ReleaseDateService releaseDateService; private final ReleaseDateService releaseDateService;
private final SpeedrunService speedrunService; private final SpeedrunService speedrunService;
@@ -116,12 +122,12 @@ public class LibraryQueryService {
} }
public Page<GameDto> listOwnedGames(Long userId, boolean hidden, GameStatus status, String nameQuery, public Page<GameDto> listOwnedGames(Long userId, boolean hidden, GameStatus status, String nameQuery,
Integer maxMinutes, boolean hideDuplicates, Collection<GameSource> sources, Integer maxMinutes, Collection<GameSource> sources,
Pageable pageable) { Pageable pageable) {
Pageable stable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), Pageable stable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(),
UserGameRepository.querySort(pageable.getSort())); UserGameRepository.querySort(pageable.getSort()));
Page<UserGame> page = userGameRepository.findOwnedGames( Page<UserGame> page = userGameRepository.findOwnedGames(
userId, hidden, status, UserGameRepository.containsPattern(nameQuery), maxMinutes, hideDuplicates, userId, hidden, status, UserGameRepository.containsPattern(nameQuery), maxMinutes,
sources == null || sources.isEmpty() ? EnumSet.allOf(GameSource.class) : sources, stable); sources == null || sources.isEmpty() ? EnumSet.allOf(GameSource.class) : sources, stable);
List<GameDto> games = toDtos(page.getContent()); List<GameDto> games = toDtos(page.getContent());
return new PageImpl<>(games, page.getPageable(), page.getTotalElements()); return new PageImpl<>(games, page.getPageable(), page.getTotalElements());
@@ -281,6 +287,7 @@ public class LibraryQueryService {
.findByUserIdAndAppidIn(games.getFirst().getUserId(), appids).stream() .findByUserIdAndAppidIn(games.getFirst().getUserId(), appids).stream()
.collect(Collectors.toMap(UserGameAchievementMeta::getAppid, m -> m)); .collect(Collectors.toMap(UserGameAchievementMeta::getAppid, m -> m));
Map<Long, Integer> editions = gameLinkRepository.editionCounts(games.getFirst().getUserId(), appids); Map<Long, Integer> editions = gameLinkRepository.editionCounts(games.getFirst().getUserId(), appids);
Map<Long, List<EntryPlatform>> platforms = gameLinkRepository.entryPlatforms(games.getFirst().getUserId(), appids);
Map<Long, ComingGame> coming = comingOf(appids); Map<Long, ComingGame> coming = comingOf(appids);
Map<Long, AchievementProgress> trophies = AchievementProgress Map<Long, AchievementProgress> trophies = AchievementProgress
.perGame(psnTrophyRepository.findProgress(games.getFirst().getUserId())).stream() .perGame(psnTrophyRepository.findProgress(games.getFirst().getUserId())).stream()
@@ -288,7 +295,8 @@ public class LibraryQueryService {
return games.stream() return games.stream()
.map(ug -> toDto(ug, meta.get(ug.getAppid()), times.get(ug.getAppid()), .map(ug -> toDto(ug, meta.get(ug.getAppid()), times.get(ug.getAppid()),
schemas.get(ug.getAppid()), unlocks.get(ug.getAppid()), editions.get(ug.getAppid()), schemas.get(ug.getAppid()), unlocks.get(ug.getAppid()), editions.get(ug.getAppid()),
coming.get(ug.getAppid()), trophies.get(ug.getAppid()))) platforms.getOrDefault(ug.getAppid(), List.of()), coming.get(ug.getAppid()),
trophies.get(ug.getAppid())))
.toList(); .toList();
} }
@@ -312,8 +320,8 @@ public class LibraryQueryService {
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g)); .stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
return new AchievementSummaryDto( return new AchievementSummaryDto(
started.stream().mapToInt(AchievementProgress::unlockedCapped).sum(), started.stream().mapToInt(AchievementProgress::unlockedCapped).sum(),
perGame.size(), entries(userId, perGame.stream().map(AchievementProgress::appid).toList()),
AchievementProgress.perfectAppids(started).size(), entries(userId, AchievementProgress.perfectAppids(started)),
averageCompletion(perGame), averageCompletion(perGame),
(int) games.stream().filter(g -> !g.known()).count(), (int) games.stream().filter(g -> !g.known()).count(),
(int) games.stream().filter(g -> g.hasAchievements() && g.unlocked() != null && !g.visible()).count(), (int) games.stream().filter(g -> g.hasAchievements() && g.unlocked() != null && !g.visible()).count(),
@@ -325,6 +333,11 @@ public class LibraryQueryService {
.toList()); .toList());
} }
// Library entries of the given games: an entry whose editions are all in the list counts once.
private int entries(Long userId, List<Long> appids) {
return appids.isEmpty() ? 0 : (int) userStatsRepository.countGames(userId, StatKey.PERFECT, appids, zone);
}
// Steam achievements and PlayStation trophies; one entry per set. // Steam achievements and PlayStation trophies; one entry per set.
private List<AchievementProgress> progress(Long userId) { private List<AchievementProgress> progress(Long userId) {
return Stream.concat(userGameRepository.findAchievementProgress(userId).stream(), return Stream.concat(userGameRepository.findAchievementProgress(userId).stream(),
@@ -364,16 +377,16 @@ public class LibraryQueryService {
.toList(); .toList();
long total = perDay.stream().mapToLong(DayMinutes::minutes).sum(); long total = perDay.stream().mapToLong(DayMinutes::minutes).sum();
List<UserGame> recent = userGameRepository.findListedPlayedInTwoWeeks(userId, PageRequest.of(0, RECENT_GAMES)); List<RecentEntry> recent = userGameRepository.findEntriesPlayedInTwoWeeks(userId, RECENT_GAMES);
Map<Long, SteamGame> meta = steamGameRepository.findAllById(recent.stream().map(UserGame::getAppid).toList()) Map<Long, SteamGame> meta = steamGameRepository.findAllById(recent.stream().map(RecentEntry::getAppid).toList())
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g)); .stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
List<RecentGameDto> recentGames = recent.stream() List<RecentGameDto> recentGames = recent.stream()
.map(ug -> new RecentGameDto( .map(ug -> new RecentGameDto(
ug.getAppid(), 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.getPlaytimeMinutes().intValue(),
ug.getPlaytime2Weeks(), ug.getPlaytime2Weeks().intValue(),
ug.getStatus(), GameStatus.valueOf(ug.getStatus()),
meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getImageUrl() : null)) meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getImageUrl() : null))
.toList(); .toList();
return new ActivityDto(perDay, total, recentGames); return new ActivityDto(perDay, total, recentGames);
@@ -410,7 +423,7 @@ public class LibraryQueryService {
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), toPublicGames(finished, meta, coming),
summary ? AchievementProgress.perfectAppids(started).size() : 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));
} }
@@ -442,7 +455,8 @@ public class LibraryQueryService {
private static GameDto toDto(UserGame ug, SteamGame meta, GameTimeToBeat time, private static GameDto toDto(UserGame ug, SteamGame meta, GameTimeToBeat time,
GameAchievementSchemaMeta schema, UserGameAchievementMeta unlocks, GameAchievementSchemaMeta schema, UserGameAchievementMeta unlocks,
Integer editions, ComingGame coming, AchievementProgress trophies) { Integer editions, List<EntryPlatform> platforms, ComingGame coming,
AchievementProgress trophies) {
Integer achievementsTotal = ug.getSource() == GameSource.STEAM && schema != null && schema.isHasSchema() Integer achievementsTotal = ug.getSource() == GameSource.STEAM && schema != null && schema.isHasSchema()
&& schema.getAchievementsTotal() > 0 ? schema.getAchievementsTotal() : null; && schema.getAchievementsTotal() > 0 ? schema.getAchievementsTotal() : null;
Integer achievementsUnlocked = achievementsTotal != null && unlocks != null && unlocks.isStatsVisible() Integer achievementsUnlocked = achievementsTotal != null && unlocks != null && unlocks.isStatsVisible()
@@ -471,7 +485,8 @@ public class LibraryQueryService {
ug.getHiddenAt() != null, ug.getHiddenAt() != null,
editions, editions,
coming != null, coming != null,
coming != null ? coming.getReleaseDate() : null); coming != null ? coming.getReleaseDate() : null,
platforms.stream().map(p -> new PlatformDto(p.source(), p.platform())).toList());
} }
// The tile shows what counts for the game: its best set, Steam's on a tie. // The tile shows what counts for the game: its best set, Steam's on a tie.
@@ -0,0 +1,25 @@
-- One entry per game across platforms (Gitea #34, slice 2). An entry is a counted_user_game row (V42); these two views
-- map it to its editions, the one place that does. Built on counted_user_game and listed_user_game: a migration that
-- changes a column of user_game drops these two views first (before counted_user_game), then creates them again.
-- Every counted row with itself and the other listed rows of its user_game_set.
CREATE VIEW user_game_entry_member AS
SELECT c.user_id, c.appid, c.appid AS member_appid
FROM counted_user_game c
UNION
SELECT c.user_id, c.appid, o.appid
FROM counted_user_game c
JOIN user_game_set me ON me.user_id = c.user_id AND me.appid = c.appid
JOIN user_game_set o ON o.user_id = me.user_id AND o.set_id = me.set_id AND o.appid <> me.appid
JOIN listed_user_game og ON og.user_id = o.user_id AND og.appid = o.appid;
-- The platforms of an entry, per member: its own source and platform, and a PlayStation row for each title the user
-- has on that key as LINKED or STEAM_OWNED (the Steam + PlayStation one-row case). UNION drops the duplicates.
CREATE VIEW user_game_entry_platform AS
SELECT m.user_id, m.appid, m.member_appid, ug.source, COALESCE(ug.platform, '') AS platform
FROM user_game_entry_member m
JOIN user_game ug ON ug.user_id = m.user_id AND ug.appid = m.member_appid
UNION
SELECT m.user_id, m.appid, m.member_appid, 'PSN', ''
FROM user_game_entry_member m
JOIN user_psn_title t ON t.user_id = m.user_id AND t.appid = m.member_appid AND t.state IN ('LINKED', 'STEAM_OWNED');
@@ -279,13 +279,26 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
} }
@Test @Test
void hideDuplicates_ListsOnlyTheEditionTheSetCounts() throws Exception { void games_ASetIsOneRowWithThePlatformsOfAllItsEditions() throws Exception {
jdbc.update("update user_game set source = 'PSN', platform = 'PS5' where user_id = ? and appid = ?", USER_A, GAME_2);
linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent()); linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent());
mockMvc.perform(as(get("/api/library/games?hideDuplicates=true"), USER_A, Role.USER)).andExpect(status().isOk())
.andExpect(jsonPath("$.totalElements").value(1));
mockMvc.perform(as(get("/api/library/games"), USER_A, Role.USER)).andExpect(status().isOk()) mockMvc.perform(as(get("/api/library/games"), USER_A, Role.USER)).andExpect(status().isOk())
.andExpect(jsonPath("$.totalElements").value(2)); .andExpect(jsonPath("$.totalElements").value(1))
.andExpect(jsonPath("$.content[0].editions").value(2))
.andExpect(jsonPath("$.content[0].platforms", hasSize(2)))
.andExpect(jsonPath("$.content[0].platforms[0].source").value("PSN"))
.andExpect(jsonPath("$.content[0].platforms[0].platform").value("PS5"))
.andExpect(jsonPath("$.content[0].platforms[1].source").value("MANUAL"))
.andExpect(jsonPath("$.content[0].platforms[1].platform").value("Switch"));
}
@Test
void games_AnOldClientsHideDuplicatesParamChangesNothing() throws Exception {
linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent());
mockMvc.perform(as(get("/api/library/games?hideDuplicates=false"), USER_A, Role.USER)).andExpect(status().isOk())
.andExpect(jsonPath("$.totalElements").value(1));
} }
private void setSource(long appid, GameSource source) { private void setSource(long appid, GameSource source) {
@@ -301,10 +314,9 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
setSource(GAME_2, GameSource.PSN); setSource(GAME_2, GameSource.PSN);
linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent()); linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent());
games("?source=PSN").andExpect(jsonPath("$.totalElements").value(2)); games("?source=PSN").andExpect(jsonPath("$.totalElements").value(1));
games("?source=PSN&hideDuplicates=true").andExpect(jsonPath("$.totalElements").value(1));
games("?source=STEAM").andExpect(jsonPath("$.totalElements").value(0)); games("?source=STEAM").andExpect(jsonPath("$.totalElements").value(0));
games("?source=MANUAL,STEAM").andExpect(jsonPath("$.totalElements").value(2)); games("?source=MANUAL,STEAM").andExpect(jsonPath("$.totalElements").value(1));
} }
@Test @Test
@@ -426,7 +438,7 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
void sourceFilter_AfterASplitTheOtherEditionNoLongerMatches() throws Exception { void sourceFilter_AfterASplitTheOtherEditionNoLongerMatches() throws Exception {
setSource(GAME_2, GameSource.PSN); setSource(GAME_2, GameSource.PSN);
gameEditionService.linkSameGames(null); gameEditionService.linkSameGames(null);
games("?source=PSN").andExpect(jsonPath("$.totalElements").value(2)); games("?source=PSN").andExpect(jsonPath("$.totalElements").value(1));
splitAs(USER_A, GAME).andExpect(status().isNoContent()); splitAs(USER_A, GAME).andExpect(status().isNoContent());
@@ -75,6 +75,7 @@ class LibraryControllerAchievementsTest extends AbstractMockedAdaptersTest {
userAchievementMetaRepository.deleteAll(); userAchievementMetaRepository.deleteAll();
jdbc.update("delete from user_psn_trophy_set"); jdbc.update("delete from user_psn_trophy_set");
jdbc.update("delete from psn_trophy_set"); jdbc.update("delete from psn_trophy_set");
jdbc.update("delete from game_link");
} }
private void trophies(long appid, String setId, String platform, int total, int earned, boolean platinum) { private void trophies(long appid, String setId, String platform, int total, int earned, boolean platinum) {
@@ -146,6 +147,19 @@ class LibraryControllerAchievementsTest extends AbstractMockedAdaptersTest {
.andExpect(jsonPath("$.closest[1].imageUrl").doesNotExist()); .andExpect(jsonPath("$.closest[1].imageUrl").doesNotExist());
} }
@Test
void summary_TwoPerfectEditionsOfASetCountAsOnePerfectGame() throws Exception {
game(10L, 10, 10, true);
game(20L, 4, 4, true);
game(30L, 4, 4, true);
jdbc.update("insert into game_link (appid, set_id) values (10, 7001), (20, 7001)");
mockMvc.perform(get("/api/library/achievements/summary"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.startedGames").value(2))
.andExpect(jsonPath("$.perfectGames").value(2));
}
@Test @Test
void summary_TrophiesCount_APlatinumSetIsPerfectAndOneGameCountsOnce() throws Exception { void summary_TrophiesCount_APlatinumSetIsPerfectAndOneGameCountsOnce() throws Exception {
game(10L, null, null, true); game(10L, null, null, true);
@@ -53,6 +53,7 @@ class LibraryControllerActivityTest extends AbstractIntegrationTest {
void seed() { void seed() {
userGameRepository.deleteAll(); userGameRepository.deleteAll();
jdbc.update("delete from user_game_playtime_day"); jdbc.update("delete from user_game_playtime_day");
jdbc.update("delete from game_link where set_id = 980001");
SteamGame repairedPortal = new SteamGame(620L, "Portal 2", null, REPAIRED_COVER, Instant.now()); SteamGame repairedPortal = new SteamGame(620L, "Portal 2", null, REPAIRED_COVER, Instant.now());
steamGameRepository.saveAll(List.of( steamGameRepository.saveAll(List.of(
new SteamGame(220L, "Half-Life 2", null, Instant.now()), new SteamGame(220L, "Half-Life 2", null, Instant.now()),
@@ -90,6 +91,19 @@ class LibraryControllerActivityTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.recentGames[1].imageUrl").doesNotExist()); .andExpect(jsonPath("$.recentGames[1].imageUrl").doesNotExist());
} }
@Test
void activity_RecentGamesAreOneEntryPerSetWithTheSumOfItsEditions() throws Exception {
jdbc.update("update user_game set playtime_2weeks = 50 where user_id = ? and appid = 700", USER_ID);
jdbc.update("insert into game_link (appid, set_id) values (220, 980001), (700, 980001)");
mockMvc.perform(get("/api/library/activity").param("days", "7"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.recentGames", hasSize(2)))
.andExpect(jsonPath("$.recentGames[0].appid").value(620))
.andExpect(jsonPath("$.recentGames[1].appid").value(220))
.andExpect(jsonPath("$.recentGames[1].playtime2Weeks").value(80));
}
@Test @Test
void activity_DefaultsTo30DaysAndClampsTheRange() throws Exception { void activity_DefaultsTo30DaysAndClampsTheRange() throws Exception {
mockMvc.perform(get("/api/library/activity")).andExpect(jsonPath("$.days", hasSize(30))); mockMvc.perform(get("/api/library/activity")).andExpect(jsonPath("$.days", hasSize(30)));
@@ -76,6 +76,7 @@ class LibraryControllerPickerFriendsTest extends AbstractMockedLibraryServicesTe
jdbc.update("delete from account_name where user_id = ?", id); jdbc.update("delete from account_name where user_id = ?", id);
} }
jdbc.update("delete from follow where user_id = ?", USER_ID); jdbc.update("delete from follow where user_id = ?", USER_ID);
jdbc.update("delete from game_link where set_id = 960001");
gameReleaseRepository.deleteAll(); gameReleaseRepository.deleteAll();
achievementMetaRepository.deleteAll(); achievementMetaRepository.deleteAll();
userGameRepository.deleteAll(); userGameRepository.deleteAll();
@@ -124,6 +125,17 @@ class LibraryControllerPickerFriendsTest extends AbstractMockedLibraryServicesTe
return ROW + ".games[?(@.appid == " + appid + ")].reason." + field; return ROW + ".games[?(@.appid == " + appid + ")].reason." + field;
} }
@Test
void friends_ThePlayStationEditionOfAFriendMatchesTheSteamEditionOfTheCaller() throws Exception {
mine(10L, GameStatus.BACKLOG, null);
friend(MAX, "Max", null);
friendGame(MAX, -10L, GameStatus.FINISHED, 100);
jdbc.update("insert into game_link (appid, set_id) values (10, 960001), (-10, 960001)");
pick().andExpect(jsonPath(ROW + ".games[*].appid", contains(10)))
.andExpect(jsonPath(reason(10, "friendName"), contains("Max")));
}
@Test @Test
void friends_FinishedAndPlayedGames_WithReason() throws Exception { void friends_FinishedAndPlayedGames_WithReason() throws Exception {
mine(10L, GameStatus.BACKLOG, null); mine(10L, GameStatus.BACKLOG, null);
@@ -93,6 +93,7 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
@BeforeEach @BeforeEach
void seed() { void seed() {
jdbc.update("delete from game_link where set_id = 980002");
gameReleaseRepository.deleteAll(); gameReleaseRepository.deleteAll();
userGameRepository.deleteAll(); userGameRepository.deleteAll();
schemaMetaRepository.deleteAll(); schemaMetaRepository.deleteAll();
@@ -114,6 +115,17 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
new UserGame(99L, 220L, 5000, Instant.now(), Instant.now()))); new UserGame(99L, 220L, 5000, Instant.now(), Instant.now())));
} }
@Test
void publicLibrary_ListsASetOnce() throws Exception {
jdbc.update("insert into game_link (appid, set_id) values (220, 980002), (700, 980002)");
mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket(USER_ID)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalGames").value(2))
.andExpect(jsonPath("$.playing", hasSize(0)))
.andExpect(jsonPath("$.finished", hasSize(2)));
}
@Test @Test
void publicLibrary_ShowsStatsPlayingAndFinishedGamesButNoNotesOrPriority() throws Exception { void publicLibrary_ShowsStatsPlayingAndFinishedGamesButNoNotesOrPriority() throws Exception {
mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket(USER_ID))) mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket(USER_ID)))
@@ -92,9 +92,9 @@ class StatGamesFlowTest extends AbstractMockedLibraryServicesTest {
count("PILE", null, "/pile/backlogGames"), count("PILE", null, "/pile/backlogGames"),
new Case("PILE_TIME_TO_BEAT", null, s -> s.at("/pile/gamesWithTimeToBeat").asLong(), "minutes", new Case("PILE_TIME_TO_BEAT", null, s -> s.at("/pile/gamesWithTimeToBeat").asLong(), "minutes",
s -> s.at("/pile/normalMinutes").asLong()), s -> s.at("/pile/normalMinutes").asLong()),
new Case("GENRE", "RPG", s -> tagValue(s.at("/taste/genres"), "RPG", "games"), "playtimeMinutes", new Case("GENRE", "RPG", s -> tagValue(s.at("/taste/genres"), "RPG", "games"), "minutes",
s -> tagValue(s.at("/taste/genres"), "RPG", "minutes")), s -> tagValue(s.at("/taste/genres"), "RPG", "minutes")),
new Case("MOOD", "CHILL", s -> tagValue(s.at("/taste/moods"), "CHILL", "games"), "playtimeMinutes", new Case("MOOD", "CHILL", s -> tagValue(s.at("/taste/moods"), "CHILL", "games"), "minutes",
s -> tagValue(s.at("/taste/moods"), "CHILL", "minutes")), s -> tagValue(s.at("/taste/moods"), "CHILL", "minutes")),
new Case("LONGEST_DAY", null, null, "minutes", s -> s.at("/habits/longestDay/minutes").asLong()), new Case("LONGEST_DAY", null, null, "minutes", s -> s.at("/habits/longestDay/minutes").asLong()),
count("UNDER_ONE_HOUR", null, "/habits/underOneHourGames"), count("UNDER_ONE_HOUR", null, "/habits/underOneHourGames"),
@@ -0,0 +1,120 @@
package core_service.modules.library.repo;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class GameEntryViewTest extends AbstractPersistenceTest {
private static final long USER = 1L;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
jdbc.update("delete from user_psn_title");
jdbc.update("delete from game_link");
jdbc.update("delete from user_game");
for (long appid : List.of(-10L, 10L, 20L, 30L)) {
jdbc.update("insert into steam_game (appid, name) values (?, ?) on conflict do nothing", appid, "Game " + appid);
}
}
private void set(long appid, long setId) {
jdbc.update("insert into game_link (appid, set_id) values (?, ?)", appid, setId);
}
private void own(long appid, String source, String platform, int playtime) {
jdbc.update("insert into user_game (user_id, appid, source, platform, playtime_minutes, status) values (?, ?, ?, ?, ?, 'BACKLOG')",
USER, appid, source, platform, playtime);
}
private void title(String key, String state, long appid) {
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, ?, ?, ?)", USER, key, state, appid);
}
private List<String> members() {
return jdbc.queryForList("select appid || '>' || member_appid from user_game_entry_member where user_id = ? "
+ "order by appid, member_appid", String.class, USER);
}
private List<String> platforms(long appid) {
return jdbc.queryForList("select member_appid || ':' || source || ':' || platform from user_game_entry_platform "
+ "where user_id = ? and appid = ? order by member_appid, source", String.class, USER, appid);
}
@Test
void member_EntryHasItselfAndItsListedEditions() {
own(10, "STEAM", null, 600);
own(-10, "PSN", "PS5", 60);
own(20, "STEAM", null, 5);
set(10, 1);
set(-10, 1);
assertThat(members()).containsExactly("10>-10", "10>10", "20>20");
}
@Test
void member_LeavesOutAHiddenEdition() {
own(10, "STEAM", null, 600);
own(-10, "PSN", "PS5", 60);
set(10, 1);
set(-10, 1);
jdbc.update("update user_game set hidden_at = now() where appid = -10");
assertThat(members()).containsExactly("10>10");
}
@Test
void member_ASplitEditionIsItsOwnEntry() {
own(10, "STEAM", null, 600);
own(-10, "PSN", "PS5", 60);
set(10, 1);
set(-10, 1);
jdbc.update("insert into user_game_split (user_id, appid) values (?, -10)", USER);
assertThat(members()).containsExactly("-10>-10", "10>10");
}
@Test
void platform_ASteamOwnedPsnTitleGivesAPsnPlatformOnTheSteamRow() {
own(10, "STEAM", null, 600);
title("c1", "STEAM_OWNED", 10);
assertThat(platforms(10)).containsExactly("10:PSN:", "10:STEAM:");
}
@Test
void platform_TwoTitlesOnOneKeyGiveOnePsnRow() {
own(10, "STEAM", null, 600);
title("c1", "STEAM_OWNED", 10);
title("c2", "STEAM_OWNED", 10);
assertThat(platforms(10)).containsExactly("10:PSN:", "10:STEAM:");
}
@Test
void platform_ASetListsEveryEditionWithItsPlatform() {
own(10, "STEAM", null, 600);
own(-10, "PSN", "PS5", 60);
set(10, 1);
set(-10, 1);
assertThat(platforms(10)).containsExactly("-10:PSN:PS5", "10:STEAM:");
}
@Test
void platform_ADismissedTitleGivesNoPlatform() {
own(10, "STEAM", null, 600);
title("c1", "DISMISSED", 10);
assertThat(platforms(10)).containsExactly("10:STEAM:");
}
}
@@ -30,7 +30,7 @@ class GameLinkRepositoryTest extends AbstractPersistenceTest {
@BeforeEach @BeforeEach
void seed() { void seed() {
for (String table : List.of("game_link_request", "game_link_pair", "game_link", "game_edition", "user_game")) { for (String table : List.of("user_psn_title", "game_link_request", "game_link_pair", "game_link", "game_edition", "user_game")) {
jdbc.update("delete from " + table); jdbc.update("delete from " + table);
} }
for (long appid : List.of(100L, 200L, -7L, -8L, 300L)) { for (long appid : List.of(100L, 200L, -7L, -8L, 300L)) {
@@ -254,6 +254,47 @@ class GameLinkRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.linkedEditions(USER, 100)).isEmpty(); assertThat(repository.linkedEditions(USER, 100)).isEmpty();
} }
private void ownOn(long appid, String source, String platform) {
jdbc.update("insert into user_game (user_id, appid, status, source, platform) values (?, ?, 'BACKLOG', ?, ?)",
USER, appid, source, platform);
}
@Test
void entryPlatforms_SteamFirstThenPlayStationThenManual_AndAPlayStationTitleWithoutNameIsDropped() {
repository.link(100, 200);
repository.link(100, -7);
ownOn(100, "STEAM", null);
ownOn(200, "PSN", "PS5");
ownOn(-7, "MANUAL", "Switch");
jdbc.update("update user_game set playtime_minutes = 500 where appid = 100");
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'STEAM_OWNED', 100)", USER);
var platforms = repository.entryPlatforms(USER, List.of(100L, 300L));
assertThat(platforms.keySet()).containsExactly(100L);
assertThat(platforms.get(100L)).extracting(p -> p.source() + ":" + p.platform())
.containsExactly("STEAM:null", "PSN:PS5", "MANUAL:Switch");
assertThat(repository.entryPlatforms(USER, List.of())).isEmpty();
}
@Test
void entryPlatforms_ASteamRowWithAPlayStationTitleHasBothPlatforms() {
ownOn(100, "STEAM", null);
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'STEAM_OWNED', 100)", USER);
assertThat(repository.entryPlatforms(USER, List.of(100L)).get(100L))
.extracting(p -> p.source() + ":" + p.platform()).containsExactly("STEAM:null", "PSN:null");
}
@Test
void entryPlatforms_AManualPlayStationRowWithALinkedTitleIsOnePlatform() {
ownOn(-7, "MANUAL", "PS5");
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', -7)", USER);
assertThat(repository.entryPlatforms(USER, List.of(-7L)).get(-7L))
.extracting(p -> p.source() + ":" + p.platform()).containsExactly("MANUAL:PS5");
}
@Test @Test
void editionCounts_GivesTwoForBothKeysAndNothingForASingleOwnedKey() { void editionCounts_GivesTwoForBothKeysAndNothingForASingleOwnedKey() {
repository.link(100, 200); repository.link(100, 200);
@@ -5,6 +5,7 @@ import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameTimeToBeat; import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.Mood; import core_service.modules.library.domain.Mood;
import core_service.modules.library.domain.RecentEntry;
import core_service.modules.library.domain.StatusCount; import core_service.modules.library.domain.StatusCount;
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;
@@ -81,7 +82,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
} }
private List<Long> appids(GameStatus status, String query) { private List<Long> appids(GameStatus status, String query) {
return userGameRepository.findOwnedGames(USER_ID, false, status, UserGameRepository.containsPattern(query), null, false, ALL_SOURCES, PAGE) return userGameRepository.findOwnedGames(USER_ID, false, status, UserGameRepository.containsPattern(query), null, ALL_SOURCES, PAGE)
.map(UserGame::getAppid).getContent(); .map(UserGame::getAppid).getContent();
} }
@@ -210,7 +211,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
@Test @Test
void findOwnedGames_CountsAllMatchesAcrossPages() { void findOwnedGames_CountsAllMatchesAcrossPages() {
var page = userGameRepository.findOwnedGames(USER_ID, false, null, UserGameRepository.containsPattern("half"), null, false, ALL_SOURCES, var page = userGameRepository.findOwnedGames(USER_ID, false, null, UserGameRepository.containsPattern("half"), null, ALL_SOURCES,
PageRequest.of(0, 1, Sort.by("appid"))); PageRequest.of(0, 1, Sort.by("appid")));
assertEquals(List.of(220L), page.map(UserGame::getAppid).getContent()); assertEquals(List.of(220L), page.map(UserGame::getAppid).getContent());
@@ -218,7 +219,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
} }
private List<Long> sortedAppids(Sort sort) { private List<Long> sortedAppids(Sort sort) {
return userGameRepository.findOwnedGames(USER_ID, false, null, null, null, false, 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();
} }
@@ -269,7 +270,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
new GameTimeToBeat(620L, 300, 480, 900, Instant.now()), new GameTimeToBeat(620L, 300, 480, 900, Instant.now()),
new GameTimeToBeat(380L, null, null, null, Instant.now()))); new GameTimeToBeat(380L, null, null, null, Instant.now())));
List<Long> shortGames = userGameRepository.findOwnedGames(USER_ID, false, null, null, 600, false, ALL_SOURCES, PAGE).map(UserGame::getAppid).getContent(); List<Long> shortGames = userGameRepository.findOwnedGames(USER_ID, false, null, null, 600, ALL_SOURCES, PAGE).map(UserGame::getAppid).getContent();
assertEquals(List.of(620L), shortGames); assertEquals(List.of(620L), shortGames);
} }
@@ -296,7 +297,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
hide(620L, 700L); hide(620L, 700L);
assertEquals(List.of(220L, 380L), appids(null, null)); assertEquals(List.of(220L, 380L), appids(null, null));
assertEquals(List.of(620L, 700L), userGameRepository.findOwnedGames(USER_ID, true, null, null, null, false, ALL_SOURCES, assertEquals(List.of(620L, 700L), userGameRepository.findOwnedGames(USER_ID, true, null, null, null, ALL_SOURCES,
PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent()); PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent());
assertEquals(Set.of(620L, 700L), Set.copyOf(userGameRepository.findHiddenAppids(USER_ID))); assertEquals(Set.of(620L, 700L), Set.copyOf(userGameRepository.findHiddenAppids(USER_ID)));
} }
@@ -317,8 +318,8 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
assertEquals(Set.of(new StatusCount(GameStatus.PLAYING, 1), new StatusCount(GameStatus.BACKLOG, 1)), assertEquals(Set.of(new StatusCount(GameStatus.PLAYING, 1), new StatusCount(GameStatus.BACKLOG, 1)),
Set.copyOf(userGameRepository.countByStatus(USER_ID))); Set.copyOf(userGameRepository.countByStatus(USER_ID)));
assertEquals(List.of(380L), userGameRepository.findBacklogAppids(USER_ID)); assertEquals(List.of(380L), userGameRepository.findBacklogAppids(USER_ID));
assertEquals(List.of(220L), userGameRepository.findListedPlayedInTwoWeeks(USER_ID, PAGE) assertEquals(List.of(220L), userGameRepository.findEntriesPlayedInTwoWeeks(USER_ID, 20)
.stream().map(UserGame::getAppid).toList()); .stream().map(RecentEntry::getAppid).toList());
assertEquals(List.of(220L), userGameRepository.findListedByStatusIn(USER_ID, List.of(GameStatus.PLAYING), PAGE) assertEquals(List.of(220L), userGameRepository.findListedByStatusIn(USER_ID, List.of(GameStatus.PLAYING), PAGE)
.stream().map(UserGame::getAppid).toList()); .stream().map(UserGame::getAppid).toList());
assertEquals(Set.of(220L, 380L), Set.copyOf(userGameRepository.findAchievementProgress(USER_ID) assertEquals(Set.of(220L, 380L), Set.copyOf(userGameRepository.findAchievementProgress(USER_ID)
@@ -370,7 +371,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
} }
private List<Long> appidsOfSources(GameSource... sources) { private List<Long> appidsOfSources(GameSource... sources) {
return userGameRepository.findOwnedGames(USER_ID, false, null, null, null, false, Set.of(sources), return userGameRepository.findOwnedGames(USER_ID, false, null, null, null, Set.of(sources),
PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent(); PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent();
} }
@@ -421,19 +422,99 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
} }
@Test @Test
void linkedBacklogEditions_CountOnceAndTheOnceFilterKeepsOne() { void linkedBacklogEditions_CountOnceAndTheListKeepsOne() {
link(380L, 700L); link(380L, 700L);
userGameRepository.flush(); userGameRepository.flush();
assertEquals(Set.of(new StatusCount(GameStatus.PLAYING, 2), new StatusCount(GameStatus.BACKLOG, 1)), assertEquals(Set.of(new StatusCount(GameStatus.PLAYING, 2), new StatusCount(GameStatus.BACKLOG, 1)),
Set.copyOf(userGameRepository.countByStatus(USER_ID))); Set.copyOf(userGameRepository.countByStatus(USER_ID)));
assertEquals(1, userGameRepository.countListedNeverPlayed(USER_ID)); assertEquals(1, userGameRepository.countListedNeverPlayed(USER_ID));
assertEquals(List.of(220L, 380L, 620L), userGameRepository.findOwnedGames(USER_ID, false, null, null, null, true, ALL_SOURCES, assertEquals(List.of(220L, 380L, 620L), userGameRepository.findOwnedGames(USER_ID, false, null, null, null, ALL_SOURCES,
PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent());
assertEquals(List.of(220L, 380L, 620L, 700L), userGameRepository.findOwnedGames(USER_ID, false, null, null, null, false, ALL_SOURCES,
PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent()); PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent());
} }
private List<Long> listed(boolean hidden, String query) {
return userGameRepository.findOwnedGames(USER_ID, hidden, null, UserGameRepository.containsPattern(query), null,
ALL_SOURCES, PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent();
}
@Test
void findOwnedGames_ASetIsOneRow_TheCountedEdition() {
link(220L, 380L);
userGameRepository.flush();
assertEquals(List.of(220L, 620L, 700L), listed(false, null));
}
@Test
void findOwnedGames_TheNameOfTheLesserEditionFindsTheEntry() {
link(220L, 380L);
userGameRepository.flush();
assertEquals(List.of(220L), listed(false, "episode"));
}
@Test
void findOwnedGames_BySource_FindsASteamRowWithAPlayStationTitle() {
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'STEAM_OWNED', 620)", USER_ID);
assertEquals(List.of(620L), appidsOfSources(GameSource.PSN));
}
@Test
void findOwnedGames_BySource_FindsAnEntryThroughItsLesserEdition() {
link(220L, 380L);
setSource(GameSource.PSN, 380L);
assertEquals(List.of(220L), appidsOfSources(GameSource.PSN));
}
@Test
void findOwnedGames_Hidden_ListsEveryHiddenRow() {
link(220L, 380L);
hide(220L, 380L);
assertEquals(List.of(220L, 380L), listed(true, null));
assertEquals(List.of(380L), listed(true, "episode"));
}
@Test
void findEntriesPlayedInTwoWeeks_SumsTheSetAndKeepsAnEntryPlayedOnlyOnTheLesserEdition() {
link(220L, 380L);
userGameRepository.findById(new UserGameId(USER_ID, 220L)).orElseThrow().setPlaytime2Weeks(30);
userGameRepository.findById(new UserGameId(USER_ID, 380L)).orElseThrow().setPlaytime2Weeks(40);
userGameRepository.findById(new UserGameId(USER_ID, 620L)).orElseThrow().setPlaytime2Weeks(50);
userGameRepository.flush();
var recent = userGameRepository.findEntriesPlayedInTwoWeeks(USER_ID, 20);
assertEquals(List.of(220L, 620L), recent.stream().map(RecentEntry::getAppid).toList());
assertEquals(70L, recent.getFirst().getPlaytime2Weeks());
assertEquals(300L, recent.getFirst().getPlaytimeMinutes());
userGameRepository.findById(new UserGameId(USER_ID, 220L)).orElseThrow().setPlaytime2Weeks(0);
userGameRepository.flush();
assertEquals(List.of(620L, 220L), userGameRepository.findEntriesPlayedInTwoWeeks(USER_ID, 20)
.stream().map(RecentEntry::getAppid).toList());
}
@Test
void countListed_CountsASetOnce() {
link(220L, 380L);
userGameRepository.flush();
assertEquals(3, userGameRepository.countListed(USER_ID));
}
@Test
void findListedByStatusIn_ListsASetOnce() {
link(220L, 380L);
userGameRepository.flush();
assertEquals(List.of(220L, 620L), userGameRepository.findListedByStatusIn(USER_ID, List.of(GameStatus.PLAYING), PAGE)
.stream().map(UserGame::getAppid).sorted().toList());
}
@Test @Test
void backlogEditionOfAPlayingSet_IsNeverPicked() { void backlogEditionOfAPlayingSet_IsNeverPicked() {
link(220L, 380L); link(220L, 380L);
@@ -374,6 +374,81 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.games(USER_ID, StatKey.TOTAL, "", BERLIN, 0, 10)).hasSize(1); assertThat(repository.games(USER_ID, StatKey.TOTAL, "", BERLIN, 0, 10)).hasSize(1);
} }
private void seedSet() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 300, GameStatus.PLAYING, Instant.now()),
game(620L, 900, GameStatus.PLAYING, Instant.now()),
game(700L, 500, GameStatus.PLAYING, Instant.now())));
link(220L, 620L);
}
@Test
void mostPlayed_ASetIsOneRowWithTheSumOfItsEditions() {
seedSet();
assertThat(repository.mostPlayed(USER_ID, 5)).extracting(UserStats.GameMinutes::minutes)
.containsExactly(1200L, 500L);
assertThat(repository.games(USER_ID, StatKey.MOST_PLAYED, "", BERLIN, 0, 10))
.extracting(UserStats.StatGame::minutes).containsExactly(1200L, 500L);
}
@Test
void mostLoyal_CountsTheMonthsOfEveryEditionOfASet() {
seedSet();
addDay(220L, LocalDate.of(2026, 7, 1), 10);
addDay(620L, LocalDate.of(2026, 8, 1), 10);
addDay(620L, LocalDate.of(2026, 9, 1), 10);
addDay(700L, LocalDate.of(2026, 7, 3), 30);
addDay(700L, LocalDate.of(2026, 8, 3), 30);
UserStats.LoyalGame loyal = repository.mostLoyal(USER_ID).orElseThrow();
assertThat(loyal.months()).isEqualTo(3);
assertThat(loyal.minutes()).isEqualTo(30);
}
@Test
void genresAndMoods_CountASetOnceWithTheSummedMinutes() {
seedSet();
for (long appid : List.of(220L, 620L, 700L)) {
gameMoodsRepository.upsert(appid, new String[]{"ACTION"}, new String[]{"Shooter"}, false, Instant.now());
}
assertThat(repository.genres(USER_ID, 8)).containsExactly(new UserStats.TagCount("Shooter", 2, 1700));
assertThat(repository.moods(USER_ID)).containsExactly(new UserStats.TagCount("ACTION", 2, 1700));
assertThat(repository.countGames(USER_ID, StatKey.GENRE, "Shooter", BERLIN)).isEqualTo(2);
assertThat(repository.games(USER_ID, StatKey.GENRE, "Shooter", BERLIN, 0, 10))
.extracting(UserStats.StatGame::minutes).containsExactly(1200L, 500L);
}
@Test
void games_PerfectListsASetOnce() {
seedSet();
assertThat(repository.countGames(USER_ID, StatKey.PERFECT, List.of(220L, 620L), BERLIN)).isEqualTo(1);
assertThat(repository.games(USER_ID, StatKey.PERFECT, List.of(220L, 620L), BERLIN, 0, 10)).hasSize(1);
}
@Test
void games_LongestDayListsASetOnceWithTheSumOfItsEditions() {
seedSet();
LocalDate day = LocalDate.of(2026, 7, 1);
addDay(220L, day, 100);
addDay(620L, day, 100);
assertThat(repository.countGames(USER_ID, StatKey.LONGEST_DAY, day, BERLIN)).isEqualTo(1);
assertThat(repository.games(USER_ID, StatKey.LONGEST_DAY, day, BERLIN, 0, 10))
.extracting(UserStats.StatGame::minutes).containsExactly(200L);
}
@Test
void platformStat_ASetCountsOnEveryPlatformItHas() {
seedSet();
assertThat(repository.gamesPerPlatform(USER_ID)).containsExactly(new UserStats.TagCount("Steam", 3, 1700));
assertThat(repository.games(USER_ID, StatKey.PLATFORM, "Steam", BERLIN, 0, 10)).hasSize(3);
}
private void comingTomorrow(long appid) { private void comingTomorrow(long appid) {
jdbc.update("insert into game_release (appid, release_date, fetched_at) values (?, ?, now())", appid, jdbc.update("insert into game_release (appid, release_date, fetched_at) values (?, ?, now())", appid,
Date.valueOf(LocalDate.now(BERLIN).plusDays(1))); Date.valueOf(LocalDate.now(BERLIN).plusDays(1)));