From 7b538b7f305ad06720068987e47e3aa66252b967 Mon Sep 17 00:00:00 2001 From: lobo Date: Mon, 5 Oct 2026 20:33:25 +0200 Subject: [PATCH] feat(library): exclude unreleased games from picker and pile --- readme.md | 9 +- .../igdb_adapter/service/IgdbApiService.java | 64 ++++++- .../library/api/dto/GameDetailDto.java | 7 +- .../modules/library/api/dto/GameDto.java | 8 +- .../library/api/dto/LibrarySummaryDto.java | 4 +- .../library/api/dto/PublicLibraryDto.java | 5 +- .../modules/library/api/dto/UserStatsDto.java | 2 +- .../modules/library/domain/ComingGame.java | 30 ++++ .../modules/library/domain/GameRelease.java | 37 +++++ .../modules/library/domain/UserStats.java | 5 +- .../repo/BacklogSnapshotRepository.java | 5 +- .../library/repo/ComingGameRepository.java | 7 + .../library/repo/GameMergeRepository.java | 2 +- .../library/repo/GameReleaseRepository.java | 36 ++++ .../library/repo/UserGameRepository.java | 26 ++- .../library/repo/UserStatsRepository.java | 17 +- .../library/service/GameDetailService.java | 10 +- .../library/service/GameImportWorker.java | 6 +- .../library/service/GameMergeService.java | 6 +- .../library/service/LibraryQueryService.java | 37 ++++- .../library/service/LibrarySyncWorker.java | 2 + .../library/service/ManualGameService.java | 5 +- .../library/service/ReleaseDateService.java | 108 ++++++++++++ .../library/service/UserStatsService.java | 8 +- .../modules/stats/repo/StatsRepository.java | 3 +- .../dto/response/SteamStoreItemsResponse.java | 14 +- .../service/SteamApiService.java | 74 +++++++-- src/main/resources/application.yml | 1 + .../db/migration/V33__Game_Release.sql | 20 +++ .../service/IgdbApiServiceTest.java | 41 +++++ .../library/api/GameImportControllerTest.java | 17 ++ .../library/api/GameMergeFlowTest.java | 3 + .../library/api/GameMergeUndoFlowTest.java | 4 + .../api/LibraryControllerComingTest.java | 102 ++++++++++++ .../api/LibraryControllerPickerTest.java | 23 +++ .../api/LibraryControllerSummaryTest.java | 27 +++ .../library/api/ManualGameControllerTest.java | 16 ++ .../api/PublicProfileControllerTest.java | 24 +++ .../repo/BacklogSnapshotRepositoryTest.java | 34 ++++ .../repo/ComingGameRepositoryTest.java | 157 ++++++++++++++++++ .../library/repo/UserGameRepositoryTest.java | 59 +++++++ .../library/repo/UserStatsRepositoryTest.java | 38 +++++ .../library/service/GameImportWorkerTest.java | 2 +- .../service/LibrarySyncWorkerTest.java | 4 + .../service/ManualGameServiceTest.java | 5 +- .../service/ReleaseDateServiceTest.java | 151 +++++++++++++++++ .../stats/repo/StatsRepositoryTest.java | 12 ++ .../service/SteamApiServiceTest.java | 30 ++++ 48 files changed, 1238 insertions(+), 69 deletions(-) create mode 100644 src/main/java/core_service/modules/library/domain/ComingGame.java create mode 100644 src/main/java/core_service/modules/library/domain/GameRelease.java create mode 100644 src/main/java/core_service/modules/library/repo/ComingGameRepository.java create mode 100644 src/main/java/core_service/modules/library/repo/GameReleaseRepository.java create mode 100644 src/main/java/core_service/modules/library/service/ReleaseDateService.java create mode 100644 src/main/resources/db/migration/V33__Game_Release.sql create mode 100644 src/test/java/core_service/modules/library/api/LibraryControllerComingTest.java create mode 100644 src/test/java/core_service/modules/library/repo/ComingGameRepositoryTest.java create mode 100644 src/test/java/core_service/modules/library/service/ReleaseDateServiceTest.java diff --git a/readme.md b/readme.md index b511af2..51b9687 100644 --- a/readme.md +++ b/readme.md @@ -28,6 +28,11 @@ To Be Played is split into three repositories: Every game carries a `reason` (`source`, plus the anchor game for `SIMILAR` or `firstSeenAt` for `LONGEST`). Games already shown can be excluded for a reroll. Moods `STORY`, `COOP`, `CHILL`, `ACTION`, `SCARY` come from IGDB genres, themes and game modes; `SPEEDRUN` from speedrun.com. +- **Coming games**: a game with a release date after today (Berlin date) or Steam "coming soon", and no playtime or + achievement of any user, is not picked and not on the pile (live count, new snapshots, pile stats, time to beat + sum). It keeps its status and shows `coming` / `comingOn`. Date: IGDB `first_release_date`, else Steam's store + (`GetItems` with `include_release`), else unknown (= released). View `coming_game`, table `game_release`; a + year-only IGDB date stays a placeholder until the real date comes (coming rows refresh daily). ```mermaid flowchart LR @@ -239,7 +244,7 @@ flowchart LR Igdb -->|"OAuth token"| Twitch["Twitch"] Speedrun --> SpeedrunApi["speedrun.com API"] LibRepo --> Db[("PostgreSQL tbp_core")] - Stats -->|"listed_user_game, counted_user_game, game_mood, user_game_status_change, deleted_user, blocked_user"| Db + Stats -->|"listed_user_game, counted_user_game, game_mood, coming_game, user_game_status_change, deleted_user, blocked_user"| Db Auth -->|"service token
/internal/accounts"| Account Account -->|"per-user rows, deleted_user, blocked_user, account_name, user_seen_at"| Db ``` @@ -255,7 +260,7 @@ 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`, `counted_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`, `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`. A module that moves to its own service takes these reads along as calls. diff --git a/src/main/java/core_service/modules/igdb_adapter/service/IgdbApiService.java b/src/main/java/core_service/modules/igdb_adapter/service/IgdbApiService.java index 144769c..ab7868f 100644 --- a/src/main/java/core_service/modules/igdb_adapter/service/IgdbApiService.java +++ b/src/main/java/core_service/modules/igdb_adapter/service/IgdbApiService.java @@ -14,6 +14,7 @@ import org.springframework.web.client.RestClient; import java.time.Duration; import java.time.Instant; +import java.time.LocalDate; import java.time.ZoneOffset; import java.util.ArrayList; import java.util.Arrays; @@ -251,8 +252,8 @@ public class IgdbApiService { Map> result = new HashMap<>(); for (int from = 0; from < appids.size(); from += BATCH) { Map> steamByIgdb = steamAppidsByIgdbGame(appids.subList(from, Math.min(from + BATCH, appids.size()))); - yearsOf(steamByIgdb.keySet()).forEach((igdbId, year) -> steamByIgdb.get(igdbId) - .forEach(appid -> result.computeIfAbsent(appid, key -> new TreeSet<>()).add(year))); + datesOf(steamByIgdb.keySet()).forEach((igdbId, date) -> steamByIgdb.get(igdbId) + .forEach(appid -> result.computeIfAbsent(appid, key -> new TreeSet<>()).add(date.getYear()))); } return Optional.of(result); } catch (Exception e) { @@ -272,8 +273,8 @@ public class IgdbApiService { List ids = List.copyOf(igdbIds); Map> result = new HashMap<>(); for (int from = 0; from < ids.size(); from += BATCH) { - yearsOf(ids.subList(from, Math.min(from + BATCH, ids.size()))) - .forEach((igdbId, year) -> result.put(igdbId, Set.of(year))); + datesOf(ids.subList(from, Math.min(from + BATCH, ids.size()))) + .forEach((igdbId, date) -> result.put(igdbId, Set.of(date.getYear()))); } return Optional.of(result); } catch (Exception e) { @@ -282,6 +283,49 @@ public class IgdbApiService { } } + /** + * The first release date per Steam appid (UTC); the earliest when an appid belongs to several IGDB games. An app + * IGDB has no date for is missing. Empty when the adapter is off or IGDB failed. + */ + public Optional> getReleaseDates(Collection steamAppids) { + if (!isEnabled() || steamAppids.isEmpty()) { + return Optional.empty(); + } + try { + List appids = List.copyOf(steamAppids); + Map result = new HashMap<>(); + for (int from = 0; from < appids.size(); from += BATCH) { + Map> steamByIgdb = steamAppidsByIgdbGame(appids.subList(from, Math.min(from + BATCH, appids.size()))); + datesOf(steamByIgdb.keySet()).forEach((igdbId, date) -> steamByIgdb.get(igdbId) + .forEach(appid -> result.merge(appid, date, (a, b) -> a.isBefore(b) ? a : b))); + } + return Optional.of(result); + } catch (Exception e) { + log.error("Error communicating with IGDB for release dates", e); + return Optional.empty(); + } + } + + /** + * Like {@link #getReleaseDates}, but for IGDB game ids. + */ + public Optional> getReleaseDatesByGameIds(Collection igdbIds) { + if (!isEnabled() || igdbIds.isEmpty()) { + return Optional.empty(); + } + try { + List ids = List.copyOf(igdbIds); + Map result = new HashMap<>(); + for (int from = 0; from < ids.size(); from += BATCH) { + result.putAll(datesOf(ids.subList(from, Math.min(from + BATCH, ids.size())))); + } + return Optional.of(result); + } catch (Exception e) { + log.error("Error communicating with IGDB for release dates", e); + return Optional.empty(); + } + } + /** * Games whose name matches, without editions and other versions. Empty when the adapter is off or IGDB failed. */ @@ -443,7 +487,7 @@ public class IgdbApiService { return result; } - private Map yearsOf(Collection igdbIds) { + private Map datesOf(Collection igdbIds) { if (igdbIds.isEmpty()) { return Map.of(); } @@ -451,17 +495,21 @@ public class IgdbApiService { IgdbResponses.Game[] games = query("/games", "fields first_release_date; where id = (" + gameIds + "); limit " + LIMIT + ";", IgdbResponses.Game[].class); - Map result = new HashMap<>(); + Map result = new HashMap<>(); for (IgdbResponses.Game game : games) { if (game.id() != null && game.firstReleaseDate() != null && igdbIds.contains(game.id())) { - result.put(game.id(), year(game.firstReleaseDate())); + result.put(game.id(), date(game.firstReleaseDate())); } } return result; } private static int year(long unixSeconds) { - return Instant.ofEpochSecond(unixSeconds).atZone(ZoneOffset.UTC).getYear(); + return date(unixSeconds).getYear(); + } + + private static LocalDate date(long unixSeconds) { + return Instant.ofEpochSecond(unixSeconds).atZone(ZoneOffset.UTC).toLocalDate(); } private static List names(List named) { diff --git a/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java b/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java index b01fdac..3543eec 100644 --- a/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java +++ b/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java @@ -5,6 +5,7 @@ import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.GameStatus; import java.time.Instant; +import java.time.LocalDate; import java.util.List; public record GameDetailDto( @@ -47,7 +48,11 @@ public record GameDetailDto( // an auditor linked the game with others; for every user boolean linked, // the user's other games of that set, empty when none - List editions + List editions, + // see GameDto#coming + boolean coming, + // see GameDto#comingOn + LocalDate comingOn ) { /** diff --git a/src/main/java/core_service/modules/library/api/dto/GameDto.java b/src/main/java/core_service/modules/library/api/dto/GameDto.java index 630cda9..3905312 100644 --- a/src/main/java/core_service/modules/library/api/dto/GameDto.java +++ b/src/main/java/core_service/modules/library/api/dto/GameDto.java @@ -4,6 +4,8 @@ import core_service.modules.library.domain.GamePriority; import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.GameStatus; +import java.time.LocalDate; + /** * @param timeToBeatMinutes IGDB's normal time to beat, null when unknown * @param achievementsTotal null when the game has no achievements or they were not fetched yet @@ -12,6 +14,8 @@ import core_service.modules.library.domain.GameStatus; * @param imageUrl a game added by hand from IGDB, or a repaired Steam header; null means the fixed Steam CDN header * @param hidden the user hid the game; it shows only under the library's hidden filter * @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" */ public record GameDto( Long appid, @@ -28,5 +32,7 @@ public record GameDto( String platform, String imageUrl, boolean hidden, - Integer editions + Integer editions, + boolean coming, + LocalDate comingOn ) {} diff --git a/src/main/java/core_service/modules/library/api/dto/LibrarySummaryDto.java b/src/main/java/core_service/modules/library/api/dto/LibrarySummaryDto.java index 07b2ed3..2af4260 100644 --- a/src/main/java/core_service/modules/library/api/dto/LibrarySummaryDto.java +++ b/src/main/java/core_service/modules/library/api/dto/LibrarySummaryDto.java @@ -7,6 +7,7 @@ import java.util.Map; /** * @param byStatus has every status, also those with 0 games * @param suggestion a random backlog game, null when the backlog is empty + * @param pileGames the BACKLOG games without the ones that are not released yet * @param backlogMinutes normal time to beat of the backlog games IGDB knows; 0 when none or IGDB is off */ public record LibrarySummaryDto( @@ -14,5 +15,6 @@ public record LibrarySummaryDto( Map byStatus, long neverPlayed, GameDto suggestion, - long backlogMinutes + long backlogMinutes, + long pileGames ) {} diff --git a/src/main/java/core_service/modules/library/api/dto/PublicLibraryDto.java b/src/main/java/core_service/modules/library/api/dto/PublicLibraryDto.java index 339b874..66830be 100644 --- a/src/main/java/core_service/modules/library/api/dto/PublicLibraryDto.java +++ b/src/main/java/core_service/modules/library/api/dto/PublicLibraryDto.java @@ -2,6 +2,7 @@ package core_service.modules.library.api.dto; import core_service.modules.library.domain.GameStatus; +import java.time.LocalDate; import java.util.List; import java.util.Map; @@ -31,7 +32,9 @@ public record PublicLibraryDto( /** * @param platform see {@link GameDto#platform()} * @param imageUrl see {@link GameDto#imageUrl()} + * @param coming see {@link GameDto#coming()} + * @param comingOn see {@link GameDto#comingOn()} */ public record Game(Long appid, String name, int playtimeMinutes, GameStatus status, Integer rating, - String platform, String imageUrl) {} + String platform, String imageUrl, boolean coming, LocalDate comingOn) {} } diff --git a/src/main/java/core_service/modules/library/api/dto/UserStatsDto.java b/src/main/java/core_service/modules/library/api/dto/UserStatsDto.java index 1b33f61..17cc9e9 100644 --- a/src/main/java/core_service/modules/library/api/dto/UserStatsDto.java +++ b/src/main/java/core_service/modules/library/api/dto/UserStatsDto.java @@ -10,7 +10,7 @@ import java.util.Map; /** * One field per {@link core_service.modules.library.domain.StatGroup}; a group that was not asked for is null. * Every number counts only listed games (not removed, not hidden). The playtime day sums (pace, longest day) also - * count removed games: those days were played. + * count removed games: those days were played. The pile does not count games that are not released yet. */ public record UserStatsDto( Overview overview, diff --git a/src/main/java/core_service/modules/library/domain/ComingGame.java b/src/main/java/core_service/modules/library/domain/ComingGame.java new file mode 100644 index 0000000..69256b6 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/ComingGame.java @@ -0,0 +1,30 @@ +package core_service.modules.library.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Immutable; + +import java.time.LocalDate; + +/** + * A game that is not released yet and nobody played: read-only view, see V33. The date is null for "coming soon". + */ +@Entity +@Immutable +@Table(name = "coming_game") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ComingGame { + + @Id + @Column(name = "appid") + private Long appid; + + @Column(name = "release_date") + private LocalDate releaseDate; +} diff --git a/src/main/java/core_service/modules/library/domain/GameRelease.java b/src/main/java/core_service/modules/library/domain/GameRelease.java new file mode 100644 index 0000000..19df669 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/GameRelease.java @@ -0,0 +1,37 @@ +package core_service.modules.library.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.time.LocalDate; + +/** + * The release date of one library key; the date is null when no source knows it, see V33. + */ +@Entity +@Table(name = "game_release") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class GameRelease { + + @Id + @Column(name = "appid") + private Long appid; + + @Column(name = "release_date") + private LocalDate releaseDate; + + @Column(name = "coming_soon", nullable = false) + private boolean comingSoon; + + @Column(name = "fetched_at", nullable = false) + private Instant fetchedAt; +} diff --git a/src/main/java/core_service/modules/library/domain/UserStats.java b/src/main/java/core_service/modules/library/domain/UserStats.java index 8dd7af1..8baf966 100644 --- a/src/main/java/core_service/modules/library/domain/UserStats.java +++ b/src/main/java/core_service/modules/library/domain/UserStats.java @@ -46,7 +46,10 @@ public final class UserStats { * @param at the date the stat is about (finished, moved); null when it has none * @param fromStatus only for the pile moves; null for a game that came in new * @param toStatus only for the pile moves + * @param coming the game is not released yet and nobody played it + * @param comingOn the release date of a coming game; null for released games and for "coming soon" */ public record StatGame(long appid, String name, String imageUrl, GameStatus status, long playtimeMinutes, - Long minutes, Instant at, GameStatus fromStatus, GameStatus toStatus) {} + Long minutes, Instant at, GameStatus fromStatus, GameStatus toStatus, + boolean coming, LocalDate comingOn) {} } diff --git a/src/main/java/core_service/modules/library/repo/BacklogSnapshotRepository.java b/src/main/java/core_service/modules/library/repo/BacklogSnapshotRepository.java index 15601ef..1770c5d 100644 --- a/src/main/java/core_service/modules/library/repo/BacklogSnapshotRepository.java +++ b/src/main/java/core_service/modules/library/repo/BacklogSnapshotRepository.java @@ -18,11 +18,12 @@ import java.util.List; public class BacklogSnapshotRepository { private static final String PILE_COLUMNS = """ - count(*) filter (where ug.status = 'BACKLOG') as backlog_games, + count(*) filter (where ug.status = 'BACKLOG' and cg.appid is null) as backlog_games, count(*) filter (where ug.playtime_minutes = 0) as never_played, - coalesce(sum(t.normally_minutes) filter (where ug.status = 'BACKLOG'), 0) as backlog_minutes, + coalesce(sum(t.normally_minutes) filter (where ug.status = 'BACKLOG' and cg.appid is null), 0) as backlog_minutes, count(*) as listed_games from counted_user_game ug left join game_time_to_beat t on t.appid = ug.appid + left join coming_game cg on cg.appid = ug.appid """; private final NamedParameterJdbcTemplate jdbc; diff --git a/src/main/java/core_service/modules/library/repo/ComingGameRepository.java b/src/main/java/core_service/modules/library/repo/ComingGameRepository.java new file mode 100644 index 0000000..996cbeb --- /dev/null +++ b/src/main/java/core_service/modules/library/repo/ComingGameRepository.java @@ -0,0 +1,7 @@ +package core_service.modules.library.repo; + +import core_service.modules.library.domain.ComingGame; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ComingGameRepository extends JpaRepository { +} diff --git a/src/main/java/core_service/modules/library/repo/GameMergeRepository.java b/src/main/java/core_service/modules/library/repo/GameMergeRepository.java index 1d865e1..ab61a3d 100644 --- a/src/main/java/core_service/modules/library/repo/GameMergeRepository.java +++ b/src/main/java/core_service/modules/library/repo/GameMergeRepository.java @@ -28,7 +28,7 @@ public class GameMergeRepository { // The shared rows of a game; steam_game last, since user_game refers to it. private static final List CATALOG_TABLES = List.of("game_mood", "game_speedrun", "game_time_to_beat", - "game_correction", "broken_image", "game_edition", "steam_game"); + "game_correction", "broken_image", "game_edition", "game_release", "steam_game"); // The paid game kinds; a merge moves their ref, an undo moves it back. private static final String GAME_KINDS = "('STARTED', 'FINISHED', 'FINISHED_MANUAL', 'COMPLETED')"; diff --git a/src/main/java/core_service/modules/library/repo/GameReleaseRepository.java b/src/main/java/core_service/modules/library/repo/GameReleaseRepository.java new file mode 100644 index 0000000..4773c22 --- /dev/null +++ b/src/main/java/core_service/modules/library/repo/GameReleaseRepository.java @@ -0,0 +1,36 @@ +package core_service.modules.library.repo; + +import core_service.modules.library.domain.GameRelease; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.Collection; +import java.util.Set; + +public interface GameReleaseRepository extends JpaRepository { + + /** + * A released or unknown game is fresh for {@code since}; a coming one (future date or coming soon) only until + * {@code comingSince}, so a real date replaces a placeholder within a day. + */ + @Query("select r.appid from GameRelease r where r.appid in :appids and r.fetchedAt > :since " + + "and ((r.releaseDate is null and r.comingSoon = false) or r.releaseDate < :today or r.fetchedAt > :comingSince)") + Set findFreshAppids(@Param("appids") Collection appids, @Param("since") Instant since, + @Param("today") LocalDate today, @Param("comingSince") Instant comingSince); + + // An upsert, not save(): two users' syncs can store the same new game at the same time. + @Modifying + @Transactional + @Query(value = "insert into game_release (appid, release_date, coming_soon, fetched_at) " + + "values (:appid, :releaseDate, :comingSoon, :fetchedAt) " + + "on conflict (appid) do update set release_date = excluded.release_date, " + + "coming_soon = excluded.coming_soon, fetched_at = excluded.fetched_at", + nativeQuery = true) + void upsert(@Param("appid") Long appid, @Param("releaseDate") LocalDate releaseDate, + @Param("comingSoon") boolean comingSoon, @Param("fetchedAt") Instant fetchedAt); +} diff --git a/src/main/java/core_service/modules/library/repo/UserGameRepository.java b/src/main/java/core_service/modules/library/repo/UserGameRepository.java index b0ccd6d..b345e19 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java @@ -104,10 +104,10 @@ public interface UserGameRepository extends JpaRepository * * @param excludeAppid never picked; {@link #NO_GAME} excludes nothing */ - @Query(value = "select * from counted_user_game where user_id = :userId and status = 'BACKLOG' " + - "and appid <> :excludeAppid " + + @Query(value = "select * from counted_user_game ug where ug.user_id = :userId and ug.status = 'BACKLOG' " + + "and ug.appid <> :excludeAppid and " + NOT_COMING + // 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 " + + "order by -ln(1 - random()) / case ug.priority when 'HIGH' then 4 when 'MEDIUM' then 2 else 1 end " + "limit 1", nativeQuery = true) Optional findRandomBacklogGame(@Param("userId") Long userId, @Param("excludeAppid") long excludeAppid); @@ -120,6 +120,12 @@ public interface UserGameRepository extends JpaRepository "and ug.source = core_service.modules.library.domain.GameSource.STEAM") List findAchievementProgress(@Param("userId") Long userId); + /** + * Leaves out the games that are not released yet, see V33. Needs a native query and the alias {@code ug} on a + * table with an {@code appid} column. + */ + String NOT_COMING = "not exists (select 1 from coming_game cg where cg.appid = ug.appid) "; + /** * The WHERE tail every picker query shares. Needs the alias {@code ug} and the parameters {@code :userId}, * {@code :excludeAppids}, {@code :maxMinutes}, {@code :mood} and {@code :speedrun}. @@ -131,7 +137,8 @@ public interface UserGameRepository extends JpaRepository "and (cast(:mood as varchar) is null or exists (select 1 from game_mood m " + " where m.appid = ug.appid and cast(:mood as varchar) = any(m.moods))) " + "and (:speedrun = false or exists (select 1 from game_speedrun s " + - " where s.appid = ug.appid and s.weblink is not null)) "; + " where s.appid = ug.appid and s.weblink is not null)) " + + "and " + NOT_COMING; String PRIORITY_ORDER = "case ug.priority when 'HIGH' then 0 when 'MEDIUM' then 1 else 2 end"; @@ -249,9 +256,18 @@ public interface UserGameRepository extends JpaRepository * Normal time to beat of the user's backlog, only games IGDB has data for. */ @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") + "where c.userId = :userId and c.status = core_service.modules.library.domain.GameStatus.BACKLOG " + + "and not exists (select 1 from ComingGame cg where cg.appid = c.appid)") long sumBacklogMinutes(@Param("userId") Long userId); + /** + * The backlog games of the pile: the BACKLOG status without the games that are not released yet. + */ + @Query("select count(c) from CountedUserGame c where c.userId = :userId " + + "and c.status = core_service.modules.library.domain.GameStatus.BACKLOG " + + "and not exists (select 1 from ComingGame cg where cg.appid = c.appid)") + long countPile(@Param("userId") Long userId); + /** * Makes a {@link #findOwnedGames} sort stable and meaningful: games without a rating or priority come last * in both directions, priority sorts by HIGH, MEDIUM, LOW (the enum names would sort alphabetically), and diff --git a/src/main/java/core_service/modules/library/repo/UserStatsRepository.java b/src/main/java/core_service/modules/library/repo/UserStatsRepository.java index 0f40486..5611303 100644 --- a/src/main/java/core_service/modules/library/repo/UserStatsRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserStatsRepository.java @@ -53,8 +53,8 @@ public class UserStatsRepository { private static final String PILE_TTB_FROM = """ 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); + where %s and ug.status = 'BACKLOG' and %s + """.formatted(OF_USER, UserGameRepository.NOT_COMING); 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 @@ -108,7 +108,7 @@ public class UserStatsRepository { Map.entry(StatKey.STATUS, counted("ug.status = :arg")), Map.entry(StatKey.PLAYED, counted("ug.playtime_minutes > 0")), Map.entry(StatKey.NEVER_STARTED, counted("ug.playtime_minutes = 0")), - Map.entry(StatKey.PILE, counted("ug.status = 'BACKLOG'")), + Map.entry(StatKey.PILE, counted("ug.status = 'BACKLOG' and " + UserGameRepository.NOT_COMING)), Map.entry(StatKey.FINISHED_ALL, counted("ug.status in " + FINISHED)), Map.entry(StatKey.UNDER_ONE_HOUR, counted(UNDER_ONE_HOUR)), Map.entry(StatKey.FINISHED_UNTRACKED, counted(UNTRACKED)), @@ -157,9 +157,9 @@ public class UserStatsRepository { return jdbc.query(""" select ug.appid, sg.name, sg.image_url, ug.first_seen_at 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 + where %s and ug.status = 'BACKLOG' and ug.playtime_minutes = 0 and %s order by ug.first_seen_at, ug.appid limit 1 - """.formatted(OF_USER), user(userId), + """.formatted(OF_USER, UserGameRepository.NOT_COMING), user(userId), (rs, i) -> new UserStats.OldestGame(rs.getLong("appid"), rs.getString("name"), rs.getString("image_url"), rs.getTimestamp("first_seen_at").toInstant())) .stream().findFirst(); @@ -286,8 +286,10 @@ public class UserStatsRepository { public List games(long userId, StatKey key, Object arg, ZoneId zone, int offset, int limit) { return jdbc.query(""" select s.appid, sg.name, sg.image_url, ug.status, coalesce(ug.playtime_minutes, 0) as playtime, - s.minutes, s.event_at, s.from_status, s.to_status + s.minutes, s.event_at, s.from_status, s.to_status, + cg.appid is not null as coming, cg.release_date as coming_on from (%s) s left join steam_game sg on sg.appid = s.appid + left join coming_game cg on cg.appid = s.appid left join user_game ug on ug.user_id = :userId and ug.appid = s.appid order by %s, s.appid limit :limit offset :offset """.formatted(SETS.get(key), ORDERS.getOrDefault(key, BY_PLAYTIME)), @@ -295,7 +297,8 @@ public class UserStatsRepository { (rs, i) -> new UserStats.StatGame(rs.getLong("appid"), rs.getString("name"), rs.getString("image_url"), status(rs.getString("status")), rs.getLong("playtime"), minutes(rs.getObject("minutes")), instant(rs.getTimestamp("event_at")), status(rs.getString("from_status")), - status(rs.getString("to_status")))); + status(rs.getString("to_status")), rs.getBoolean("coming"), + rs.getObject("coming_on", LocalDate.class))); } public long countGames(long userId, StatKey key, Object arg, ZoneId zone) { diff --git a/src/main/java/core_service/modules/library/service/GameDetailService.java b/src/main/java/core_service/modules/library/service/GameDetailService.java index c3d352d..84efcb2 100644 --- a/src/main/java/core_service/modules/library/service/GameDetailService.java +++ b/src/main/java/core_service/modules/library/service/GameDetailService.java @@ -6,6 +6,7 @@ import core_service.modules.library.api.dto.EditionDto; import core_service.modules.library.api.dto.FinishedHintDto; import core_service.modules.library.api.dto.GameDetailDto; import core_service.modules.library.domain.AchievementProgress; +import core_service.modules.library.domain.ComingGame; import core_service.modules.library.domain.GameAchievementSchema; import core_service.modules.library.domain.GameAchievementSchemaMeta; import core_service.modules.library.domain.FinishedHint; @@ -18,6 +19,7 @@ import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGameAchievement; import core_service.modules.library.domain.UserGameAchievementMeta; import core_service.modules.library.domain.UserGameId; +import core_service.modules.library.repo.ComingGameRepository; import core_service.modules.library.repo.GameAchievementSchemaMetaRepository; import core_service.modules.library.repo.GameCorrectionRepository; import core_service.modules.library.repo.GameLinkRepository; @@ -59,8 +61,10 @@ public class GameDetailService { private final UserGameAchievementMetaRepository userAchievementMetaRepository; private final AchievementSyncService achievementSyncService; private final GameTimeToBeatRepository gameTimeToBeatRepository; + private final ComingGameRepository comingGameRepository; private final TimeToBeatService timeToBeatService; private final MoodService moodService; + private final ReleaseDateService releaseDateService; private final GameEditionService gameEditionService; private final GameSpeedrunRepository gameSpeedrunRepository; private final SpeedrunService speedrunService; @@ -117,6 +121,7 @@ public class GameDetailService { // Users without a Steam ID never sync, so the time to beat of their games is asked again here. timeToBeatService.refreshInBackground(List.of(appid)); moodService.refreshInBackground(List.of(appid)); + releaseDateService.refreshInBackground(List.of(appid)); return new Refreshed(userGame, false); } @@ -198,6 +203,7 @@ public class GameDetailService { userGame.getFinishedHintDismissed())) .map(hint -> new FinishedHintDto(hint.type(), hint.achievement())) .orElse(null); + Optional coming = comingGameRepository.findById(userGame.getAppid()); boolean hasAchievements = total > 0; int remaining = Math.max(0, total - unlocked); double pct = total == 0 ? 0.0 : (unlocked * 100.0 / total); @@ -237,7 +243,9 @@ public class GameDetailService { gameLinkRepository.setOf(userGame.getAppid()).isPresent(), gameLinkRepository.linkedEditions(userGame.getUserId(), userGame.getAppid()).stream() .map(e -> new EditionDto(e.appid(), e.name(), e.platform(), e.source(), e.status(), e.imageUrl())) - .toList() + .toList(), + coming.isPresent(), + coming.map(ComingGame::getReleaseDate).orElse(null) ); } } diff --git a/src/main/java/core_service/modules/library/service/GameImportWorker.java b/src/main/java/core_service/modules/library/service/GameImportWorker.java index 4645073..996a49d 100644 --- a/src/main/java/core_service/modules/library/service/GameImportWorker.java +++ b/src/main/java/core_service/modules/library/service/GameImportWorker.java @@ -52,6 +52,7 @@ public class GameImportWorker { private final ManualGameService manualGameService; private final TimeToBeatService timeToBeatService; private final MoodService moodService; + private final ReleaseDateService releaseDateService; private final Duration stallTime; // Only a try that started before this app is one a restart stopped. private final Instant startedAt = Instant.now(); @@ -60,11 +61,13 @@ public class GameImportWorker { public GameImportWorker(GameImportRepository importRepository, ManualGameService manualGameService, TimeToBeatService timeToBeatService, MoodService moodService, + ReleaseDateService releaseDateService, @Value("${app.library.import-stall-minutes:5}") long stallMinutes) { this.importRepository = importRepository; this.manualGameService = manualGameService; this.timeToBeatService = timeToBeatService; this.moodService = moodService; + this.releaseDateService = releaseDateService; this.stallTime = Duration.ofMinutes(stallMinutes); this.matching = new Lane("game-import-match", MATCHING_QUEUE, this::match); this.saving = new Lane("game-import-save", SAVING_QUEUE, this::save); @@ -159,9 +162,10 @@ public class GameImportWorker { try { timeToBeatService.refresh(appids); moodService.refresh(appids); + releaseDateService.refresh(appids); } catch (RuntimeException e) { // The next sync or page view asks again. - log.warn("Time to beat or moods of import {} not loaded: {}", importId, e.getMessage()); + log.warn("Time to beat, moods or release dates of import {} not loaded: {}", importId, e.getMessage()); } if (Thread.currentThread().isInterrupted()) { return; diff --git a/src/main/java/core_service/modules/library/service/GameMergeService.java b/src/main/java/core_service/modules/library/service/GameMergeService.java index d71c086..4990910 100644 --- a/src/main/java/core_service/modules/library/service/GameMergeService.java +++ b/src/main/java/core_service/modules/library/service/GameMergeService.java @@ -68,15 +68,18 @@ public class GameMergeService { private final AdminAuditRepository adminAuditRepository; private final TimeToBeatService timeToBeatService; private final MoodService moodService; + private final ReleaseDateService releaseDateService; private final TransactionTemplate transaction; public GameMergeService(GameMergeRepository mergeRepository, AdminAuditRepository adminAuditRepository, TimeToBeatService timeToBeatService, - MoodService moodService, PlatformTransactionManager transactionManager) { + MoodService moodService, ReleaseDateService releaseDateService, + PlatformTransactionManager transactionManager) { this.mergeRepository = mergeRepository; this.adminAuditRepository = adminAuditRepository; this.timeToBeatService = timeToBeatService; this.moodService = moodService; + this.releaseDateService = releaseDateService; this.transaction = new TransactionTemplate(transactionManager); } @@ -110,6 +113,7 @@ public class GameMergeService { // The target may have had no moods or time to beat yet; both skip what is still fresh. timeToBeatService.refreshInBackground(List.of(to)); moodService.refreshInBackground(List.of(to)); + releaseDateService.refreshInBackground(List.of(to)); } return result; } diff --git a/src/main/java/core_service/modules/library/service/LibraryQueryService.java b/src/main/java/core_service/modules/library/service/LibraryQueryService.java index 50bd428..d156d22 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -11,6 +11,7 @@ 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.GameAchievementSchemaMeta; import core_service.modules.library.domain.GameSource; @@ -25,6 +26,7 @@ import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGameAchievementMeta; import core_service.modules.library.domain.UserSteamSync; +import core_service.modules.library.repo.ComingGameRepository; import core_service.modules.library.repo.GameAchievementSchemaMetaRepository; import core_service.modules.library.repo.GameLinkRepository; import core_service.modules.library.repo.GameTimeToBeatRepository; @@ -79,10 +81,12 @@ public class LibraryQueryService { private final PlaytimeDayRepository playtimeDayRepository; private final WishlistGameRepository wishlistGameRepository; private final GameTimeToBeatRepository gameTimeToBeatRepository; + private final ComingGameRepository comingGameRepository; private final GameAchievementSchemaMetaRepository schemaMetaRepository; private final UserGameAchievementMetaRepository userAchievementMetaRepository; private final UserStatsService userStatsService; private final MoodService moodService; + private final ReleaseDateService releaseDateService; private final SpeedrunService speedrunService; private final GameLinkRepository gameLinkRepository; @@ -109,6 +113,7 @@ public class LibraryQueryService { * With a mood, also refreshes the backlog's moods or speedrun links in the background for the next pick. */ public PickerDto pickBacklogGames(Long userId, Integer maxMinutes, Mood mood, List excludeAppids, int count) { + releaseDateService.refreshBacklogInBackground(userId); if (mood == Mood.SPEEDRUN) { speedrunService.refreshBacklogInBackground(userId); } else if (mood != null) { @@ -168,12 +173,18 @@ public class LibraryQueryService { .findByUserIdAndAppidIn(games.getFirst().getUserId(), appids).stream() .collect(Collectors.toMap(UserGameAchievementMeta::getAppid, m -> m)); Map editions = gameLinkRepository.editionCounts(games.getFirst().getUserId(), appids); + Map coming = comingOf(appids); 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()))) + schemas.get(ug.getAppid()), unlocks.get(ug.getAppid()), editions.get(ug.getAppid()), + coming.get(ug.getAppid()))) .toList(); } + private Map comingOf(Collection appids) { + return comingGameRepository.findAllById(appids).stream().collect(Collectors.toMap(ComingGame::getAppid, c -> c)); + } + public AchievementSummaryDto getAchievementSummary(Long userId) { List games = userGameRepository.findAchievementProgress(userId); List started = AchievementProgress.started(games); @@ -218,7 +229,8 @@ public class LibraryQueryService { GameDto suggestion = userGameRepository.findRandomBacklogGame(userId, excludeAppid != null ? excludeAppid : UserGameRepository.NO_GAME) .map(ug -> toDtos(List.of(ug)).getFirst()) .orElse(null); - return new LibrarySummaryDto(total, byStatus, neverPlayed, suggestion, userGameRepository.sumBacklogMinutes(userId)); + return new LibrarySummaryDto(total, byStatus, neverPlayed, suggestion, userGameRepository.sumBacklogMinutes(userId), + userGameRepository.countPile(userId)); } /** @@ -267,9 +279,11 @@ public class LibraryQueryService { ? userGameRepository.findListedByStatusIn(userId, List.of(GameStatus.FINISHED, GameStatus.COMPLETED), PageRequest.of(0, PUBLIC_FINISHED, Sort.by(Sort.Order.desc("rating").nullsLast()).and(Sort.by("appid")))) : null; - Map meta = steamGameRepository.findAllById(Stream.of(playing, finished) - .filter(Objects::nonNull).flatMap(List::stream).map(UserGame::getAppid).toList()) + List shownAppids = Stream.of(playing, finished) + .filter(Objects::nonNull).flatMap(List::stream).map(UserGame::getAppid).toList(); + Map meta = steamGameRepository.findAllById(shownAppids) .stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g)); + Map coming = comingOf(shownAppids); List started = summary ? AchievementProgress.started(userGameRepository.findAchievementProgress(userId)) : null; Set shared = sections.contains(ProfileSection.STATS) ? StatGroup.shared(profileStats) : Set.of(); return new PublicLibraryDto( @@ -277,7 +291,7 @@ public class LibraryQueryService { summary ? userGameRepository.sumPlaytime(userId) : null, summary ? byStatus.get(GameStatus.FINISHED) + byStatus.get(GameStatus.COMPLETED) : null, sections.contains(ProfileSection.STATUS) ? byStatus : null, - toPublicGames(playing, meta), toPublicGames(finished, meta), + toPublicGames(playing, meta, coming), toPublicGames(finished, meta, coming), summary ? AchievementProgress.perfectAppids(started).size() : null, summary ? averageCompletion(started) : null, shared.isEmpty() ? null : userStatsService.getStats(userId, shared)); @@ -286,7 +300,8 @@ public class LibraryQueryService { /** * @return null for a hidden section */ - private static List toPublicGames(List games, Map meta) { + private static List toPublicGames(List games, Map meta, + Map coming) { if (games == null) { return null; } @@ -294,7 +309,9 @@ public class LibraryQueryService { .map(ug -> new PublicLibraryDto.Game(ug.getAppid(), meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getName() : null, ug.getPlaytimeMinutes(), ug.getStatus(), ug.getRating(), ug.getPlatform(), - meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getImageUrl() : null)) + meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getImageUrl() : null, + coming.containsKey(ug.getAppid()), + coming.containsKey(ug.getAppid()) ? coming.get(ug.getAppid()).getReleaseDate() : null)) .toList(); } @@ -307,7 +324,7 @@ public class LibraryQueryService { private static GameDto toDto(UserGame ug, SteamGame meta, GameTimeToBeat time, GameAchievementSchemaMeta schema, UserGameAchievementMeta unlocks, - Integer editions) { + Integer editions, ComingGame coming) { Integer achievementsTotal = ug.getSource() == GameSource.STEAM && schema != null && schema.isHasSchema() && schema.getAchievementsTotal() > 0 ? schema.getAchievementsTotal() : null; Integer achievementsUnlocked = achievementsTotal != null && unlocks != null && unlocks.isStatsVisible() @@ -327,7 +344,9 @@ public class LibraryQueryService { ug.getPlatform(), meta != null ? meta.getImageUrl() : null, ug.getHiddenAt() != null, - editions); + editions, + coming != null, + coming != null ? coming.getReleaseDate() : null); } public Optional getSyncStatus(Long userId) { diff --git a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java index 2496036..67a07bf 100644 --- a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java +++ b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java @@ -65,6 +65,7 @@ public class LibrarySyncWorker { private final WishlistGameRepository wishlistGameRepository; private final TimeToBeatService timeToBeatService; private final MoodService moodService; + private final ReleaseDateService releaseDateService; private final GameEditionService gameEditionService; private final ApplicationEventPublisher eventPublisher; private final TransactionTemplate transaction; @@ -151,6 +152,7 @@ public class LibrarySyncWorker { igdbAppids.addAll(userGameRepository.findActiveManualAppids(userId)); igdbAppids.removeAll(new HashSet<>(userGameRepository.findHiddenAppids(userId))); timeToBeatService.refresh(igdbAppids); + releaseDateService.refresh(igdbAppids); moodService.refresh(igdbAppids); gameEditionService.refresh(igdbAppids); finalStatus = Status.OK; diff --git a/src/main/java/core_service/modules/library/service/ManualGameService.java b/src/main/java/core_service/modules/library/service/ManualGameService.java index 506fbbf..6223798 100644 --- a/src/main/java/core_service/modules/library/service/ManualGameService.java +++ b/src/main/java/core_service/modules/library/service/ManualGameService.java @@ -67,12 +67,13 @@ public class ManualGameService { private final TimeToBeatService timeToBeatService; private final MoodService moodService; private final GameEditionService gameEditionService; + private final ReleaseDateService releaseDateService; private final TransactionTemplate transaction; public ManualGameService(IgdbApiService igdbApiService, UserGameRepository userGameRepository, SteamGameRepository steamGameRepository, GameMergeRepository mergeRepository, TimeToBeatService timeToBeatService, MoodService moodService, - GameEditionService gameEditionService, + GameEditionService gameEditionService, ReleaseDateService releaseDateService, PlatformTransactionManager transactionManager) { this.igdbApiService = igdbApiService; this.userGameRepository = userGameRepository; @@ -81,6 +82,7 @@ public class ManualGameService { this.timeToBeatService = timeToBeatService; this.moodService = moodService; this.gameEditionService = gameEditionService; + this.releaseDateService = releaseDateService; this.transaction = new TransactionTemplate(transactionManager); } @@ -173,6 +175,7 @@ public class ManualGameService { timeToBeatService.refreshInBackground(List.of(added.appid())); moodService.refreshInBackground(List.of(added.appid())); gameEditionService.refreshInBackground(List.of(added.appid())); + releaseDateService.refreshInBackground(List.of(added.appid())); } return result; } diff --git a/src/main/java/core_service/modules/library/service/ReleaseDateService.java b/src/main/java/core_service/modules/library/service/ReleaseDateService.java new file mode 100644 index 0000000..099b358 --- /dev/null +++ b/src/main/java/core_service/modules/library/service/ReleaseDateService.java @@ -0,0 +1,108 @@ +package core_service.modules.library.service; + +import core_service.modules.igdb_adapter.service.IgdbApiService; +import core_service.modules.library.domain.GameKeys; +import core_service.modules.library.repo.GameReleaseRepository; +import core_service.modules.library.repo.UserGameRepository; +import core_service.modules.steam_adapter.service.SteamApiService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.task.TaskExecutor; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; + +/** + * Stores the release date of games, the same way {@link TimeToBeatService} stores the time to beat. IGDB's first + * release date wins; Steam's store is asked only for Steam games IGDB has no date for. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class ReleaseDateService { + + private static final SteamApiService.StoreRelease NO_RELEASE = new SteamApiService.StoreRelease(null, false); + + private final IgdbApiService igdbApiService; + private final SteamApiService steamApiService; + private final GameReleaseRepository gameReleaseRepository; + private final UserGameRepository userGameRepository; + private final TaskExecutor applicationTaskExecutor; + + @Value("${app.library.release-ttl-days:30}") + private long ttlDays; + + @Value("${app.library.zone:Europe/Berlin}") + private ZoneId zone; + + /** + * Asks for the games without a fresh row and stores the answer, also "no date". Does nothing when IGDB is off. + * Stores nothing when IGDB fails; when Steam fails, the Steam games without an IGDB date are skipped. + */ + public void refresh(Collection appids) { + if (!igdbApiService.isEnabled() || appids.isEmpty()) return; + Set stale = stale(appids); + if (stale.isEmpty()) return; + + IgdbLookup.Answer igdb = IgdbLookup.byAppid(stale, igdbApiService::getReleaseDates, + igdbApiService::getReleaseDatesByGameIds); + Set answered = igdb.answered(); + if (answered.size() < stale.size()) { + log.warn("Release dates of {} games not refreshed, IGDB did not answer", stale.size() - answered.size()); + } + Set steamAsk = new TreeSet<>(); + for (Long appid : answered) { + if (GameKeys.isSteam(appid) && !igdb.found().containsKey(appid)) steamAsk.add(appid); + } + Optional> steam = steamAsk.isEmpty() ? Optional.of(Map.of()) + : steamApiService.getReleases(List.copyOf(steamAsk)); + if (steam.isEmpty()) { + log.warn("Release dates of {} Steam games not refreshed, Steam did not answer", steamAsk.size()); + } + Instant now = Instant.now(); + for (Long appid : answered) { + LocalDate igdbDate = igdb.found().get(appid); + if (igdbDate != null) { + gameReleaseRepository.upsert(appid, igdbDate, false, now); + } else if (!steamAsk.contains(appid)) { + gameReleaseRepository.upsert(appid, null, false, now); + } else if (steam.isPresent()) { + SteamApiService.StoreRelease release = steam.get().getOrDefault(appid, NO_RELEASE); + gameReleaseRepository.upsert(appid, release.date(), release.date() == null && release.comingSoon(), now); + } + } + } + + /** + * Same as {@link #refresh}, but the request does not wait for IGDB or Steam, and an error does not fail it. + */ + public void refreshInBackground(Collection appids) { + if (!igdbApiService.isEnabled()) return; + Refreshes.staleInBackground(applicationTaskExecutor, "Release dates of " + appids, () -> appids, this::stale, + this::refresh); + } + + /** + * For the picker: a user without a Steam sync gets dates only here, and a Steam user before the next sync. + */ + public void refreshBacklogInBackground(Long userId) { + if (!igdbApiService.isEnabled()) return; + Refreshes.staleInBackground(applicationTaskExecutor, "Backlog release dates of user " + userId, + () -> userGameRepository.findBacklogAppids(userId), this::stale, this::refresh); + } + + private Set stale(Collection appids) { + return Refreshes.stale(appids, (ids, since) -> gameReleaseRepository.findFreshAppids(ids, since, + LocalDate.now(zone), Instant.now().minus(Duration.ofDays(1))), ttlDays); + } +} diff --git a/src/main/java/core_service/modules/library/service/UserStatsService.java b/src/main/java/core_service/modules/library/service/UserStatsService.java index 8366b51..7abdb3b 100644 --- a/src/main/java/core_service/modules/library/service/UserStatsService.java +++ b/src/main/java/core_service/modules/library/service/UserStatsService.java @@ -56,13 +56,13 @@ public class UserStatsService { * Runs only the queries of the asked groups; the others stay null. */ public UserStatsDto getStats(long userId, Set groups) { - Map byStatus = groups.contains(StatGroup.OVERVIEW) || groups.contains(StatGroup.PILE) + Map byStatus = groups.contains(StatGroup.OVERVIEW) ? userGameRepository.countEveryStatus(userId) : Map.of(); long playtime = groups.contains(StatGroup.OVERVIEW) || groups.contains(StatGroup.HABITS) ? userGameRepository.sumPlaytime(userId) : 0; return new UserStatsDto( groups.contains(StatGroup.OVERVIEW) ? overview(userId, byStatus, playtime) : null, - groups.contains(StatGroup.PILE) ? pile(userId, byStatus.get(GameStatus.BACKLOG)) : null, + groups.contains(StatGroup.PILE) ? pile(userId) : null, groups.contains(StatGroup.TOP_GAMES) ? topGames(userId) : null, groups.contains(StatGroup.TASTE) ? taste(userId) : null, groups.contains(StatGroup.HABITS) ? habits(userId, playtime) : null, @@ -77,11 +77,11 @@ public class UserStatsService { return new UserStatsDto.Overview(total, playtime, total - neverStarted, neverStarted, byStatus); } - private UserStatsDto.Pile pile(long userId, long backlogGames) { + private UserStatsDto.Pile pile(long userId) { UserStats.PileTimes times = userStatsRepository.pileTimes(userId); LocalDate from = LocalDate.now(zone).minusDays(StatFormulas.PACE_DAYS - 1L); long recentMinutes = userStatsRepository.minutesPlayedSince(userId, from); - return new UserStatsDto.Pile(backlogGames, times.normalMinutes(), times.completionistMinutes(), + return new UserStatsDto.Pile(userGameRepository.countPile(userId), times.normalMinutes(), times.completionistMinutes(), times.gamesWithTimeToBeat(), StatFormulas.minutesPerWeek(recentMinutes), StatFormulas.paceWeeks(times.normalMinutes(), recentMinutes), StatFormulas.workYears(times.normalMinutes()), userStatsRepository.oldestUnplayed(userId).orElse(null)); diff --git a/src/main/java/core_service/modules/stats/repo/StatsRepository.java b/src/main/java/core_service/modules/stats/repo/StatsRepository.java index a12cca5..bf50931 100644 --- a/src/main/java/core_service/modules/stats/repo/StatsRepository.java +++ b/src/main/java/core_service/modules/stats/repo/StatsRepository.java @@ -110,7 +110,8 @@ public class StatsRepository { Long v = jdbc.queryForObject(""" SELECT COALESCE(MAX(c), 0) FROM ( SELECT COUNT(*) AS c FROM counted_user_game - WHERE %s AND playtime_minutes = 0 GROUP BY user_id + WHERE %s AND playtime_minutes = 0 + AND appid NOT IN (SELECT appid FROM coming_game) GROUP BY user_id ) t """.formatted(NOT_DELETED_OR_BLOCKED), Long.class); return v != null ? v : 0L; diff --git a/src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamStoreItemsResponse.java b/src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamStoreItemsResponse.java index 1c3d9a0..249bbe1 100644 --- a/src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamStoreItemsResponse.java +++ b/src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamStoreItemsResponse.java @@ -19,6 +19,7 @@ public record SteamStoreItemsResponse( * @param success 1 for a known app * @param bestPurchaseOption null for an app without a price, e.g. not released yet * @param assets only sent when the request asks for them + * @param release only sent when the request asks for it */ @JsonIgnoreProperties(ignoreUnknown = true) public record StoreItem( @@ -26,7 +27,18 @@ public record SteamStoreItemsResponse( @JsonProperty("success") Integer success, @JsonProperty("name") String name, @JsonProperty("best_purchase_option") PurchaseOption bestPurchaseOption, - @JsonProperty("assets") Assets assets + @JsonProperty("assets") Assets assets, + @JsonProperty("release") Release release + ) {} + + /** + * @param steamReleaseDate unix seconds; null when Steam has no date + * @param comingSoon true while the app is announced and not out + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record Release( + @JsonProperty("steam_release_date") Long steamReleaseDate, + @JsonProperty("is_coming_soon") Boolean comingSoon ) {} /** diff --git a/src/main/java/core_service/modules/steam_adapter/service/SteamApiService.java b/src/main/java/core_service/modules/steam_adapter/service/SteamApiService.java index 78b93b7..e5ce8a0 100644 --- a/src/main/java/core_service/modules/steam_adapter/service/SteamApiService.java +++ b/src/main/java/core_service/modules/steam_adapter/service/SteamApiService.java @@ -20,6 +20,8 @@ import org.springframework.stereotype.Service; import org.springframework.web.client.RestClient; import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -110,6 +112,12 @@ public class SteamApiService { */ public record StoreItem(String name, String price, String originalPrice, int discountPct) {} + /** + * @param date null when Steam gives none + * @param comingSoon true while Steam says the app is not out + */ + public record StoreRelease(LocalDate date, boolean comingSoon) {} + /** * Needs a public wishlist; a private one comes back as an empty list, like an empty one. * @@ -175,6 +183,31 @@ public class SteamApiService { return result; } + /** + * Steam's release date per app, UTC. Apps Steam sends no release for are missing. Empty when any batch failed, so + * the caller can tell "failed" from "no data". + */ + @RateLimiter(name = "steamApi") + public Optional> getReleases(List appids) { + Map result = new HashMap<>(); + try { + for (int from = 0; from < appids.size(); from += STORE_ITEMS_BATCH) { + List batch = appids.subList(from, Math.min(from + STORE_ITEMS_BATCH, appids.size())); + for (SteamStoreItemsResponse.StoreItem item : requestStoreItems(batch, "{\"include_release\":true}")) { + SteamStoreItemsResponse.Release release = item.release(); + if (release == null) continue; + LocalDate date = release.steamReleaseDate() == null ? null + : Instant.ofEpochSecond(release.steamReleaseDate()).atZone(ZoneOffset.UTC).toLocalDate(); + result.put(item.appid(), new StoreRelease(date, Boolean.TRUE.equals(release.comingSoon()))); + } + } + return Optional.of(result); + } catch (Exception e) { + log.error("Error communicating with Steam API for release dates of {} apps", appids.size(), e); + return Optional.empty(); + } + } + private Map getStoreItemsBatch(List appids) { Map items = new HashMap<>(); for (SteamStoreItemsResponse.StoreItem item : fetchStoreItems(appids, "{}")) { @@ -192,30 +225,37 @@ public class SteamApiService { * @return the known apps (success 1); empty when the request failed */ private List fetchStoreItems(List appids, String dataRequest) { - String ids = appids.stream().map(id -> "{\"appid\":" + id + "}").collect(Collectors.joining(",")); - String inputJson = "{\"ids\":[" + ids + "],\"context\":{\"language\":\"" + storeLanguage - + "\",\"country_code\":\"" + storeCountry + "\"},\"data_request\":" + dataRequest + "}"; try { - SteamStoreItemsResponse response = steamRestClient.get() - .uri(uriBuilder -> uriBuilder - .path("/IStoreBrowseService/GetItems/v1/") - .queryParam("key", apiKey) - .queryParam("input_json", "{inputJson}") - .build(inputJson)) - .retrieve() - .body(SteamStoreItemsResponse.class); - if (response == null || response.response() == null || response.response().storeItems() == null) { - return List.of(); - } - return response.response().storeItems().stream() - .filter(item -> item.appid() != null && Integer.valueOf(1).equals(item.success())) - .toList(); + return requestStoreItems(appids, dataRequest); } catch (Exception e) { log.error("Error communicating with Steam API for store items {}", appids, e); return List.of(); } } + /** + * @return the known apps (success 1); throws when the request failed + */ + private List requestStoreItems(List appids, String dataRequest) { + String ids = appids.stream().map(id -> "{\"appid\":" + id + "}").collect(Collectors.joining(",")); + String inputJson = "{\"ids\":[" + ids + "],\"context\":{\"language\":\"" + storeLanguage + + "\",\"country_code\":\"" + storeCountry + "\"},\"data_request\":" + dataRequest + "}"; + SteamStoreItemsResponse response = steamRestClient.get() + .uri(uriBuilder -> uriBuilder + .path("/IStoreBrowseService/GetItems/v1/") + .queryParam("key", apiKey) + .queryParam("input_json", "{inputJson}") + .build(inputJson)) + .retrieve() + .body(SteamStoreItemsResponse.class); + if (response == null || response.response() == null || response.response().storeItems() == null) { + return List.of(); + } + return response.response().storeItems().stream() + .filter(item -> item.appid() != null && Integer.valueOf(1).equals(item.success())) + .toList(); + } + /** * Per-game achievement schema (global, language-aware). * Returns empty when the game has no achievements or Steam answers 4xx, its final answer for delisted apps and diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 74dd582..0cc0243 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -65,6 +65,7 @@ app: schema-ttl-days: 30 speedrun-ttl-days: 30 edition-ttl-days: 30 + release-ttl-days: 30 # Reported broken pictures, repaired after the nightly sync. image-repair-cron: "0 30 1 * * *" merge-undo-purge-cron: "0 45 3 * * *" diff --git a/src/main/resources/db/migration/V33__Game_Release.sql b/src/main/resources/db/migration/V33__Game_Release.sql new file mode 100644 index 0000000..1e49525 --- /dev/null +++ b/src/main/resources/db/migration/V33__Game_Release.sql @@ -0,0 +1,20 @@ +-- Release date per library key, shared by all users and refreshed like game_edition. IGDB first_release_date wins, +-- then Steam's release date. release_date null: no source knows it. coming_soon: Steam says coming soon, no date. +CREATE TABLE game_release ( + appid BIGINT PRIMARY KEY, + release_date DATE, + coming_soon BOOLEAN NOT NULL DEFAULT FALSE, + fetched_at TIMESTAMPTZ NOT NULL +); +CREATE INDEX user_game_played_appid_idx ON user_game (appid) WHERE playtime_minutes > 0; +CREATE INDEX uga_meta_unlocked_appid_idx ON user_game_achievement_meta (appid) WHERE achievements_unlocked > 0; +-- A game that is not out yet: the date is in the future, or Steam says coming soon without a date, and nobody +-- has played it. A user_game row of any user counts as play, also a hidden or removed one. An unknown date is never +-- coming. "Today" is the Berlin date, the zone of app.library.zone (snapshot day); it is computed on every read, so a +-- game counts again on its release day. A new zone in app.library.zone needs a new migration for this view. +CREATE VIEW coming_game AS +SELECT r.appid, r.release_date FROM game_release r +WHERE (r.release_date > (now() AT TIME ZONE 'Europe/Berlin')::date + OR (r.release_date IS NULL AND r.coming_soon)) + AND NOT EXISTS (SELECT 1 FROM user_game ug WHERE ug.appid = r.appid AND ug.playtime_minutes > 0) + AND NOT EXISTS (SELECT 1 FROM user_game_achievement_meta m WHERE m.appid = r.appid AND m.achievements_unlocked > 0); diff --git a/src/test/java/core_service/modules/igdb_adapter/service/IgdbApiServiceTest.java b/src/test/java/core_service/modules/igdb_adapter/service/IgdbApiServiceTest.java index ac963c1..6c6a6fa 100644 --- a/src/test/java/core_service/modules/igdb_adapter/service/IgdbApiServiceTest.java +++ b/src/test/java/core_service/modules/igdb_adapter/service/IgdbApiServiceTest.java @@ -9,6 +9,7 @@ import org.springframework.http.MediaType; import org.springframework.test.web.client.MockRestServiceServer; import org.springframework.web.client.RestClient; +import java.time.LocalDate; import java.util.List; import java.util.Map; import java.util.Optional; @@ -189,6 +190,46 @@ class IgdbApiServiceTest extends AbstractUnitTest { assertThat(service.getReleaseYears(List.of(620L))).isEmpty(); } + @Test + void getReleaseDates_SteamAppLinkedToTwoIgdbGames_GetsTheEarliestUtcDate() { + expectToken(); + igdb.expect(requestTo(containsString("/external_games"))) + .andRespond(withSuccess("[{\"game\": 72, \"uid\": \"620\"}, {\"game\": 233, \"uid\": \"620\"}, {\"game\": 9, \"uid\": \"220\"}]", + MediaType.APPLICATION_JSON)); + igdb.expect(requestTo(containsString("/games"))) + // 2006-06-01 and 2017-05-05 UTC; game 9 has no date + .andRespond(withSuccess("[{\"id\": 233, \"first_release_date\": 1493942400}, {\"id\": 72, \"first_release_date\": 1149120000}, {\"id\": 9}]", + MediaType.APPLICATION_JSON)); + + assertThat(service.getReleaseDates(List.of(620L, 220L))).contains(Map.of(620L, LocalDate.of(2006, 6, 1))); + } + + @Test + void getReleaseDatesByGameIds_MapsTheIgdbIdToItsDate() { + expectToken(); + igdb.expect(requestTo(containsString("/games"))) + .andExpect(content().string(containsString("id = (1025,1026)"))) + .andRespond(withSuccess("[{\"id\": 1025, \"first_release_date\": 1493942400}, {\"id\": 1026}]", MediaType.APPLICATION_JSON)); + + assertThat(service.getReleaseDatesByGameIds(List.of(1025L, 1026L))).contains(Map.of(1025L, LocalDate.of(2017, 5, 5))); + } + + @Test + void getReleaseDates_IgdbError_IsEmpty() { + expectToken(); + igdb.expect(requestTo(containsString("/external_games"))).andRespond(withServerError()); + + assertThat(service.getReleaseDates(List.of(620L))).isEmpty(); + } + + @Test + void getReleaseDatesByGameIds_IgdbError_IsEmpty() { + expectToken(); + igdb.expect(requestTo(containsString("/games"))).andRespond(withServerError()); + + assertThat(service.getReleaseDatesByGameIds(List.of(1025L))).isEmpty(); + } + @Test void getGameTags_NoSteamAppKnown_AsksNoGames() { expectToken(); diff --git a/src/test/java/core_service/modules/library/api/GameImportControllerTest.java b/src/test/java/core_service/modules/library/api/GameImportControllerTest.java index 2e295eb..55e862d 100644 --- a/src/test/java/core_service/modules/library/api/GameImportControllerTest.java +++ b/src/test/java/core_service/modules/library/api/GameImportControllerTest.java @@ -6,6 +6,7 @@ import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGameId; +import core_service.modules.library.repo.GameReleaseRepository; import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.service.GameImportWorker; import core_service.shared.test.AbstractMockedAdaptersTest; @@ -22,6 +23,7 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; import java.time.Instant; +import java.time.LocalDate; import java.util.Collections; import java.util.List; import java.util.Map; @@ -71,6 +73,9 @@ class GameImportControllerTest extends AbstractMockedAdaptersTest { @Autowired private JdbcTemplate jdbc; + @Autowired + private GameReleaseRepository gameReleaseRepository; + @BeforeEach void seed() { jdbc.update("delete from game_alias"); @@ -269,6 +274,18 @@ class GameImportControllerTest extends AbstractMockedAdaptersTest { assertEquals(id, importIdOf(-119388L)); } + @Test + void save_StoresTheReleaseDateOfTheAddedGames() throws Exception { + long id = createReady("{\"source\": \"TEXT\", \"rows\": [{\"title\": \"Zelda Tears\"}]}"); + gameReleaseRepository.deleteAll(); + LocalDate releaseDate = LocalDate.now().plusDays(60); + when(igdbApiService.getReleaseDatesByGameIds(any())).thenReturn(Optional.of(Map.of(119388L, releaseDate))); + + saveAndWait(id, "{\"lineNo\": 1, \"igdbId\": 119388, \"platform\": \"Switch\", \"status\": \"BACKLOG\"}"); + + assertEquals(releaseDate, gameReleaseRepository.findById(-119388L).orElseThrow().getReleaseDate()); + } + @Test void undo_RemovesTheGamesStillAddedByHand_AndCountsTheChangedOnes() throws Exception { long id = createReady("{\"source\": \"TEXT\", \"rows\": [{\"title\": \"Elden Ring\"}, {\"title\": \"Zelda Tears\"}]}"); diff --git a/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java b/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java index edee19b..a531925 100644 --- a/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java +++ b/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java @@ -171,6 +171,8 @@ class GameMergeFlowTest extends AbstractIntegrationTest { jdbc.update("delete from game_link_pair"); jdbc.update("delete from game_link_request"); jdbc.update("delete from game_edition"); + jdbc.update("delete from game_release"); + jdbc.update("insert into game_release (appid, release_date, fetched_at) values (?, current_date, now())", MANUAL); jdbc.update("insert into game_link (appid, set_id) values (?, 1), (?, 1)", MANUAL, MANUAL_2); jdbc.update("insert into game_link_pair (appid_a, appid_b, state) values (?, ?, 'OPEN')", MANUAL_2, STEAM_2); jdbc.update("insert into game_link_pair (appid_a, appid_b, state) values (?, ?, 'OPEN')", MANUAL_2, MANUAL); @@ -185,6 +187,7 @@ class GameMergeFlowTest extends AbstractIntegrationTest { assertThat(jdbc.queryForObject("select count(*) from game_link_pair", Integer.class)).isEqualTo(1); assertThat(jdbc.queryForObject("select count(*) from game_link_request", Integer.class)).isEqualTo(1); assertThat(jdbc.queryForObject("select count(*) from game_edition", Integer.class)).isZero(); + assertThat(jdbc.queryForObject("select count(*) from game_release", Integer.class)).isZero(); assertThat(audit()).hasSize(1).first().asString().contains("GAME_MERGE"); } diff --git a/src/test/java/core_service/modules/library/api/GameMergeUndoFlowTest.java b/src/test/java/core_service/modules/library/api/GameMergeUndoFlowTest.java index b4183a7..9750776 100644 --- a/src/test/java/core_service/modules/library/api/GameMergeUndoFlowTest.java +++ b/src/test/java/core_service/modules/library/api/GameMergeUndoFlowTest.java @@ -186,9 +186,12 @@ class GameMergeUndoFlowTest extends AbstractIntegrationTest { manualRow(AUDITOR_ID, MANUAL, GameStatus.PLAYING); gameMoodsRepository.upsert(MANUAL, new String[]{"ACTION"}, new String[]{"Roguelike"}, false, Instant.now()); jdbc.update("insert into game_time_to_beat (appid, normally_minutes, fetched_at) values (?, 600, now())", MANUAL); + jdbc.update("delete from game_release"); + jdbc.update("insert into game_release (appid, release_date, fetched_at) values (?, current_date, now())", MANUAL); jdbc.update("insert into game_correction (appid, field, value, corrected_by, corrected_at) " + "values (?, 'NAME', 'Hades Switch', 1, now())", MANUAL); merge(MANUAL, STEAM).andExpect(status().isOk()); + assertThat(count("select count(*) from game_release")).isZero(); jdbc.update("update user_game set status = 'FINISHED', rating = 4 where user_id = ? and appid = ?", AUDITOR_ID, STEAM); int history = logRows(AUDITOR_ID, STEAM); @@ -204,6 +207,7 @@ class GameMergeUndoFlowTest extends AbstractIntegrationTest { assertThat(steamGameRepository.findById(MANUAL).orElseThrow().getName()).isEqualTo("Hades (Switch)"); assertThat(gameMoodsRepository.findById(MANUAL)).isPresent(); assertThat(gameTimeToBeatRepository.findById(MANUAL)).isPresent(); + assertThat(count("select count(*) from game_release where appid = ?", MANUAL)).isEqualTo(1); assertThat(count("select count(*) from game_correction where appid = ?", MANUAL)).isEqualTo(1); assertThat(aliasOf(MANUAL)).isNull(); assertThat(count("select count(*) from game_merge_undo")).isZero(); diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerComingTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerComingTest.java new file mode 100644 index 0000000..fbff608 --- /dev/null +++ b/src/test/java/core_service/modules/library/api/LibraryControllerComingTest.java @@ -0,0 +1,102 @@ +package core_service.modules.library.api; + +import core_service.modules.library.domain.GameStatus; +import core_service.modules.library.domain.SteamGame; +import core_service.modules.library.domain.UserGame; +import core_service.modules.library.repo.GameReleaseRepository; +import core_service.modules.library.repo.SteamGameRepository; +import core_service.modules.library.repo.UserGameRepository; +import core_service.shared.test.AbstractMockedAdaptersTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; + +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.nullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * The dev profile authenticates every request as user 1. Games added by hand, so the detail page asks Steam nothing. + */ +@AutoConfigureMockMvc +class LibraryControllerComingTest extends AbstractMockedAdaptersTest { + + private static final long USER_ID = 1L; + private static final long COMING = -4001L; + private static final long COMING_SOON = -4002L; + private static final long RELEASED = -4003L; + private static final LocalDate TOMORROW = LocalDate.now(ZoneId.of("Europe/Berlin")).plusDays(1); + + @Autowired + private MockMvc mockMvc; + + @Autowired + private SteamGameRepository steamGameRepository; + + @Autowired + private UserGameRepository userGameRepository; + + @Autowired + private GameReleaseRepository gameReleaseRepository; + + @Autowired + private JdbcTemplate jdbc; + + @BeforeEach + void seed() { + userGameRepository.deleteAll(); + gameReleaseRepository.deleteAll(); + for (long appid : new long[]{COMING, COMING_SOON, RELEASED}) { + steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now())); + userGameRepository.save(UserGame.manual(USER_ID, appid, "PC", GameStatus.BACKLOG, 0, Instant.now())); + } + gameReleaseRepository.upsert(COMING, TOMORROW, false, Instant.now()); + gameReleaseRepository.upsert(COMING_SOON, null, true, Instant.now()); + gameReleaseRepository.upsert(RELEASED, TOMORROW.minusDays(10), false, Instant.now()); + } + + @Test + void list_MarksComingGamesWithTheirDate() throws Exception { + mockMvc.perform(get("/api/library/games")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[?(@.appid == " + COMING + ")].coming").value(true)) + .andExpect(jsonPath("$.content[?(@.appid == " + COMING + ")].comingOn").value(TOMORROW.toString())) + .andExpect(jsonPath("$.content[?(@.appid == " + COMING_SOON + ")].coming").value(true)) + .andExpect(jsonPath("$.content[?(@.appid == " + COMING_SOON + ")].comingOn").value(contains(nullValue()))) + .andExpect(jsonPath("$.content[?(@.appid == " + RELEASED + ")].coming").value(false)) + .andExpect(jsonPath("$.content[?(@.appid == " + RELEASED + ")].comingOn").value(contains(nullValue()))); + } + + @Test + void detail_ComingGame_HasTheDate() throws Exception { + mockMvc.perform(get("/api/library/games/" + COMING)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.coming").value(true)) + .andExpect(jsonPath("$.comingOn").value(TOMORROW.toString())); + } + + @Test + void detail_ReleasedGame_IsNotComing() throws Exception { + mockMvc.perform(get("/api/library/games/" + RELEASED)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.coming").value(false)) + .andExpect(jsonPath("$.comingOn").value(nullValue())); + } + + @Test + void detail_ComingGameSomeonePlayed_IsNotComing() throws Exception { + jdbc.update("update user_game set playtime_minutes = 30 where appid = ?", COMING); + + mockMvc.perform(get("/api/library/games/" + COMING)) + .andExpect(jsonPath("$.coming").value(false)); + } +} diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java index d6a58ca..90a0806 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java @@ -7,6 +7,7 @@ import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGameId; import core_service.modules.library.repo.GameMoodsRepository; +import core_service.modules.library.repo.GameReleaseRepository; import core_service.modules.library.repo.GameSpeedrunRepository; import core_service.modules.library.repo.GameTimeToBeatRepository; import core_service.modules.library.repo.SteamGameRepository; @@ -19,6 +20,8 @@ import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.test.web.servlet.MockMvc; import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsInAnyOrder; @@ -57,8 +60,12 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest { @Autowired private GameSpeedrunRepository gameSpeedrunRepository; + @Autowired + private GameReleaseRepository gameReleaseRepository; + @BeforeEach void clean() { + gameReleaseRepository.deleteAll(); userGameRepository.deleteAll(); gameTimeToBeatRepository.deleteAll(); gameMoodsRepository.deleteAll(); @@ -283,4 +290,20 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest { verify(syncService).syncIfStale(USER_ID, "76561198000000001"); } + + @Test + void picker_ComingGame_IsNeverPickedUntilItsReleaseDay() throws Exception { + LocalDate berlinToday = LocalDate.now(ZoneId.of("Europe/Berlin")); + game(10L, GameStatus.BACKLOG, null, null); + game(20L, GameStatus.BACKLOG, null, null); + gameReleaseRepository.upsert(20L, berlinToday.plusDays(1), false, Instant.now()); + + mockMvc.perform(get("/api/library/picker")) + .andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10))); + + gameReleaseRepository.upsert(20L, berlinToday, false, Instant.now()); + + mockMvc.perform(get("/api/library/picker")) + .andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10, 20))); + } } diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerSummaryTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerSummaryTest.java index 0b6c67e..fc1e694 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerSummaryTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerSummaryTest.java @@ -4,6 +4,7 @@ import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameTimeToBeat; import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.UserGame; +import core_service.modules.library.repo.GameReleaseRepository; import core_service.modules.library.repo.GameTimeToBeatRepository; import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.UserGameRepository; @@ -15,6 +16,8 @@ import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.test.web.servlet.MockMvc; import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; import java.util.List; import static org.hamcrest.Matchers.nullValue; @@ -42,9 +45,13 @@ class LibraryControllerSummaryTest extends AbstractIntegrationTest { @Autowired private GameTimeToBeatRepository gameTimeToBeatRepository; + @Autowired + private GameReleaseRepository gameReleaseRepository; + @BeforeEach void clean() { userGameRepository.deleteAll(); + gameReleaseRepository.deleteAll(); } @Test @@ -108,4 +115,24 @@ class LibraryControllerSummaryTest extends AbstractIntegrationTest { mockMvc.perform(get("/api/library/summary").param("exclude", "220")) .andExpect(jsonPath("$.suggestion").value(nullValue())); } + + @Test + void summary_ComingGame_StaysInTheStatusCountButLeavesThePile() throws Exception { + steamGameRepository.saveAll(List.of( + new SteamGame(220L, "Half-Life 2", null, Instant.now()), + new SteamGame(620L, "Dawn of War 4", null, Instant.now()))); + userGameRepository.saveAll(List.of(new UserGame(USER_ID, 220L, 0, Instant.now(), Instant.now()), + new UserGame(USER_ID, 620L, 0, Instant.now(), Instant.now()))); + gameTimeToBeatRepository.saveAll(List.of( + new GameTimeToBeat(220L, 500, 700, 900, Instant.now()), + new GameTimeToBeat(620L, 1, 9999, 1, Instant.now()))); + gameReleaseRepository.upsert(620L, LocalDate.now(ZoneId.of("Europe/Berlin")).plusDays(1), false, Instant.now()); + + mockMvc.perform(get("/api/library/summary")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.byStatus.BACKLOG").value(2)) + .andExpect(jsonPath("$.pileGames").value(1)) + .andExpect(jsonPath("$.backlogMinutes").value(700)) + .andExpect(jsonPath("$.suggestion.appid").value(220)); + } } diff --git a/src/test/java/core_service/modules/library/api/ManualGameControllerTest.java b/src/test/java/core_service/modules/library/api/ManualGameControllerTest.java index 2358e9e..abd5386 100644 --- a/src/test/java/core_service/modules/library/api/ManualGameControllerTest.java +++ b/src/test/java/core_service/modules/library/api/ManualGameControllerTest.java @@ -7,6 +7,7 @@ import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGameId; +import core_service.modules.library.repo.GameReleaseRepository; import core_service.modules.library.repo.GameTimeToBeatRepository; import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.UserGameRepository; @@ -21,6 +22,7 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.web.servlet.MockMvc; import java.time.Instant; +import java.time.LocalDate; import java.util.List; import java.util.Map; import java.util.Optional; @@ -66,6 +68,9 @@ class ManualGameControllerTest extends AbstractMockedAdaptersTest { @Autowired private GameTimeToBeatRepository gameTimeToBeatRepository; + @Autowired + private GameReleaseRepository gameReleaseRepository; + @Autowired private JdbcTemplate jdbc; @@ -113,6 +118,17 @@ class ManualGameControllerTest extends AbstractMockedAdaptersTest { assertEquals(3600, gameTimeToBeatRepository.findById(-119388L).orElseThrow().getNormallyMinutes()); } + @Test + void add_StoresTheReleaseDate() throws Exception { + gameReleaseRepository.deleteAll(); + LocalDate releaseDate = LocalDate.now().plusDays(60); + when(igdbApiService.getReleaseDatesByGameIds(any())).thenReturn(Optional.of(Map.of(119388L, releaseDate))); + + add(119388, "Switch").andExpect(status().isCreated()); + + assertEquals(releaseDate, gameReleaseRepository.findById(-119388L).orElseThrow().getReleaseDate()); + } + @Test void add_GameAddedBeforeIgdbKnewItOnSteam_Is409() throws Exception { add(119388, "Switch").andExpect(status().isCreated()); diff --git a/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java b/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java index f5685af..9712119 100644 --- a/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java +++ b/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java @@ -7,6 +7,7 @@ import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGameAchievementMeta; import core_service.modules.library.repo.GameAchievementSchemaMetaRepository; +import core_service.modules.library.repo.GameReleaseRepository; import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.UserGameAchievementMetaRepository; import core_service.modules.library.repo.UserGameRepository; @@ -21,8 +22,11 @@ import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; import java.util.List; +import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.nullValue; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -70,8 +74,12 @@ class PublicProfileControllerTest extends AbstractIntegrationTest { return game; } + @Autowired + private GameReleaseRepository gameReleaseRepository; + @BeforeEach void seed() { + gameReleaseRepository.deleteAll(); userGameRepository.deleteAll(); schemaMetaRepository.deleteAll(); userAchievementMetaRepository.deleteAll(); @@ -109,6 +117,22 @@ class PublicProfileControllerTest extends AbstractIntegrationTest { .andExpect(jsonPath("$..priority").isEmpty()); } + @Test + void publicLibrary_ComingGame_ShowsItsDateAndAReleasedOneDoesNot() throws Exception { + LocalDate tomorrow = LocalDate.now(ZoneId.of("Europe/Berlin")).plusDays(1); + steamGameRepository.save(new SteamGame(900L, "Dawn of War 4", null, Instant.now())); + userGameRepository.save(game(900L, 0, GameStatus.PLAYING, null)); + gameReleaseRepository.upsert(900L, tomorrow, false, Instant.now()); + gameReleaseRepository.upsert(220L, tomorrow.minusDays(100), false, Instant.now()); + + mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket(USER_ID))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.playing[?(@.appid == 900)].coming").value(true)) + .andExpect(jsonPath("$.playing[?(@.appid == 900)].comingOn").value(tomorrow.toString())) + .andExpect(jsonPath("$.playing[?(@.appid == 220)].coming").value(false)) + .andExpect(jsonPath("$.playing[?(@.appid == 220)].comingOn").value(contains(nullValue()))); + } + @Test void publicLibrary_ShowsPerfectGamesAndAverageCompletion() throws Exception { schemaMetaRepository.saveAll(List.of( diff --git a/src/test/java/core_service/modules/library/repo/BacklogSnapshotRepositoryTest.java b/src/test/java/core_service/modules/library/repo/BacklogSnapshotRepositoryTest.java index 6863de0..cf7d8b5 100644 --- a/src/test/java/core_service/modules/library/repo/BacklogSnapshotRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/BacklogSnapshotRepositoryTest.java @@ -12,8 +12,10 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Import; import org.springframework.jdbc.core.JdbcTemplate; +import java.sql.Date; import java.time.Instant; import java.time.LocalDate; +import java.time.ZoneId; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -124,4 +126,36 @@ class BacklogSnapshotRepositoryTest extends AbstractPersistenceTest { assertThat(repository.findSince(2L, DAY)).isEmpty(); } + + private void comingTomorrow(long appid) { + jdbc.update("insert into game_release (appid, release_date, fetched_at) values (?, ?, now())", appid, + Date.valueOf(LocalDate.now(ZoneId.of("Europe/Berlin")).plusDays(1))); + } + + @Test + void comingGame_LeavesTheBacklogNumbersOfNewSnapshotsAndLive() { + userGameRepository.saveAllAndFlush(List.of(game(1L, 220L, 0, GameStatus.BACKLOG), + game(1L, 1000L, 0, GameStatus.BACKLOG))); + comingTomorrow(1000L); + + repository.writeAll(DAY); + + UserStats.PileDay expected = new UserStats.PileDay(DAY, 1, 2, 600); + assertThat(repository.findSince(1L, DAY)).containsExactly(expected); + assertThat(repository.live(1L, DAY)).isEqualTo(expected); + } + + @Test + void comingGame_AnOldSnapshotKeepsItsValue() { + userGameRepository.saveAllAndFlush(List.of(game(1L, 220L, 0, GameStatus.BACKLOG), + game(1L, 1000L, 0, GameStatus.BACKLOG))); + repository.writeAll(DAY.minusDays(1)); + comingTomorrow(1000L); + + repository.writeAll(DAY); + + assertThat(repository.findSince(1L, DAY.minusDays(1))).containsExactly( + new UserStats.PileDay(DAY.minusDays(1), 2, 2, 10599), + new UserStats.PileDay(DAY, 1, 2, 600)); + } } diff --git a/src/test/java/core_service/modules/library/repo/ComingGameRepositoryTest.java b/src/test/java/core_service/modules/library/repo/ComingGameRepositoryTest.java new file mode 100644 index 0000000..e92294b --- /dev/null +++ b/src/test/java/core_service/modules/library/repo/ComingGameRepositoryTest.java @@ -0,0 +1,157 @@ +package core_service.modules.library.repo; + +import core_service.modules.library.domain.ComingGame; +import core_service.modules.library.domain.SteamGame; +import core_service.modules.library.domain.UserGame; +import core_service.shared.test.AbstractPersistenceTest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class ComingGameRepositoryTest extends AbstractPersistenceTest { + + private static final long GAME = 4000L; + private static final LocalDate TODAY = LocalDate.now(ZoneId.of("Europe/Berlin")); + + @Autowired + private ComingGameRepository comingGameRepository; + + @Autowired + private GameReleaseRepository gameReleaseRepository; + + @Autowired + private UserGameRepository userGameRepository; + + @Autowired + private SteamGameRepository steamGameRepository; + + @Autowired + private JdbcTemplate jdbc; + + private void release(LocalDate date, boolean comingSoon) { + gameReleaseRepository.upsert(GAME, date, comingSoon, Instant.now()); + } + + private void owned(long userId, int playtime) { + steamGameRepository.saveAndFlush(new SteamGame(GAME, "Dawn of War 4", null, Instant.now())); + userGameRepository.saveAndFlush(new UserGame(userId, GAME, playtime, Instant.now(), Instant.now())); + } + + private boolean coming() { + return comingGameRepository.findById(GAME).isPresent(); + } + + @Test + void view_FutureDateAndNoPlay_IsComingWithItsDate() { + release(TODAY.plusDays(1), false); + owned(1L, 0); + + assertThat(comingGameRepository.findById(GAME)).get().extracting(ComingGame::getReleaseDate) + .isEqualTo(TODAY.plusDays(1)); + } + + @Test + void view_OnTheReleaseDay_IsNotComing() { + release(TODAY, false); + + assertThat(coming()).isFalse(); + } + + @Test + void view_PastDate_IsNotComing() { + release(TODAY.minusDays(1), false); + + assertThat(coming()).isFalse(); + } + + @Test + void view_UnknownDate_IsNotComing() { + release(null, false); + + assertThat(coming()).isFalse(); + } + + @Test + void view_ComingSoonWithoutDate_IsComingWithoutDate() { + release(null, true); + + assertThat(comingGameRepository.findById(GAME)).get().extracting(ComingGame::getReleaseDate).isNull(); + } + + @Test + void view_ComingSoonWithoutDateButPlaytime_IsNotComing() { + release(null, true); + owned(1L, 5); + + assertThat(coming()).isFalse(); + } + + @Test + void view_FutureDateButAnotherUsersPlaytime_IsNotComing() { + release(TODAY.plusDays(30), false); + owned(1L, 0); + owned(2L, 10); + + assertThat(coming()).isFalse(); + } + + @Test + void view_FutureDateButUnlockedAchievement_IsNotComing() { + release(TODAY.plusDays(30), false); + owned(1L, 0); + jdbc.update("insert into user_game_achievement_meta (user_id, appid, achievements_unlocked) values (1, ?, 1)", GAME); + + assertThat(coming()).isFalse(); + } + + @Test + void view_FutureDateAndAchievementMetaWithoutUnlocks_IsComing() { + release(TODAY.plusDays(30), false); + jdbc.update("insert into user_game_achievement_meta (user_id, appid, achievements_unlocked) values (1, ?, 0)", GAME); + + assertThat(coming()).isTrue(); + } + + @Test + void findFreshAppids_ComingRowsFetchedTwoDaysAgoAreStale_ReleasedRowIsFresh() { + Instant twoDaysAgo = Instant.now().minusSeconds(2 * 86_400L); + gameReleaseRepository.upsert(1L, TODAY.plusDays(5), false, twoDaysAgo); + gameReleaseRepository.upsert(2L, null, true, twoDaysAgo); + gameReleaseRepository.upsert(3L, TODAY.minusDays(5), false, twoDaysAgo); + gameReleaseRepository.upsert(4L, null, false, twoDaysAgo); + gameReleaseRepository.upsert(5L, TODAY.plusDays(5), false, Instant.now()); + + Set fresh = gameReleaseRepository.findFreshAppids(List.of(1L, 2L, 3L, 4L, 5L, 6L), + Instant.now().minusSeconds(30 * 86_400L), TODAY, Instant.now().minusSeconds(86_400L)); + + assertThat(fresh).containsExactlyInAnyOrder(3L, 4L, 5L); + } + + @Test + void findFreshAppids_OldRowBeyondTheTtl_IsStale() { + gameReleaseRepository.upsert(3L, TODAY.minusDays(50), false, Instant.now().minusSeconds(40 * 86_400L)); + + assertThat(gameReleaseRepository.findFreshAppids(List.of(3L), Instant.now().minusSeconds(30 * 86_400L), TODAY, + Instant.now().minusSeconds(86_400L))).isEmpty(); + } + + @Test + void upsert_SecondCall_ReplacesTheRow() { + release(TODAY.plusDays(5), false); + release(null, true); + + assertThat(gameReleaseRepository.findById(GAME)).get() + .satisfies(r -> { + assertThat(r.getReleaseDate()).isNull(); + assertThat(r.isComingSoon()).isTrue(); + }); + } +} diff --git a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java index e1bce8e..6ffb971 100644 --- a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java @@ -24,6 +24,8 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; import java.util.List; import java.util.Set; @@ -34,6 +36,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class UserGameRepositoryTest extends AbstractPersistenceTest { private static final long USER_ID = 1L; + private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin"); private static final PageRequest PAGE = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "playtimeMinutes")); @Autowired @@ -51,6 +54,9 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { @Autowired private GameMoodsRepository gameMoodsRepository; + @Autowired + private GameReleaseRepository gameReleaseRepository; + @Autowired private PlatformTransactionManager transactionManager; @@ -547,4 +553,57 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { assertEquals(List.of(1003L), ids(userGameRepository.findSimilarGames(USER_ID, 1001L, null, Mood.COOP, List.of(1002L), 10))); assertEquals(List.of(1003L), ids(userGameRepository.findLongestWaitingGames(USER_ID, null, Mood.COOP, List.of(1002L), 10))); } + + private void backlogGameReleasing(long appid, LocalDate releaseDate, String... moods) { + addGame(appid, GameStatus.BACKLOG, null, Instant.now(), 0, moods); + gameReleaseRepository.upsert(appid, releaseDate, false, Instant.now()); + } + + @Test + void comingGame_IsNeverPickedFromAnySource() { + hide(380L, 700L); + LocalDate tomorrow = LocalDate.now(BERLIN).plusDays(1); + addGame(1001L, GameStatus.BACKLOG, null, Instant.now(), 0, "STORY"); + backlogGameReleasing(1002L, tomorrow, "STORY"); + + assertEquals(List.of(1001L), ids(userGameRepository.findPickerGames(USER_ID, null, null, List.of(), 10))); + assertEquals(List.of(1001L), ids(userGameRepository.findLongestWaitingGames(USER_ID, null, null, List.of(), 10))); + assertTrue(userGameRepository.findSimilarGames(USER_ID, 1001L, null, null, List.of(), 10).isEmpty()); + for (int i = 0; i < 20; i++) { + assertEquals(1001L, userGameRepository.findRandomBacklogGame(USER_ID, UserGameRepository.NO_GAME).orElseThrow().getAppid()); + } + } + + @Test + void releasedGame_IsPickedAndCounted() { + hide(380L, 700L); + backlogGameReleasing(1002L, LocalDate.now(BERLIN), "STORY"); + backlogGameReleasing(1003L, LocalDate.now(BERLIN).minusDays(1), "STORY"); + + assertEquals(List.of(1002L, 1003L), ids(userGameRepository.findPickerGames(USER_ID, null, null, List.of(), 10)) + .stream().sorted().toList()); + assertEquals(2, userGameRepository.countPile(USER_ID)); + } + + @Test + void comingGame_LeavesThePileCountAndTheBacklogMinutesButKeepsItsStatus() { + LocalDate tomorrow = LocalDate.now(BERLIN).plusDays(1); + backlogGameReleasing(1002L, tomorrow); + gameTimeToBeatRepository.save(new GameTimeToBeat(1002L, 1, 5000, 1, Instant.now())); + gameTimeToBeatRepository.save(new GameTimeToBeat(380L, 100, 200, 300, Instant.now())); + + assertEquals(2, userGameRepository.countPile(USER_ID)); + assertEquals(3, userGameRepository.countEveryStatus(USER_ID).get(GameStatus.BACKLOG)); + assertEquals(200, userGameRepository.sumBacklogMinutes(USER_ID)); + } + + @Test + void comingGame_WithAnyUsersPlaytime_IsPickedAndCounted() { + hide(380L, 700L); + backlogGameReleasing(1002L, LocalDate.now(BERLIN).plusDays(30)); + userGameRepository.saveAndFlush(new UserGame(2L, 1002L, 10, Instant.now(), Instant.now())); + + assertEquals(List.of(1002L), ids(userGameRepository.findPickerGames(USER_ID, null, null, List.of(), 10))); + assertEquals(1, userGameRepository.countPile(USER_ID)); + } } diff --git a/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java b/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java index 57fe45b..a32f920 100644 --- a/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java @@ -14,12 +14,14 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Import; import org.springframework.jdbc.core.JdbcTemplate; +import java.sql.Date; import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; @Import(UserStatsRepository.class) class UserStatsRepositoryTest extends AbstractPersistenceTest { @@ -370,4 +372,40 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest { assertThat(repository.countGames(USER_ID, StatKey.TOTAL, "", BERLIN)).isEqualTo(1); assertThat(repository.games(USER_ID, StatKey.TOTAL, "", BERLIN, 0, 10)).hasSize(1); } + + 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))); + } + + @Test + void comingGame_LeavesThePileNumbersAndDrillSets() { + userGameRepository.saveAllAndFlush(List.of( + game(220L, 0, GameStatus.BACKLOG, Instant.parse("2026-01-01T00:00:00Z")), + game(620L, 0, GameStatus.BACKLOG, Instant.EPOCH))); + gameTimeToBeatRepository.saveAllAndFlush(List.of(new GameTimeToBeat(220L, 300, 600, 1200, Instant.now()), + new GameTimeToBeat(620L, 300, 5000, 8000, Instant.now()))); + comingTomorrow(620L); + + assertThat(repository.pileTimes(USER_ID)).isEqualTo(new UserStats.PileTimes(600, 1200, 1)); + assertThat(repository.oldestUnplayed(USER_ID).orElseThrow().appid()).isEqualTo(220L); + assertThat(repository.countGames(USER_ID, StatKey.PILE, "", BERLIN)).isEqualTo(1); + assertThat(repository.countGames(USER_ID, StatKey.PILE_TIME_TO_BEAT, "", BERLIN)).isEqualTo(1); + assertThat(repository.countGames(USER_ID, StatKey.TOTAL, "", BERLIN)).isEqualTo(2); + } + + @Test + void games_MarkComingGamesWithTheirDate() { + userGameRepository.saveAllAndFlush(List.of( + game(220L, 0, GameStatus.BACKLOG, Instant.now()), + game(620L, 0, GameStatus.BACKLOG, Instant.now()))); + comingTomorrow(620L); + + List games = repository.games(USER_ID, StatKey.TOTAL, "", BERLIN, 0, 10); + + assertThat(games).extracting(UserStats.StatGame::appid, UserStats.StatGame::coming, UserStats.StatGame::comingOn) + .containsExactlyInAnyOrder( + tuple(220L, false, null), + tuple(620L, true, LocalDate.now(BERLIN).plusDays(1))); + } } diff --git a/src/test/java/core_service/modules/library/service/GameImportWorkerTest.java b/src/test/java/core_service/modules/library/service/GameImportWorkerTest.java index ff08a4d..a0e901f 100644 --- a/src/test/java/core_service/modules/library/service/GameImportWorkerTest.java +++ b/src/test/java/core_service/modules/library/service/GameImportWorkerTest.java @@ -56,7 +56,7 @@ class GameImportWorkerTest { .thenReturn(Optional.of(7L), Optional.empty()); when(repository.findStalled(any())).thenReturn(Map.of()); GameImportWorker worker = new GameImportWorker(repository, mock(ManualGameService.class), - mock(TimeToBeatService.class), mock(MoodService.class), 5); + mock(TimeToBeatService.class), mock(MoodService.class), mock(ReleaseDateService.class), 5); try { worker.wakeMatching(); verify(repository, timeout(2000)).findNext(GameImportWorker.MATCHING_QUEUE); diff --git a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java index 8655274..1e60c08 100644 --- a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java +++ b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java @@ -126,6 +126,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest { @Mock private MoodService moodService; + @Mock + private ReleaseDateService releaseDateService; + @Mock private GameEditionService gameEditionService; @@ -544,6 +547,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest { verify(timeToBeatService).refresh(List.of(220L, -1025L)); verify(moodService).refresh(List.of(220L, -1025L)); + verify(releaseDateService).refresh(List.of(220L, -1025L)); verify(gameEditionService).refresh(List.of(220L, -1025L)); } diff --git a/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java b/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java index 501629e..400b9ff 100644 --- a/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java +++ b/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java @@ -53,6 +53,9 @@ class ManualGameServiceTest extends AbstractUnitTest { @Mock private GameEditionService gameEditionService; + @Mock + private ReleaseDateService releaseDateService; + @Mock private PlatformTransactionManager transactionManager; @@ -61,7 +64,7 @@ class ManualGameServiceTest extends AbstractUnitTest { @BeforeEach void setUp() { service = new ManualGameService(igdbApiService, userGameRepository, steamGameRepository, mergeRepository, timeToBeatService, - moodService, gameEditionService, transactionManager); + moodService, gameEditionService, releaseDateService, transactionManager); } @Test diff --git a/src/test/java/core_service/modules/library/service/ReleaseDateServiceTest.java b/src/test/java/core_service/modules/library/service/ReleaseDateServiceTest.java new file mode 100644 index 0000000..e5bd225 --- /dev/null +++ b/src/test/java/core_service/modules/library/service/ReleaseDateServiceTest.java @@ -0,0 +1,151 @@ +package core_service.modules.library.service; + +import core_service.modules.igdb_adapter.service.IgdbApiService; +import core_service.modules.library.repo.GameReleaseRepository; +import core_service.modules.library.repo.UserGameRepository; +import core_service.modules.steam_adapter.service.SteamApiService; +import core_service.shared.test.AbstractUnitTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.springframework.core.task.SyncTaskExecutor; +import org.springframework.core.task.TaskExecutor; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ReleaseDateServiceTest extends AbstractUnitTest { + + private static final LocalDate IGDB_DATE = LocalDate.of(2026, 12, 4); + private static final LocalDate STEAM_DATE = LocalDate.of(2026, 11, 20); + + @Mock + private IgdbApiService igdbApiService; + + @Mock + private SteamApiService steamApiService; + + @Mock + private GameReleaseRepository gameReleaseRepository; + + @Mock + private UserGameRepository userGameRepository; + + @Spy + private TaskExecutor applicationTaskExecutor = new SyncTaskExecutor(); + + @InjectMocks + private ReleaseDateService service; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "ttlDays", 30L); + ReflectionTestUtils.setField(service, "zone", ZoneId.of("Europe/Berlin")); + when(igdbApiService.isEnabled()).thenReturn(true); + when(gameReleaseRepository.findFreshAppids(anyCollection(), any(), any(), any())).thenReturn(Set.of()); + } + + @Test + void refresh_IgdbDate_WinsAndSteamIsNotAskedForIt() { + when(igdbApiService.getReleaseDates(Set.of(620L))).thenReturn(Optional.of(Map.of(620L, IGDB_DATE))); + + service.refresh(List.of(620L)); + + verify(gameReleaseRepository).upsert(eq(620L), eq(IGDB_DATE), eq(false), any(Instant.class)); + verify(steamApiService, never()).getReleases(any()); + } + + @Test + void refresh_SteamIsAskedOnlyForSteamKeysWithoutIgdbDate() { + when(igdbApiService.getReleaseDates(Set.of(620L, 700L))).thenReturn(Optional.of(Map.of(620L, IGDB_DATE))); + when(igdbApiService.getReleaseDatesByGameIds(Set.of(7L))).thenReturn(Optional.of(Map.of())); + when(steamApiService.getReleases(List.of(700L))).thenReturn(Optional.of(Map.of( + 700L, new SteamApiService.StoreRelease(STEAM_DATE, false)))); + + service.refresh(List.of(620L, 700L, -7L)); + + verify(steamApiService).getReleases(List.of(700L)); + verify(gameReleaseRepository).upsert(eq(620L), eq(IGDB_DATE), eq(false), any(Instant.class)); + verify(gameReleaseRepository).upsert(eq(700L), eq(STEAM_DATE), eq(false), any(Instant.class)); + verify(gameReleaseRepository).upsert(eq(-7L), isNull(), eq(false), any(Instant.class)); + } + + @Test + void refresh_SteamComingSoonWithoutDate_StoresNullDateAndComingSoon() { + when(igdbApiService.getReleaseDates(Set.of(700L))).thenReturn(Optional.of(Map.of())); + when(steamApiService.getReleases(List.of(700L))).thenReturn(Optional.of(Map.of( + 700L, new SteamApiService.StoreRelease(null, true)))); + + service.refresh(List.of(700L)); + + verify(gameReleaseRepository).upsert(eq(700L), isNull(), eq(true), any(Instant.class)); + } + + @Test + void refresh_IgdbDateBeatsSteamComingSoon() { + when(igdbApiService.getReleaseDates(Set.of(700L))).thenReturn(Optional.of(Map.of(700L, IGDB_DATE))); + + service.refresh(List.of(700L)); + + verify(gameReleaseRepository).upsert(eq(700L), eq(IGDB_DATE), eq(false), any(Instant.class)); + verify(steamApiService, never()).getReleases(any()); + } + + @Test + void refresh_SteamKnowsNothing_StoresUnknown() { + when(igdbApiService.getReleaseDates(Set.of(700L))).thenReturn(Optional.of(Map.of())); + when(steamApiService.getReleases(List.of(700L))).thenReturn(Optional.of(Map.of())); + + service.refresh(List.of(700L)); + + verify(gameReleaseRepository).upsert(eq(700L), isNull(), eq(false), any(Instant.class)); + } + + @Test + void refresh_IgdbError_StoresNothing() { + when(igdbApiService.getReleaseDates(any())).thenReturn(Optional.empty()); + + service.refresh(List.of(620L)); + + verify(gameReleaseRepository, never()).upsert(any(), any(), anyBoolean(), any()); + verify(steamApiService, never()).getReleases(any()); + } + + @Test + void refresh_SteamError_StoresOnlyTheIgdbDatedKeys() { + when(igdbApiService.getReleaseDates(Set.of(620L, 700L))).thenReturn(Optional.of(Map.of(620L, IGDB_DATE))); + when(steamApiService.getReleases(anyList())).thenReturn(Optional.empty()); + + service.refresh(List.of(620L, 700L)); + + verify(gameReleaseRepository).upsert(eq(620L), eq(IGDB_DATE), eq(false), any(Instant.class)); + verify(gameReleaseRepository, never()).upsert(eq(700L), any(), anyBoolean(), any()); + } + + @Test + void refresh_FreshKeys_AreSkipped() { + when(gameReleaseRepository.findFreshAppids(anyCollection(), any(), any(), any())).thenReturn(Set.of(620L)); + + service.refresh(List.of(620L)); + + verify(igdbApiService, never()).getReleaseDates(any()); + } +} diff --git a/src/test/java/core_service/modules/stats/repo/StatsRepositoryTest.java b/src/test/java/core_service/modules/stats/repo/StatsRepositoryTest.java index 34d6a8d..72fc99d 100644 --- a/src/test/java/core_service/modules/stats/repo/StatsRepositoryTest.java +++ b/src/test/java/core_service/modules/stats/repo/StatsRepositoryTest.java @@ -17,6 +17,7 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.context.annotation.Import; import org.springframework.test.util.ReflectionTestUtils; +import java.sql.Date; import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; @@ -239,4 +240,15 @@ class StatsRepositoryTest extends AbstractPersistenceTest { hiddenGames_LeaveTheTopLists(); assertLandingListsIgnoreUser3(); } + + @Test + void biggestBacklog_LeavesOutComingGames() { + steamGameRepository.saveAndFlush(new SteamGame(3000L, "Not out yet", null, Instant.now())); + userGameRepository.saveAllAndFlush(List.of(new UserGame(5L, 220L, 0, Instant.now(), Instant.now()), + new UserGame(5L, 3000L, 0, Instant.now(), Instant.now()))); + jdbc.update("insert into game_release (appid, release_date, fetched_at) values (3000, ?, now())", + Date.valueOf(LocalDate.now(ZoneId.of("Europe/Berlin")).plusDays(1))); + + assertEquals(1, statsRepository.biggestBacklog()); + } } diff --git a/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceTest.java b/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceTest.java index a52ca0d..04de180 100644 --- a/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceTest.java +++ b/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceTest.java @@ -15,6 +15,7 @@ import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; import java.time.Instant; +import java.time.LocalDate; import java.util.List; import java.util.Map; import java.util.Optional; @@ -236,6 +237,35 @@ class SteamApiServiceTest { assertThat(steamApiService.getStoreHeaderUrls(List.of(620L))).isEmpty(); } + @Test + void getReleases_SendsIncludeReleaseAndParsesDateAndComingSoon() { + server.expect(requestTo(containsString("/IStoreBrowseService/GetItems/v1/"))) + .andExpect(queryParam("input_json", containsString("%22data_request%22%3A%7B%22include_release%22%3Atrue%7D"))) + .andRespond(withSuccess(""" + {"response": {"store_items": [ + {"appid": 620, "success": 1, "release": {"steam_release_date": 1796427000, "is_coming_soon": true}}, + {"appid": 700, "success": 1, "release": {"is_coming_soon": true}}, + {"appid": 800, "success": 1, "release": {"steam_release_date": 1493942400}}, + {"appid": 900, "success": 1} + ]}} + """, MediaType.APPLICATION_JSON)); + + Map releases = steamApiService.getReleases(List.of(620L, 700L, 800L, 900L)).orElseThrow(); + + assertThat(releases).containsOnly( + Map.entry(620L, new SteamApiService.StoreRelease(LocalDate.of(2026, 12, 4), true)), + Map.entry(700L, new SteamApiService.StoreRelease(null, true)), + Map.entry(800L, new SteamApiService.StoreRelease(LocalDate.of(2017, 5, 5), false))); + } + + @Test + void getReleases_ServerError_IsEmpty() { + server.expect(requestTo(containsString("/IStoreBrowseService/GetItems/v1/"))) + .andRespond(withServerError()); + + assertThat(steamApiService.getReleases(List.of(620L))).isEmpty(); + } + @Test void getPlayerAchievements_Forbidden_IsEmpty() { server.expect(requestTo(containsString("/ISteamUserStats/GetPlayerAchievements/v1/")))