feat(library): exclude unreleased games from picker and pile
Build, Test & Push Core Service / quality-check (push) Successful in 1m22s
Build, Test & Push Core Service / docker (push) Successful in 1m34s

This commit is contained in:
lobo committed 2026-10-05 20:33:25 +02:00
1 parent 33b230ee91
commit 7b538b7f30
48 files changed
+1238 -69

No files matched your search

@@ -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<Long, Set<Integer>> result = new HashMap<>();
for (int from = 0; from < appids.size(); from += BATCH) {
Map<Long, List<Long>> 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<Long> ids = List.copyOf(igdbIds);
Map<Long, Set<Integer>> 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<Map<Long, LocalDate>> getReleaseDates(Collection<Long> steamAppids) {
if (!isEnabled() || steamAppids.isEmpty()) {
return Optional.empty();
}
try {
List<Long> appids = List.copyOf(steamAppids);
Map<Long, LocalDate> result = new HashMap<>();
for (int from = 0; from < appids.size(); from += BATCH) {
Map<Long, List<Long>> 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<Map<Long, LocalDate>> getReleaseDatesByGameIds(Collection<Long> igdbIds) {
if (!isEnabled() || igdbIds.isEmpty()) {
return Optional.empty();
}
try {
List<Long> ids = List.copyOf(igdbIds);
Map<Long, LocalDate> 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<Long, Integer> yearsOf(Collection<Long> igdbIds) {
private Map<Long, LocalDate> datesOf(Collection<Long> 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<Long, Integer> result = new HashMap<>();
Map<Long, LocalDate> 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<String> names(List<IgdbResponses.Named> named) {
@@ -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<EditionDto> editions
List<EditionDto> editions,
// see GameDto#coming
boolean coming,
// see GameDto#comingOn
LocalDate comingOn
) {
/**
@@ -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
) {}
@@ -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<GameStatus, Long> byStatus,
long neverPlayed,
GameDto suggestion,
long backlogMinutes
long backlogMinutes,
long pileGames
) {}
@@ -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) {}
}
@@ -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,
@@ -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;
}
@@ -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;
}
@@ -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) {}
}
@@ -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;
@@ -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<ComingGame, Long> {
}
@@ -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<String> 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')";
@@ -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<GameRelease, Long> {
/**
* 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<Long> findFreshAppids(@Param("appids") Collection<Long> 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);
}
@@ -104,10 +104,10 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
*
* @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<UserGame> findRandomBacklogGame(@Param("userId") Long userId, @Param("excludeAppid") long excludeAppid);
@@ -120,6 +120,12 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
"and ug.source = core_service.modules.library.domain.GameSource.STEAM")
List<AchievementProgress> 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<UserGame, UserGameId>
"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<UserGame, UserGameId>
* 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
@@ -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<UserStats.StatGame> 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) {
@@ -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<ComingGame> 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)
);
}
}
@@ -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;
@@ -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;
}
@@ -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<Long> 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<Long, Integer> editions = gameLinkRepository.editionCounts(games.getFirst().getUserId(), appids);
Map<Long, ComingGame> 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<Long, ComingGame> comingOf(Collection<Long> appids) {
return comingGameRepository.findAllById(appids).stream().collect(Collectors.toMap(ComingGame::getAppid, c -> c));
}
public AchievementSummaryDto getAchievementSummary(Long userId) {
List<AchievementProgress> games = userGameRepository.findAchievementProgress(userId);
List<AchievementProgress> 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<Long, SteamGame> meta = steamGameRepository.findAllById(Stream.of(playing, finished)
.filter(Objects::nonNull).flatMap(List::stream).map(UserGame::getAppid).toList())
List<Long> shownAppids = Stream.of(playing, finished)
.filter(Objects::nonNull).flatMap(List::stream).map(UserGame::getAppid).toList();
Map<Long, SteamGame> meta = steamGameRepository.findAllById(shownAppids)
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
Map<Long, ComingGame> coming = comingOf(shownAppids);
List<AchievementProgress> started = summary ? AchievementProgress.started(userGameRepository.findAchievementProgress(userId)) : null;
Set<StatGroup> 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<PublicLibraryDto.Game> toPublicGames(List<UserGame> games, Map<Long, SteamGame> meta) {
private static List<PublicLibraryDto.Game> toPublicGames(List<UserGame> games, Map<Long, SteamGame> meta,
Map<Long, ComingGame> coming) {
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<UserSteamSync> getSyncStatus(Long userId) {
@@ -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;
@@ -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;
}
@@ -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<Long> appids) {
if (!igdbApiService.isEnabled() || appids.isEmpty()) return;
Set<Long> stale = stale(appids);
if (stale.isEmpty()) return;
IgdbLookup.Answer<LocalDate> igdb = IgdbLookup.byAppid(stale, igdbApiService::getReleaseDates,
igdbApiService::getReleaseDatesByGameIds);
Set<Long> 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<Long> steamAsk = new TreeSet<>();
for (Long appid : answered) {
if (GameKeys.isSteam(appid) && !igdb.found().containsKey(appid)) steamAsk.add(appid);
}
Optional<Map<Long, SteamApiService.StoreRelease>> 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<Long> 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<Long> stale(Collection<Long> appids) {
return Refreshes.stale(appids, (ids, since) -> gameReleaseRepository.findFreshAppids(ids, since,
LocalDate.now(zone), Instant.now().minus(Duration.ofDays(1))), ttlDays);
}
}
@@ -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<StatGroup> groups) {
Map<GameStatus, Long> byStatus = groups.contains(StatGroup.OVERVIEW) || groups.contains(StatGroup.PILE)
Map<GameStatus, Long> 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));
@@ -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;
@@ -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
) {}
/**
@@ -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<Map<Long, StoreRelease>> getReleases(List<Long> appids) {
Map<Long, StoreRelease> result = new HashMap<>();
try {
for (int from = 0; from < appids.size(); from += STORE_ITEMS_BATCH) {
List<Long> 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<Long, StoreItem> getStoreItemsBatch(List<Long> appids) {
Map<Long, StoreItem> 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<SteamStoreItemsResponse.StoreItem> fetchStoreItems(List<Long> 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<SteamStoreItemsResponse.StoreItem> requestStoreItems(List<Long> 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
+1
View File
@@ -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 * * *"
@@ -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);
@@ -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();
@@ -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\"}]}");
@@ -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");
}
@@ -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();
@@ -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));
}
}
@@ -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)));
}
}
@@ -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));
}
}
@@ -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());
@@ -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(
@@ -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));
}
}
@@ -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<Long> 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();
});
}
}
@@ -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));
}
}
@@ -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<UserStats.StatGame> 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)));
}
}
@@ -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);
@@ -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));
}
@@ -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
@@ -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());
}
}
@@ -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());
}
}
@@ -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<Long, SteamApiService.StoreRelease> 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/")))