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
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
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
sums, most played, genres, moods and platforms still use every listed game.
playtime, then the older `first_seen_at`; `counted_user_game`, V28), so the set is on the pile only when every owned edition is `BACKLOG`. Most played, most loyal,
genres, moods, perfect games and the longest day list one entry per set, with the playtime of all its editions
(`user_game_entry_member`, V43; its list shows that sum as `minutes`). Platforms and playtime sums (`sumPlaytime`) stay
per edition: a Steam + PlayStation set counts on both platforms.
- **Drill-down**: every countable number opens the games behind it. A `StatKey` (library), `CommunityKey` (landing page)
or a Pile Points track/bonus row names a number; its SQL "game set" is the same text the number counts, so the
list and the number cannot drift. 40 games per page (at most page 250), a fixed order per key. A profile visitor
@@ -164,7 +166,7 @@ flowchart LR
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 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
groups the user shares (the ticket's `profileStats` claim, derived in Auth from the profile tiles); none by
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 |
| `GET` | `/api/rewards/progress/{track}/games?page=`, `/api/rewards/history/{kind}/{ref}/games?page=` (the games behind a track or a `MONTH` / `STREAK` bonus; `400 NOT_A_GAME_LIST`) | JWT |
| `PUT` | `/api/rewards/equipped/{slot}` (`{item}`, null = take off; `200` wallet, `409 NOT_OWNED`, `400 WRONG_SLOT`) | JWT |
| `GET` | `/api/library/games` (`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 |
| `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 |
@@ -339,7 +341,7 @@ flowchart LR
`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
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
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
@@ -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
`listed_user_game` for native SQL; V24 says what a migration of `user_game` must do with the view. The numbers that
count games once per linked set read `counted_user_game` (V28), built on `listed_user_game`: a migration that changes
a column of `user_game` drops 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`
(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
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`.
- Pile, stats, snapshots and the dashboard count a set once (`counted_user_game`, see "Stats"); the library
filter `hideDuplicates=true` hides the lesser editions. Pile Points pay a set once (see "Pile Points"), and the
- Every list of the user's own games shows a set once, as its counted edition (`counted_user_game`, see "Stats"): 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.
- 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
flowchart LR
@@ -563,6 +574,10 @@ flowchart LR
Link --> Set["user_game_set"]
Split["user_game_split (user splits)"] -->|"minus"| Set
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)"]
Row -->|"update: user_game_set_share"| Row
Row -.->|"old values"| Backup["user_game_set_backup"]
@@ -136,7 +136,6 @@ public class LibraryController {
@RequestParam(required = false) String q,
@RequestParam(required = false) Integer maxMinutes,
@RequestParam(defaultValue = "false") boolean hidden,
@RequestParam(defaultValue = "false") boolean hideDuplicates,
@RequestParam(required = false) List<GameSource> source
) {
Long userId = principal.getIdAsLong();
@@ -145,7 +144,7 @@ public class LibraryController {
int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE;
int safePage = Math.max(0, page);
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));
}
@@ -5,6 +5,7 @@ import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import java.time.LocalDate;
import java.util.List;
/**
* @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 coming the game is not released yet and nobody played it; it does not count on the pile
* @param comingOn the release date of a coming game; null for released games and for "coming soon"
* @param platforms the platforms of every edition of the entry, Steam first; empty for a hidden game
*/
public record GameDto(
Long appid,
@@ -36,5 +38,6 @@ public record GameDto(
boolean hidden,
Integer editions,
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
* 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
@RequiredArgsConstructor
@@ -34,8 +35,11 @@ public class FriendPlayRepository {
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 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
and coalesce(lf.set_id::text, 'a' || fg.appid) = coalesce(lu.set_id::text, 'a' || ug.appid)
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 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.Edition;
import core_service.modules.library.domain.EntryPlatform;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.LinkPair;
@@ -11,6 +12,7 @@ import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
@@ -305,6 +307,34 @@ public class GameLinkRepository {
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) {
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.GameStatus;
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.UserGame;
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")
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);
@Query("select coalesce(sum(ug.playtimeMinutes), 0) from UserGame ug where " + LISTED)
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,
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")
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 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 hideDuplicates only the games a linked set counts (see V28), not its lesser editions
* @param sources the sources a row or one of its listed editions in the user's set (see V42) must have; never empty
* @param sources the sources one platform of the entry must have (a PlayStation title on a Steam row counts as
* PSN); never empty, and ignored for the hidden games
*/
@Query("select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " +
"where ug.userId = :userId and ug.removedAt is null " +
"and ((:hidden = true and ug.hiddenAt is not null) or (:hidden = false and ug.hiddenAt is null)) " +
"and (:status is null or ug.status = :status) " +
"and (:namePattern is null or 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 " +
" 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)) " +
"and (ug.source in :sources or exists (select 1 from UserGameSetMember l " +
" join UserGameSetMember o on o.userId = l.userId and o.setId = l.setId and o.appid <> l.appid " +
" 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))")
"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,
@Param("hidden") boolean hidden,
@Param("status") GameStatus status,
@Param("namePattern") String namePattern,
@Param("maxMinutes") Integer maxMinutes,
@Param("hideDuplicates") boolean hideDuplicates,
@Param("sources") Collection<GameSource> sources,
Pageable pageable);
@@ -39,11 +39,16 @@ public class UserStatsRepository {
where sc.user_id = :userId and %s
group by sc.appid
""".formatted(FINISH_CHANGE);
private static final String PLAYTIME_DAYS_NOT_HIDDEN = """
from user_game_playtime_day p
private static final String DAY_NOT_HIDDEN = """
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)
""";
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. */
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
where %s and ug.status = 'BACKLOG' and %s
""".formatted(OF_USER, UserGameRepository.NOT_COMING);
/** Per entry (see V43), the playtime of all its editions; the user's other rows are not in it. */
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 = """
from listed_user_game ug join steam_game sg on sg.appid = ug.appid
where %s and ug.playtime_minutes > 0
""".formatted(OF_USER);
from %s ug join steam_game sg on sg.appid = ug.appid
where ug.playtime_minutes > 0
""".formatted(ENTRY_PLAYTIME);
private static final String PLATFORM_FROM = """
from (select ug.appid, case when ug.source = 'STEAM' then 'Steam' else trim(ug.platform) end as name,
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 + ", "
+ 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 + ", "
+ NO_MOVE + " " + PLAYTIME_DAYS_NOT_HIDDEN + "and p.day = :arg group by p.appid";
private static final String LONGEST_DAY_SET = "select coalesce(e.appid, p.appid) as appid, sum(p.minutes) as minutes, "
+ 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
+ " 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, "
@@ -113,13 +123,14 @@ public class UserStatsRepository {
Map.entry(StatKey.UNDER_ONE_HOUR, counted(UNDER_ONE_HOUR)),
Map.entry(StatKey.FINISHED_UNTRACKED, counted(UNTRACKED)),
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.MOOD, tagSet("moods")),
Map.entry(StatKey.PLATFORM, "select g.appid, " + NO_STAT + " " + PLATFORM_FROM
+ "and lower(g.name) = lower(trim(:arg))"),
Map.entry(StatKey.PERFECT, "select ug.appid, " + NO_STAT + " from listed_user_game ug where " + OF_USER
+ "and ug.appid in (:arg)"),
Map.entry(StatKey.PERFECT, "select distinct e.appid, " + NO_STAT + " from user_game_entry_member e "
+ "where e.user_id = :userId and e.member_appid in (:arg)"),
Map.entry(StatKey.LONGEST_DAY, LONGEST_DAY_SET),
Map.entry(StatKey.FINISHED_YEAR, FINISHED_YEAR_SET),
Map.entry(StatKey.TREND_ADDED, movedSet(ADDED)),
@@ -127,11 +138,13 @@ public class UserStatsRepository {
Map.entry(StatKey.TREND_MOVES, MOVES_SET));
/** The list order; a key not here goes by playtime. */
private static final Map<StatKey, String> ORDERS = Map.of(
StatKey.NEVER_STARTED, BY_NAME, StatKey.PILE, BY_NAME, StatKey.FINISHED_UNTRACKED, BY_NAME,
StatKey.PILE_TIME_TO_BEAT, BY_MINUTES, StatKey.LONGEST_DAY, BY_MINUTES,
StatKey.FINISHED_YEAR, BY_DATE, StatKey.TREND_ADDED, BY_DATE, StatKey.TREND_DONE, BY_DATE,
StatKey.TREND_MOVES, BY_DATE);
private static final Map<StatKey, String> ORDERS = Map.ofEntries(
Map.entry(StatKey.NEVER_STARTED, BY_NAME), Map.entry(StatKey.PILE, BY_NAME),
Map.entry(StatKey.FINISHED_UNTRACKED, BY_NAME), Map.entry(StatKey.PILE_TIME_TO_BEAT, BY_MINUTES),
Map.entry(StatKey.LONGEST_DAY, BY_MINUTES), Map.entry(StatKey.MOST_PLAYED, BY_MINUTES),
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 =
(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) {
return jdbc.query("""
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
%s group by p.appid) l
from (select coalesce(e.appid, p.appid) as appid, count(distinct date_trunc('month', p.day)) as months,
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 steam_game sg on sg.appid = l.appid
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.getString("image_url"), rs.getLong("months"), rs.getLong("minutes")))
.stream().findFirst();
@@ -201,13 +215,14 @@ public class UserStatsRepository {
private static String tagFrom(String column) {
return """
from listed_user_game ug join game_mood m on m.appid = ug.appid cross join unnest(m.%s) as tag(name)
where %s""".formatted(column, OF_USER);
from counted_user_game ug join %s t on t.appid = ug.appid
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) {
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
group by tag.name order by minutes desc, games desc, tag.name limit :limit
""".formatted(tagFrom(column)), user(userId).addValue("limit", limit), TAG_COUNT);
@@ -312,7 +327,8 @@ public class UserStatsRepository {
}
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. */
@@ -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.PickerCategoryDto;
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.RecentGameDto;
import core_service.modules.library.api.dto.WishlistDto;
import core_service.modules.library.domain.AchievementProgress;
import core_service.modules.library.domain.ComingGame;
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.GameAchievementSchemaMeta;
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.PickSource;
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.StatKey;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
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.UserGameAchievementMetaRepository;
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.WishlistGameRepository;
import core_service.shared.security.Public;
@@ -96,6 +101,7 @@ public class LibraryQueryService {
private final GameAchievementSchemaMetaRepository schemaMetaRepository;
private final UserGameAchievementMetaRepository userAchievementMetaRepository;
private final UserStatsService userStatsService;
private final UserStatsRepository userStatsRepository;
private final MoodService moodService;
private final ReleaseDateService releaseDateService;
private final SpeedrunService speedrunService;
@@ -116,12 +122,12 @@ public class LibraryQueryService {
}
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 stable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(),
UserGameRepository.querySort(pageable.getSort()));
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);
List<GameDto> games = toDtos(page.getContent());
return new PageImpl<>(games, page.getPageable(), page.getTotalElements());
@@ -281,6 +287,7 @@ public class LibraryQueryService {
.findByUserIdAndAppidIn(games.getFirst().getUserId(), appids).stream()
.collect(Collectors.toMap(UserGameAchievementMeta::getAppid, m -> m));
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, AchievementProgress> trophies = AchievementProgress
.perGame(psnTrophyRepository.findProgress(games.getFirst().getUserId())).stream()
@@ -288,7 +295,8 @@ public class LibraryQueryService {
return games.stream()
.map(ug -> toDto(ug, meta.get(ug.getAppid()), times.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();
}
@@ -312,8 +320,8 @@ public class LibraryQueryService {
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
return new AchievementSummaryDto(
started.stream().mapToInt(AchievementProgress::unlockedCapped).sum(),
perGame.size(),
AchievementProgress.perfectAppids(started).size(),
entries(userId, perGame.stream().map(AchievementProgress::appid).toList()),
entries(userId, AchievementProgress.perfectAppids(started)),
averageCompletion(perGame),
(int) games.stream().filter(g -> !g.known()).count(),
(int) games.stream().filter(g -> g.hasAchievements() && g.unlocked() != null && !g.visible()).count(),
@@ -325,6 +333,11 @@ public class LibraryQueryService {
.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.
private List<AchievementProgress> progress(Long userId) {
return Stream.concat(userGameRepository.findAchievementProgress(userId).stream(),
@@ -364,16 +377,16 @@ public class LibraryQueryService {
.toList();
long total = perDay.stream().mapToLong(DayMinutes::minutes).sum();
List<UserGame> recent = userGameRepository.findListedPlayedInTwoWeeks(userId, PageRequest.of(0, RECENT_GAMES));
Map<Long, SteamGame> meta = steamGameRepository.findAllById(recent.stream().map(UserGame::getAppid).toList())
List<RecentEntry> recent = userGameRepository.findEntriesPlayedInTwoWeeks(userId, RECENT_GAMES);
Map<Long, SteamGame> meta = steamGameRepository.findAllById(recent.stream().map(RecentEntry::getAppid).toList())
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
List<RecentGameDto> recentGames = recent.stream()
.map(ug -> new RecentGameDto(
ug.getAppid(),
meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getName() : null,
ug.getPlaytimeMinutes(),
ug.getPlaytime2Weeks(),
ug.getStatus(),
ug.getPlaytimeMinutes().intValue(),
ug.getPlaytime2Weeks().intValue(),
GameStatus.valueOf(ug.getStatus()),
meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getImageUrl() : null))
.toList();
return new ActivityDto(perDay, total, recentGames);
@@ -410,7 +423,7 @@ public class LibraryQueryService {
summary ? byStatus.get(GameStatus.FINISHED) + byStatus.get(GameStatus.COMPLETED) : null,
sections.contains(ProfileSection.STATUS) ? byStatus : null,
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,
shared.isEmpty() ? null : userStatsService.getStats(userId, shared));
}
@@ -442,7 +455,8 @@ public class LibraryQueryService {
private static GameDto toDto(UserGame ug, SteamGame meta, GameTimeToBeat time,
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()
&& schema.getAchievementsTotal() > 0 ? schema.getAchievementsTotal() : null;
Integer achievementsUnlocked = achievementsTotal != null && unlocks != null && unlocks.isStatsVisible()
@@ -471,7 +485,8 @@ public class LibraryQueryService {
ug.getHiddenAt() != null,
editions,
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.
@@ -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
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());
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())
.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) {
@@ -301,10 +314,9 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
setSource(GAME_2, GameSource.PSN);
linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent());
games("?source=PSN").andExpect(jsonPath("$.totalElements").value(2));
games("?source=PSN&hideDuplicates=true").andExpect(jsonPath("$.totalElements").value(1));
games("?source=PSN").andExpect(jsonPath("$.totalElements").value(1));
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
@@ -426,7 +438,7 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
void sourceFilter_AfterASplitTheOtherEditionNoLongerMatches() throws Exception {
setSource(GAME_2, GameSource.PSN);
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());
@@ -75,6 +75,7 @@ class LibraryControllerAchievementsTest extends AbstractMockedAdaptersTest {
userAchievementMetaRepository.deleteAll();
jdbc.update("delete from user_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) {
@@ -146,6 +147,19 @@ class LibraryControllerAchievementsTest extends AbstractMockedAdaptersTest {
.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
void summary_TrophiesCount_APlatinumSetIsPerfectAndOneGameCountsOnce() throws Exception {
game(10L, null, null, true);
@@ -53,6 +53,7 @@ class LibraryControllerActivityTest extends AbstractIntegrationTest {
void seed() {
userGameRepository.deleteAll();
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());
steamGameRepository.saveAll(List.of(
new SteamGame(220L, "Half-Life 2", null, Instant.now()),
@@ -90,6 +91,19 @@ class LibraryControllerActivityTest extends AbstractIntegrationTest {
.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
void activity_DefaultsTo30DaysAndClampsTheRange() throws Exception {
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 follow where user_id = ?", USER_ID);
jdbc.update("delete from game_link where set_id = 960001");
gameReleaseRepository.deleteAll();
achievementMetaRepository.deleteAll();
userGameRepository.deleteAll();
@@ -124,6 +125,17 @@ class LibraryControllerPickerFriendsTest extends AbstractMockedLibraryServicesTe
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
void friends_FinishedAndPlayedGames_WithReason() throws Exception {
mine(10L, GameStatus.BACKLOG, null);
@@ -93,6 +93,7 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
@BeforeEach
void seed() {
jdbc.update("delete from game_link where set_id = 980002");
gameReleaseRepository.deleteAll();
userGameRepository.deleteAll();
schemaMetaRepository.deleteAll();
@@ -114,6 +115,17 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
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
void publicLibrary_ShowsStatsPlayingAndFinishedGamesButNoNotesOrPriority() throws Exception {
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"),
new Case("PILE_TIME_TO_BEAT", null, s -> s.at("/pile/gamesWithTimeToBeat").asLong(), "minutes",
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")),
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")),
new Case("LONGEST_DAY", null, null, "minutes", s -> s.at("/habits/longestDay/minutes").asLong()),
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
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);
}
for (long appid : List.of(100L, 200L, -7L, -8L, 300L)) {
@@ -254,6 +254,47 @@ class GameLinkRepositoryTest extends AbstractPersistenceTest {
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
void editionCounts_GivesTwoForBothKeysAndNothingForASingleOwnedKey() {
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.GameStatus;
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.SteamGame;
import core_service.modules.library.domain.UserGame;
@@ -81,7 +82,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
}
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();
}
@@ -210,7 +211,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
@Test
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")));
assertEquals(List.of(220L), page.map(UserGame::getAppid).getContent());
@@ -218,7 +219,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
}
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();
}
@@ -269,7 +270,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
new GameTimeToBeat(620L, 300, 480, 900, 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);
}
@@ -296,7 +297,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
hide(620L, 700L);
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());
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)),
Set.copyOf(userGameRepository.countByStatus(USER_ID)));
assertEquals(List.of(380L), userGameRepository.findBacklogAppids(USER_ID));
assertEquals(List.of(220L), userGameRepository.findListedPlayedInTwoWeeks(USER_ID, PAGE)
.stream().map(UserGame::getAppid).toList());
assertEquals(List.of(220L), userGameRepository.findEntriesPlayedInTwoWeeks(USER_ID, 20)
.stream().map(RecentEntry::getAppid).toList());
assertEquals(List.of(220L), userGameRepository.findListedByStatusIn(USER_ID, List.of(GameStatus.PLAYING), PAGE)
.stream().map(UserGame::getAppid).toList());
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) {
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();
}
@@ -421,19 +422,99 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
}
@Test
void linkedBacklogEditions_CountOnceAndTheOnceFilterKeepsOne() {
void linkedBacklogEditions_CountOnceAndTheListKeepsOne() {
link(380L, 700L);
userGameRepository.flush();
assertEquals(Set.of(new StatusCount(GameStatus.PLAYING, 2), new StatusCount(GameStatus.BACKLOG, 1)),
Set.copyOf(userGameRepository.countByStatus(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,
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,
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());
}
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
void backlogEditionOfAPlayingSet_IsNeverPicked() {
link(220L, 380L);
@@ -374,6 +374,81 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
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) {
jdbc.update("insert into game_release (appid, release_date, fetched_at) values (?, ?, now())", appid,
Date.valueOf(LocalDate.now(BERLIN).plusDays(1)));