Public Access
feat(library): count linked editions once in pile and stats
All game counts read the counted_user_game view, which keeps only the most advanced edition of a linked set (older edition wins a tie). Add the hideDuplicates filter to the library query.
This commit is contained in:
1 parent
e806670d26
commit
221b5594c5
14 files changed
+211
-38
No files matched your search
@@ -53,7 +53,9 @@ To Be Played is split into three repositories:
|
||||
and moods; moon trips (Apollo 11, 195.3 h), longest day, share under one hour; finished games per year (from 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
|
||||
`listed_user_game`, V24).
|
||||
`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.
|
||||
- **Status history**: a database trigger logs every status change of a library game in `user_game_status_change`
|
||||
(old and new status, time, whether the user chose it), whatever code path made it. It starts with V14; older
|
||||
changes are not known.
|
||||
@@ -80,7 +82,7 @@ To Be Played is split into three repositories:
|
||||
- **Time to beat**: story, extras and completionist hours from the IGDB API. Optional: without credentials the
|
||||
feature is off and the rest works.
|
||||
- **Public profile data**: totals, status counts, games in progress and finished games with rating, perfect games
|
||||
and average completion. Readable only
|
||||
and average completion (the numbers count a linked set once; the lists keep every 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
|
||||
@@ -131,7 +133,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
|
||||
| `GET` | `/api/rewards/wallet` (balance, catalog, owned, equipped, last 50 ledger rows with the game name) | JWT |
|
||||
| `POST` | `/api/rewards/purchases` (`{item}`; `200` wallet, `409 ALREADY_OWNED` / `NOT_ENOUGH_POINTS`) | JWT |
|
||||
| `PUT` | `/api/rewards/equipped/{slot}` (`{item}`, null = take off; `200` wallet, `409 NOT_OWNED`, `400 WRONG_SLOT`) | JWT |
|
||||
| `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT |
|
||||
| `GET` | `/api/library/games` (`hideDuplicates=true` leaves out the lesser editions of a linked set), `/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`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR |
|
||||
| `POST` | `/api/library/games/{appid}/merge` (`{otherAppid}`; `200 {appid}` = the key that stays, `400 SAME_GAME` / `STEAM_GAMES`, `404 GAME_NOT_FOUND`) | JWT, AUDITOR |
|
||||
@@ -204,7 +206,7 @@ flowchart LR
|
||||
Igdb -->|"OAuth token"| Twitch["Twitch"]
|
||||
Speedrun --> SpeedrunApi["speedrun.com API"]
|
||||
LibRepo --> Db[("PostgreSQL tbp_core")]
|
||||
Stats -->|"listed_user_game, game_mood, user_game_status_change, deleted_user, blocked_user"| Db
|
||||
Stats -->|"listed_user_game, counted_user_game, game_mood, user_game_status_change, deleted_user, blocked_user"| Db
|
||||
Auth -->|"service token<br/>/internal/accounts"| Account
|
||||
Account -->|"per-user rows, deleted_user, blocked_user, user_seen_at"| Db
|
||||
```
|
||||
@@ -220,12 +222,14 @@ flowchart LR
|
||||
| `speedrun_adapter` | speedrun.com client: game search by name, verified runs |
|
||||
|
||||
**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_game`, `steam_game`, `game_mood`, `user_game_status_change`, `deleted_user` and `blocked_user`; `account`
|
||||
SQL, which ArchUnit cannot see: `stats` reads `listed_user_game`, `counted_user_game`, `steam_game`, `game_mood`, `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`. A module that moves to its own service takes these reads along as calls.
|
||||
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.
|
||||
`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`.
|
||||
|
||||
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:
|
||||
@@ -348,6 +352,9 @@ IGDB id; remakes (8) keep their own id. `null` when IGDB does not know the game.
|
||||
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 are unchanged: `STARTED` and `FINISHED` stay per row,
|
||||
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).
|
||||
|
||||
|
||||
@@ -109,7 +109,8 @@ public class LibraryController {
|
||||
@RequestParam(required = false) GameStatus status,
|
||||
@RequestParam(required = false) String q,
|
||||
@RequestParam(required = false) Integer maxMinutes,
|
||||
@RequestParam(defaultValue = "false") boolean hidden
|
||||
@RequestParam(defaultValue = "false") boolean hidden,
|
||||
@RequestParam(defaultValue = "false") boolean hideDuplicates
|
||||
) {
|
||||
Long userId = principal.getIdAsLong();
|
||||
principal.steamIdText().ifPresent(steamId -> syncService.syncIfStale(userId, steamId));
|
||||
@@ -117,7 +118,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, PageRequest.of(safePage, safeSize, safeSort));
|
||||
var dtoPage = queryService.listOwnedGames(userId, hidden, status, q, maxMinutes != null && maxMinutes > 0 ? maxMinutes : null, hideDuplicates, PageRequest.of(safePage, safeSize, safeSort));
|
||||
return ResponseEntity.ok(PageDto.of(dtoPage));
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,8 @@ public record UserStatsDto(
|
||||
Map<GameStatus, Long> byStatus) {}
|
||||
|
||||
/**
|
||||
* The pile is every game with status BACKLOG. Times to beat only count games IGDB knows.
|
||||
* The pile is every game with status BACKLOG; a linked set counts once and is on the pile only when every owned
|
||||
* edition is BACKLOG. Times to beat only count games IGDB knows.
|
||||
*
|
||||
* @param minutesPerWeek average playtime per week of the last 90 days
|
||||
* @param paceWeeks weeks to play the pile's normal time at that pace; null without playtime in the 90 days
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* The games a user's numbers count: read-only view, see V28.
|
||||
*/
|
||||
@Entity
|
||||
@Immutable
|
||||
@Table(name = "counted_user_game")
|
||||
@IdClass(UserGameId.class)
|
||||
@Getter
|
||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
public class CountedUserGame {
|
||||
|
||||
@Id
|
||||
@Column(name = "user_id")
|
||||
private Long userId;
|
||||
|
||||
@Id
|
||||
@Column(name = "appid")
|
||||
private Long appid;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 16)
|
||||
private GameStatus status;
|
||||
|
||||
@Column(name = "playtime_minutes", nullable = false)
|
||||
private int playtimeMinutes;
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The daily pile size per user, table backlog_snapshot. Counts listed games only, like {@link UserGameRepository#LISTED}.
|
||||
* The daily pile size per user, table backlog_snapshot. Counts listed games only, like {@link UserGameRepository#LISTED}, and a linked set once (V28).
|
||||
*/
|
||||
@Repository
|
||||
@RequiredArgsConstructor
|
||||
@@ -22,7 +22,7 @@ public class BacklogSnapshotRepository {
|
||||
count(*) filter (where ug.playtime_minutes = 0) as never_played,
|
||||
coalesce(sum(t.normally_minutes) filter (where ug.status = 'BACKLOG'), 0) as backlog_minutes,
|
||||
count(*) as listed_games
|
||||
from listed_user_game ug left join game_time_to_beat t on t.appid = ug.appid
|
||||
from counted_user_game ug left join game_time_to_beat t on t.appid = ug.appid
|
||||
""";
|
||||
|
||||
private final NamedParameterJdbcTemplate jdbc;
|
||||
|
||||
@@ -74,11 +74,11 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
||||
@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);
|
||||
|
||||
@Query("select count(ug) from UserGame ug where " + LISTED + "and ug.playtimeMinutes = 0")
|
||||
@Query("select count(c) from CountedUserGame c where c.userId = :userId and c.playtimeMinutes = 0")
|
||||
long countListedNeverPlayed(@Param("userId") Long userId);
|
||||
|
||||
@Query("select new core_service.modules.library.domain.StatusCount(ug.status, count(ug)) from UserGame ug " +
|
||||
"where " + LISTED + "group by ug.status")
|
||||
@Query("select new core_service.modules.library.domain.StatusCount(c.status, count(c)) from CountedUserGame c " +
|
||||
"where c.userId = :userId group by c.status")
|
||||
List<StatusCount> countByStatus(@Param("userId") Long userId);
|
||||
|
||||
/**
|
||||
@@ -104,7 +104,7 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
||||
*
|
||||
* @param excludeAppid never picked; {@link #NO_GAME} excludes nothing
|
||||
*/
|
||||
@Query(value = "select * from listed_user_game where user_id = :userId and status = 'BACKLOG' " +
|
||||
@Query(value = "select * from counted_user_game where user_id = :userId and status = 'BACKLOG' " +
|
||||
"and appid <> :excludeAppid " +
|
||||
// Weighted random pick: the smallest -ln(u) / weight wins.
|
||||
"order by -ln(1 - random()) / case priority when 'HIGH' then 4 when 'MEDIUM' then 2 else 1 end " +
|
||||
@@ -136,7 +136,7 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
||||
return queryPickerGames(userId, maxMinutes, storedMood, speedrun, excluded, count);
|
||||
}
|
||||
|
||||
@Query(value = "select ug.* from listed_user_game ug where ug.user_id = :userId " +
|
||||
@Query(value = "select ug.* from counted_user_game ug where ug.user_id = :userId " +
|
||||
"and ug.status = 'BACKLOG' and ug.appid not in (:excludeAppids) " +
|
||||
"and (cast(:maxMinutes as integer) is null or exists (select 1 from game_time_to_beat t " +
|
||||
" where t.appid = ug.appid and t.normally_minutes <= :maxMinutes)) " +
|
||||
@@ -158,6 +158,7 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
||||
* @param status null for every status
|
||||
* @param namePattern a {@link #containsPattern} result, null for every name
|
||||
* @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
|
||||
*/
|
||||
@Query("select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " +
|
||||
"where ug.userId = :userId and ug.removedAt is null " +
|
||||
@@ -165,20 +166,22 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
||||
"and (:status is null or ug.status = :status) " +
|
||||
"and (:namePattern is null or lower(sg.name) like :namePattern escape '\\') " +
|
||||
"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 " +
|
||||
" where c.userId = ug.userId and c.appid = ug.appid))")
|
||||
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,
|
||||
Pageable pageable);
|
||||
|
||||
/**
|
||||
* Normal time to beat of the user's backlog, only games IGDB has data for.
|
||||
*/
|
||||
@Query("select coalesce(sum(t.normallyMinutes), 0) from UserGame ug join GameTimeToBeat t on t.appid = ug.appid " +
|
||||
"where " + LISTED +
|
||||
"and ug.status = core_service.modules.library.domain.GameStatus.BACKLOG")
|
||||
@Query("select coalesce(sum(t.normallyMinutes), 0) from CountedUserGame c join GameTimeToBeat t on t.appid = c.appid " +
|
||||
"where c.userId = :userId and c.status = core_service.modules.library.domain.GameStatus.BACKLOG")
|
||||
long sumBacklogMinutes(@Param("userId") Long userId);
|
||||
|
||||
/**
|
||||
|
||||
@@ -50,7 +50,7 @@ public class UserStatsRepository {
|
||||
return jdbc.queryForObject("""
|
||||
select coalesce(sum(t.normally_minutes), 0) as normal, coalesce(sum(t.completely_minutes), 0) as complete,
|
||||
count(t.normally_minutes) as known
|
||||
from listed_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'
|
||||
""".formatted(OF_USER), user(userId),
|
||||
(rs, i) -> new UserStats.PileTimes(rs.getLong("normal"), rs.getLong("complete"), rs.getLong("known")));
|
||||
@@ -66,7 +66,7 @@ public class UserStatsRepository {
|
||||
public Optional<UserStats.OldestGame> oldestUnplayed(long userId) {
|
||||
return jdbc.query("""
|
||||
select ug.appid, sg.name, sg.image_url, ug.first_seen_at
|
||||
from listed_user_game ug join steam_game sg on sg.appid = ug.appid
|
||||
from counted_user_game ug join steam_game sg on sg.appid = ug.appid
|
||||
where %s and ug.status = 'BACKLOG' and ug.playtime_minutes = 0
|
||||
order by ug.first_seen_at, ug.appid limit 1
|
||||
""".formatted(OF_USER), user(userId),
|
||||
@@ -148,7 +148,7 @@ public class UserStatsRepository {
|
||||
return jdbc.queryForObject("""
|
||||
select count(*) filter (where ug.playtime_minutes > 0) as started,
|
||||
count(*) filter (where ug.playtime_minutes between 1 and 59) as under_one_hour
|
||||
from listed_user_game ug where %s
|
||||
from counted_user_game ug where %s
|
||||
""".formatted(OF_USER), user(userId),
|
||||
(rs, i) -> new UserStats.PlayedCounts(rs.getLong("started"), rs.getLong("under_one_hour")));
|
||||
}
|
||||
@@ -161,7 +161,7 @@ public class UserStatsRepository {
|
||||
return jdbc.query("""
|
||||
select cast(extract(year from coalesce(ug.finished_at_estimate, f.finished_at) at time zone :zone)
|
||||
as integer) as year, count(*) as games
|
||||
from listed_user_game ug left join (%s) f on f.appid = ug.appid
|
||||
from counted_user_game ug left join (%s) f on f.appid = ug.appid
|
||||
where %s and ug.status in %s and coalesce(ug.finished_at_estimate, f.finished_at) is not null
|
||||
group by year order by year
|
||||
""".formatted(FIRST_FINISH, OF_USER, FINISHED), user(userId).addValue("zone", zone.getId()),
|
||||
@@ -170,7 +170,7 @@ public class UserStatsRepository {
|
||||
|
||||
public long finishedBeforeTracking(long userId) {
|
||||
Long games = jdbc.queryForObject("""
|
||||
select count(*) from listed_user_game ug
|
||||
select count(*) from counted_user_game ug
|
||||
where %s and ug.status in %s and ug.finished_at_estimate is null
|
||||
and not exists (select 1 from user_game_status_change sc
|
||||
where sc.user_id = ug.user_id and sc.appid = ug.appid and %s)
|
||||
@@ -190,7 +190,7 @@ public class UserStatsRepository {
|
||||
or sc.from_status is not null or sc.changed_at > uss.history_since)) as added,
|
||||
count(distinct sc.appid) filter (where sc.from_status = 'BACKLOG' and sc.to_status <> 'BACKLOG')
|
||||
as done
|
||||
from user_game_status_change sc join listed_user_game ug on ug.user_id = sc.user_id and ug.appid = sc.appid
|
||||
from user_game_status_change sc join counted_user_game ug on ug.user_id = sc.user_id and ug.appid = sc.appid
|
||||
left join user_steam_sync uss on uss.user_id = sc.user_id
|
||||
where %s and sc.changed_at >= :monthStart
|
||||
""".formatted(OF_USER), user(userId).addValue("monthStart", Timestamp.from(monthStart)),
|
||||
|
||||
@@ -87,11 +87,11 @@ public class LibraryQueryService {
|
||||
}
|
||||
|
||||
public Page<GameDto> listOwnedGames(Long userId, boolean hidden, GameStatus status, String nameQuery,
|
||||
Integer maxMinutes, Pageable pageable) {
|
||||
Integer maxMinutes, boolean hideDuplicates, 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, stable);
|
||||
userId, hidden, status, UserGameRepository.containsPattern(nameQuery), maxMinutes, hideDuplicates, stable);
|
||||
List<GameDto> games = toDtos(page.getContent());
|
||||
return new PageImpl<>(games, page.getPageable(), page.getTotalElements());
|
||||
}
|
||||
|
||||
@@ -28,13 +28,13 @@ public class StatsRepository {
|
||||
|
||||
public long countLibraryEntries() {
|
||||
Long v = jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED, Long.class);
|
||||
"SELECT COUNT(*) FROM counted_user_game WHERE " + NOT_DELETED_OR_BLOCKED, Long.class);
|
||||
return v != null ? v : 0L;
|
||||
}
|
||||
|
||||
public long countGamesPlayed() {
|
||||
Long v = jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED + " AND playtime_minutes > 0", Long.class);
|
||||
"SELECT COUNT(*) FROM counted_user_game WHERE " + NOT_DELETED_OR_BLOCKED + " AND playtime_minutes > 0", Long.class);
|
||||
return v != null ? v : 0L;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ public class StatsRepository {
|
||||
public long biggestLibrary() {
|
||||
Long v = jdbc.queryForObject("""
|
||||
SELECT COALESCE(MAX(c), 0) FROM (
|
||||
SELECT COUNT(*) AS c FROM listed_user_game
|
||||
SELECT COUNT(*) AS c FROM counted_user_game
|
||||
WHERE %s GROUP BY user_id
|
||||
) t
|
||||
""".formatted(NOT_DELETED_OR_BLOCKED), Long.class);
|
||||
@@ -63,7 +63,7 @@ public class StatsRepository {
|
||||
public long biggestBacklog() {
|
||||
Long v = jdbc.queryForObject("""
|
||||
SELECT COALESCE(MAX(c), 0) FROM (
|
||||
SELECT COUNT(*) AS c FROM listed_user_game
|
||||
SELECT COUNT(*) AS c FROM counted_user_game
|
||||
WHERE %s AND playtime_minutes = 0 GROUP BY user_id
|
||||
) t
|
||||
""".formatted(NOT_DELETED_OR_BLOCKED), Long.class);
|
||||
@@ -131,7 +131,7 @@ public class StatsRepository {
|
||||
return jdbc.queryForList("""
|
||||
SELECT CAST(EXTRACT(YEAR FROM COALESCE(ug.finished_at_estimate, f.finished_at) AT TIME ZONE ?)
|
||||
AS INTEGER) AS year, COUNT(*) AS games
|
||||
FROM listed_user_game ug
|
||||
FROM counted_user_game ug
|
||||
LEFT JOIN (SELECT sc.user_id AS uid, sc.appid, MIN(sc.changed_at) AS finished_at
|
||||
FROM user_game_status_change sc
|
||||
WHERE sc.to_status IN ('FINISHED', 'COMPLETED')
|
||||
|
||||
@@ -174,6 +174,7 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
|
||||
void link_ChangesEveryUsersNumbersAndShowsTheEditions() throws Exception {
|
||||
requestAs(USER_A, GAME, GAME_2).andExpect(status().isNoContent());
|
||||
|
||||
mockMvc.perform(as(get("/api/library/summary"), USER_A, Role.USER)).andExpect(jsonPath("$.total").value(2));
|
||||
linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent());
|
||||
|
||||
queue().andExpect(jsonPath("$.content", hasSize(0)));
|
||||
@@ -185,6 +186,9 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
|
||||
.andExpect(jsonPath("$.editions[0].appid").value(GAME_2));
|
||||
mockMvc.perform(as(get("/api/library/games"), user, Role.USER)).andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.content[*].editions", hasItem(2)));
|
||||
mockMvc.perform(as(get("/api/library/summary"), user, Role.USER)).andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.total").value(1))
|
||||
.andExpect(jsonPath("$.byStatus.BACKLOG").value(1));
|
||||
}
|
||||
requestAs(USER_A, GAME, GAME_2).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("ALREADY_LINKED"));
|
||||
linkAs(Role.AUDITOR, GAME_2, GAME).andExpect(status().isConflict());
|
||||
@@ -267,4 +271,14 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
|
||||
.andExpect(jsonPath("$.content", hasSize(0))).andExpect(jsonPath("$.totalElements").value(1))
|
||||
.andExpect(jsonPath("$.page").value(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void hideDuplicates_ListsOnlyTheEditionTheSetCounts() throws Exception {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,16 @@ class BacklogSnapshotRepositoryTest extends AbstractPersistenceTest {
|
||||
assertThat(repository.findSince(2L, DAY)).containsExactly(new UserStats.PileDay(DAY, 0, 1, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeAll_ALinkedSetCountsOnce() {
|
||||
userGameRepository.saveAllAndFlush(List.of(game(1L, 220L, 0, GameStatus.BACKLOG), game(1L, 620L, 0, GameStatus.BACKLOG)));
|
||||
jdbc.update("insert into game_link (appid, set_id) values (220, 1), (620, 1)");
|
||||
|
||||
repository.writeAll(DAY);
|
||||
|
||||
assertThat(repository.findSince(1L, DAY)).containsExactly(new UserStats.PileDay(DAY, 1, 1, 600));
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeAll_SecondRunOnTheSameDay_ReplacesTheRow() {
|
||||
UserGame game = game(1L, 220L, 0, GameStatus.BACKLOG);
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
@@ -37,6 +38,9 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
||||
@Autowired
|
||||
private UserGameRepository userGameRepository;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
@Autowired
|
||||
private SteamGameRepository steamGameRepository;
|
||||
|
||||
@@ -65,7 +69,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
||||
}
|
||||
|
||||
private List<Long> appids(GameStatus status, String query) {
|
||||
return userGameRepository.findOwnedGames(USER_ID, false, status, UserGameRepository.containsPattern(query), null, PAGE)
|
||||
return userGameRepository.findOwnedGames(USER_ID, false, status, UserGameRepository.containsPattern(query), null, false, PAGE)
|
||||
.map(UserGame::getAppid).getContent();
|
||||
}
|
||||
|
||||
@@ -194,7 +198,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
||||
|
||||
@Test
|
||||
void findOwnedGames_CountsAllMatchesAcrossPages() {
|
||||
var page = userGameRepository.findOwnedGames(USER_ID, false, null, UserGameRepository.containsPattern("half"), null,
|
||||
var page = userGameRepository.findOwnedGames(USER_ID, false, null, UserGameRepository.containsPattern("half"), null, false,
|
||||
PageRequest.of(0, 1, Sort.by("appid")));
|
||||
|
||||
assertEquals(List.of(220L), page.map(UserGame::getAppid).getContent());
|
||||
@@ -202,7 +206,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
||||
}
|
||||
|
||||
private List<Long> sortedAppids(Sort sort) {
|
||||
return userGameRepository.findOwnedGames(USER_ID, false, null, null, null, PageRequest.of(0, 20, UserGameRepository.querySort(sort)))
|
||||
return userGameRepository.findOwnedGames(USER_ID, false, null, null, null, false, PageRequest.of(0, 20, UserGameRepository.querySort(sort)))
|
||||
.map(UserGame::getAppid).getContent();
|
||||
}
|
||||
|
||||
@@ -253,7 +257,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, PAGE).map(UserGame::getAppid).getContent();
|
||||
List<Long> shortGames = userGameRepository.findOwnedGames(USER_ID, false, null, null, 600, false, PAGE).map(UserGame::getAppid).getContent();
|
||||
|
||||
assertEquals(List.of(620L), shortGames);
|
||||
}
|
||||
@@ -280,7 +284,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,
|
||||
assertEquals(List.of(620L, 700L), userGameRepository.findOwnedGames(USER_ID, true, null, null, null, false,
|
||||
PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent());
|
||||
assertEquals(Set.of(620L, 700L), Set.copyOf(userGameRepository.findHiddenAppids(USER_ID)));
|
||||
}
|
||||
@@ -345,6 +349,41 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
||||
Set.copyOf(userGameRepository.countByStatus(USER_ID)));
|
||||
}
|
||||
|
||||
private void link(long... appids) {
|
||||
jdbc.update("delete from game_link");
|
||||
for (long appid : appids) {
|
||||
jdbc.update("insert into game_link (appid, set_id) values (?, 1)", appid);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void linkedBacklogEditions_CountOnceAndTheOnceFilterKeepsOne() {
|
||||
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,
|
||||
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,
|
||||
PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void backlogEditionOfAPlayingSet_IsNeverPicked() {
|
||||
link(220L, 380L);
|
||||
userGameRepository.flush();
|
||||
|
||||
for (int i = 0; i < 20; i++) {
|
||||
assertEquals(700L, userGameRepository.findRandomBacklogGame(USER_ID, UserGameRepository.NO_GAME).orElseThrow().getAppid());
|
||||
}
|
||||
assertEquals(List.of(700L), userGameRepository.findPickerGames(USER_ID, null, null, List.of(), 10)
|
||||
.stream().map(UserGame::getAppid).toList());
|
||||
assertEquals(Set.of(new StatusCount(GameStatus.PLAYING, 2), new StatusCount(GameStatus.BACKLOG, 1)),
|
||||
Set.copyOf(userGameRepository.countByStatus(USER_ID)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void countNeverPlayed_CountsOnlyOwnActiveGamesWithoutPlaytime() {
|
||||
UserGame removed = userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow();
|
||||
|
||||
@@ -94,6 +94,48 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
|
||||
assertThat(repository.pileTimes(USER_ID)).isEqualTo(new UserStats.PileTimes(600, 1200, 1));
|
||||
}
|
||||
|
||||
private void link(long... appids) {
|
||||
for (long appid : appids) {
|
||||
jdbc.update("insert into game_link (appid, set_id) values (?, 1)", appid);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void pileTimes_ALinkedSetCountsOnce() {
|
||||
userGameRepository.saveAllAndFlush(List.of(
|
||||
game(220L, 0, GameStatus.BACKLOG, Instant.now()),
|
||||
game(620L, 0, GameStatus.BACKLOG, Instant.now())));
|
||||
gameTimeToBeatRepository.saveAllAndFlush(List.of(new GameTimeToBeat(220L, 300, 600, 1200, Instant.now()),
|
||||
new GameTimeToBeat(620L, 300, 500, 800, Instant.now())));
|
||||
link(220L, 620L);
|
||||
|
||||
assertThat(repository.pileTimes(USER_ID)).isEqualTo(new UserStats.PileTimes(600, 1200, 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void oldestUnplayed_LeavesOutABacklogEditionOfAPlayedSet() {
|
||||
userGameRepository.saveAllAndFlush(List.of(
|
||||
game(620L, 0, GameStatus.BACKLOG, Instant.EPOCH),
|
||||
game(700L, 60, GameStatus.PLAYING, Instant.now()),
|
||||
game(220L, 0, GameStatus.BACKLOG, Instant.parse("2026-01-01T00:00:00Z"))));
|
||||
link(620L, 700L);
|
||||
|
||||
assertThat(repository.oldestUnplayed(USER_ID).orElseThrow().appid()).isEqualTo(220L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void finishedPerYear_ALinkedSetCountsOnce() {
|
||||
userGameRepository.saveAllAndFlush(List.of(
|
||||
game(220L, 300, GameStatus.BACKLOG, Instant.now()),
|
||||
game(620L, 300, GameStatus.BACKLOG, Instant.now())));
|
||||
setStatus(220L, "FINISHED");
|
||||
setStatus(620L, "FINISHED");
|
||||
link(220L, 620L);
|
||||
|
||||
assertThat(repository.finishedPerYear(USER_ID, BERLIN))
|
||||
.containsExactly(new UserStats.YearCount(LocalDate.now(BERLIN).getYear(), 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void minutesPlayedSince_LeavesOutOlderDaysAndHiddenGames() {
|
||||
userGameRepository.saveAllAndFlush(List.of(game(220L, 300, GameStatus.PLAYING, Instant.now())));
|
||||
|
||||
@@ -66,6 +66,22 @@ class StatsRepositoryTest extends AbstractPersistenceTest {
|
||||
assertEquals(0, statsRepository.biggestBacklog());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aLinkedSet_CountsOnceInLibraryBacklogAndFinishedPerYear() {
|
||||
jdbc.update("insert into game_link (appid, set_id) values (220, 1), (1000, 1)");
|
||||
userGameRepository.saveAllAndFlush(List.of(new UserGame(5L, 220L, 0, Instant.now(), Instant.now()),
|
||||
new UserGame(5L, 1000L, 0, Instant.now(), Instant.now())));
|
||||
|
||||
assertEquals(1, statsRepository.biggestLibrary());
|
||||
assertEquals(1, statsRepository.biggestBacklog());
|
||||
assertEquals(2, statsRepository.countDistinctGames());
|
||||
|
||||
jdbc.update("UPDATE user_game SET status = 'FINISHED' WHERE user_id = 5");
|
||||
|
||||
assertEquals(1L, statsRepository.finishedPerYear("Europe/Berlin").stream()
|
||||
.mapToLong(row -> ((Number) row.get("games")).longValue()).sum());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hiddenGames_LeaveTheTopLists() {
|
||||
assertEquals(List.of(220L), appids(statsRepository.topOwnedGames(5)));
|
||||
|
||||
Reference in new issue
Block a user