Compare commits

...
2 Commits
Author SHA1 Message Date
lobo 117ff61730 feat(picker): add series and studio rows from IGDB credits
Build, Test & Push Core Service / docker (push) Successful in 1m36s
Build, Test & Push Core Service / quality-check (push) Successful in 1m22s
New V37 tables game_credit and game_credit_check store IGDB
collections and developer/publisher companies per game.
A scheduled job backfills existing games (250 per 30 s);
sync, import, manual add, merge and detail page refresh them.
SERIES row: next part of a collection the user played.
STUDIO row: backlog games from developers of top played games.
2026-10-06 20:22:53 +02:00
lobo 8dd398321c feat(picker): return rows of up to 3 games per source
Picker returns categories in fixed order: NEXT_UP, SIMILAR, LONGEST,
RANDOM, each with up to 3 games. Empty rows are left out.
Param `count` is removed; new optional `category` loads one row (reroll).
Budget and exclude apply to all rows; mood only to RANDOM.
SIMILAR takes the 3 most played games, one similar game each.
Remove PickMix.
2026-10-06 19:41:24 +02:00
38 changed files with 1347 additions and 320 deletions

No files matched your search

+14 -7
View File
@@ -21,12 +21,19 @@ To Be Played is split into three repositories:
can be taken over; its own finish then changes nothing. can be taken over; its own finish then changes nothing.
- **Backlog**: status (`BACKLOG`, `PLAYING`, `FINISHED`, `COMPLETED` = 100 %, `DROPPED`), rating 1–5, notes, priority, - **Backlog**: status (`BACKLOG`, `PLAYING`, `FINISHED`, `COMPLETED` = 100 %, `DROPPED`), rating 1–5, notes, priority,
filters and sorting. "Pile of Shame" summary with a random suggestion of what to play. filters and sorting. "Pile of Shame" summary with a random suggestion of what to play.
- **Picker**: up to 10 backlog games for a time budget (IGDB normal time to beat) and a mood, mixed from four - **Picker**: six rows of up to 3 backlog games each, for a time budget (IGDB normal time to beat), in this order:
sources, one per slot in turn: a game sharing moods with a random one of the user's 5 most played games (the `NEXT_UP` (the first games of the user's own backlog order), `SERIES` (next in series: for each of the 6 most played
most shared moods first), the game waiting longest (oldest `firstSeenAt`), and a random one (HIGH priority first, IGDB series the user played, one backlog part, the parts released after the latest played part first), `SIMILAR`
then MEDIUM, then the rest), and a random one of the first 5 games of the user's own backlog order that pass the ("because you played": for each of the 3 most played games the first backlog game sharing its moods, no game twice),
budget, mood and exclude filters (`NEXT_UP`). A source with nothing left gives its slot to a random game; no game appears twice. `STUDIO` (for each of the 6 developers of the user's most played games, one backlog game with a credit of that
Every game carries a `reason` (`source`, plus the anchor game for `SIMILAR` or `firstSeenAt` for `LONGEST`). company, developer credits first; publishers are never anchors, so a big publisher adds at most one card),
`LONGEST` (oldest `firstSeenAt`) and `RANDOM` (the mood row: random, HIGH priority first, then MEDIUM, then the
rest). The mood filters only the `RANDOM` row; budget and exclude filters apply to every row. A row with no game is
left out. A game can show in more than one row. IGDB series (collections) and companies are stored per game
(`game_credit`, `game_credit_check`) and refreshed with the other IGDB data; a scheduled backfill
(`app.library.credit-backfill-*`) fetches them for existing games, 250 games per 30 s, so the two rows fill up over
time after a deploy. Every game carries a `reason` (`source`, plus the anchor game for `SIMILAR`, `seriesName` for
`SERIES`, `studioName` for `STUDIO` or `firstSeenAt` for `LONGEST`).
Games already shown can be excluded for a reroll. Moods `STORY`, `COOP`, 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. `CHILL`, `ACTION`, `SCARY` come from IGDB genres, themes and game modes; `SPEEDRUN` from speedrun.com.
- **Follow users (#27)**: a logged-in user follows people with a public profile by display name (`/api/follows`, at most - **Follow users (#27)**: a logged-in user follows people with a public profile by display name (`/api/follows`, at most
@@ -202,7 +209,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | JWT | | `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | JWT |
| `GET` | `/api/library/backlog?limit=&released=` (`{games}` in the user's order; `limit` 1-20, none = all) | JWT | | `GET` | `/api/library/backlog?limit=&released=` (`{games}` in the user's order; `limit` 1-20, none = all) | JWT |
| `PUT` | `/api/library/backlog` (`{appids}` = the whole order; `204`) | JWT | | `PUT` | `/api/library/backlog` (`{appids}` = the whole order; `204`) | JWT |
| `GET` | `/api/library/picker?maxMinutes=&mood=&count=&exclude=` (each game has a `reason` object) | JWT | | `GET` | `/api/library/picker?maxMinutes=&mood=&exclude=&category=` (`{categories:[{source, games}]}`; each game has a `reason` object; `category` returns that row only) | JWT |
| `GET` | `/api/library/stats?groups=` (`OVERVIEW`, `PILE`, `TOP_GAMES`, `TASTE`, `HABITS`, `FINISHED`, `TREND`, `PLATFORMS`; none = all) | JWT | | `GET` | `/api/library/stats?groups=` (`OVERVIEW`, `PILE`, `TOP_GAMES`, `TASTE`, `HABITS`, `FINISHED`, `TREND`, `PLATFORMS`; none = all) | JWT |
| `GET` | `/api/library/stats/games?key=&arg=&page=` (the games behind a stats number; `400 BAD_ARG`) | JWT | | `GET` | `/api/library/stats/games?key=&arg=&page=` (the games behind a stats number; `400 BAD_ARG`) | JWT |
| `GET` | `/api/library/achievements/summary` | JWT | | `GET` | `/api/library/achievements/summary` | JWT |
@@ -52,6 +52,23 @@ public final class IgdbResponses {
@JsonProperty("game_modes") java.util.List<Named> gameModes @JsonProperty("game_modes") java.util.List<Named> gameModes
) {} ) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record IdNamed(@JsonProperty("id") Long id, @JsonProperty("name") String name) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record InvolvedCompany(
@JsonProperty("company") IdNamed company,
@JsonProperty("developer") Boolean developer,
@JsonProperty("publisher") Boolean publisher
) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Credits(
@JsonProperty("id") Long id,
@JsonProperty("collections") java.util.List<IdNamed> collections,
@JsonProperty("involved_companies") java.util.List<InvolvedCompany> involvedCompanies
) {}
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
public record Edition( public record Edition(
@JsonProperty("id") Long id, @JsonProperty("id") Long id,
@@ -22,6 +22,7 @@ import java.util.Comparator;
import java.util.Collection; import java.util.Collection;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -49,6 +50,17 @@ public class IgdbApiService {
*/ */
public record GameTags(List<String> genres, List<String> themes, List<String> modes) {} public record GameTags(List<String> genres, List<String> themes, List<String> modes) {}
public record Series(long id, String name) {}
public record Company(long id, String name, boolean developer, boolean publisher) {}
/**
* @param series IGDB collections, e.g. "Mass Effect"
* @param companies only those that developed or published, one entry per company; both lists are empty when IGDB
* has none
*/
public record Credits(List<Series> series, List<Company> companies) {}
/** /**
* @param sameGameId the IGDB game all editions, remasters and ports of one game share, see {@link #sameGameId} * @param sameGameId the IGDB game all editions, remasters and ports of one game share, see {@link #sameGameId}
*/ */
@@ -156,6 +168,65 @@ public class IgdbApiService {
} }
} }
/**
* @return the series and companies of the Steam apps IGDB links to a game, empty lists when that game has none;
* apps IGDB does not link are missing. An app linked to several games gets the credits of all of them. Empty when
* the adapter is off or a request failed.
*/
public Optional<Map<Long, Credits>> getCredits(Collection<Long> steamAppids) {
if (!isEnabled() || steamAppids.isEmpty()) {
return Optional.empty();
}
try {
List<Long> appids = List.copyOf(steamAppids);
Map<Long, Credits> 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())));
creditsOf(steamByIgdb.keySet()).forEach((igdbId, credits) ->
steamByIgdb.get(igdbId).forEach(appid -> result.merge(appid, credits, IgdbApiService::union)));
}
return Optional.of(result);
} catch (Exception e) {
log.error("Error communicating with IGDB for credits", e);
return Optional.empty();
}
}
/**
* Like {@link #getCredits}, but for IGDB game ids.
*/
public Optional<Map<Long, Credits>> getCreditsByGameIds(Collection<Long> igdbIds) {
if (!isEnabled() || igdbIds.isEmpty()) {
return Optional.empty();
}
try {
List<Long> ids = List.copyOf(igdbIds);
Map<Long, Credits> result = new HashMap<>();
for (int from = 0; from < ids.size(); from += BATCH) {
result.putAll(creditsOf(ids.subList(from, Math.min(from + BATCH, ids.size()))));
}
return Optional.of(result);
} catch (Exception e) {
log.error("Error communicating with IGDB for credits", e);
return Optional.empty();
}
}
private static Credits union(Credits a, Credits b) {
Map<Long, Series> series = new LinkedHashMap<>();
Stream.concat(a.series().stream(), b.series().stream()).forEach(s -> series.putIfAbsent(s.id(), s));
return new Credits(List.copyOf(series.values()),
byCompany(Stream.concat(a.companies().stream(), b.companies().stream())));
}
// One entry per company, its roles OR-ed.
private static List<Company> byCompany(Stream<Company> companies) {
Map<Long, Company> byId = new LinkedHashMap<>();
companies.forEach(c -> byId.merge(c.id(), c, (x, y) ->
new Company(x.id(), x.name(), x.developer() || y.developer(), x.publisher() || y.publisher())));
return List.copyOf(byId.values());
}
private static GameTags union(GameTags a, GameTags b) { private static GameTags union(GameTags a, GameTags b) {
return new GameTags(union(a.genres(), b.genres()), union(a.themes(), b.themes()), union(a.modes(), b.modes())); return new GameTags(union(a.genres(), b.genres()), union(a.themes(), b.themes()), union(a.modes(), b.modes()));
} }
@@ -470,6 +541,44 @@ public class IgdbApiService {
return result; return result;
} }
private Map<Long, Credits> creditsOf(Collection<Long> igdbIds) {
if (igdbIds.isEmpty()) {
return Map.of();
}
String gameIds = igdbIds.stream().map(String::valueOf).collect(Collectors.joining(","));
IgdbResponses.Credits[] games = query("/games",
"fields collections.name,involved_companies.company.name,involved_companies.developer,"
+ "involved_companies.publisher; where id = (" + gameIds + "); limit " + LIMIT + ";",
IgdbResponses.Credits[].class);
Map<Long, Credits> result = new HashMap<>();
for (IgdbResponses.Credits game : games) {
if (game.id() != null && igdbIds.contains(game.id())) {
result.put(game.id(), new Credits(seriesOf(game.collections()), companiesOf(game.involvedCompanies())));
}
}
return result;
}
private static List<Series> seriesOf(List<IgdbResponses.IdNamed> collections) {
return collections == null ? List.of() : collections.stream()
.filter(IgdbApiService::hasName)
.map(c -> new Series(c.id(), c.name().trim()))
.toList();
}
// Only developers and publishers; porting or supporting companies are dropped.
private static List<Company> companiesOf(List<IgdbResponses.InvolvedCompany> involved) {
return involved == null ? List.of() : byCompany(involved.stream()
.filter(i -> hasName(i.company()))
.map(i -> new Company(i.company().id(), i.company().name().trim(),
Boolean.TRUE.equals(i.developer()), Boolean.TRUE.equals(i.publisher())))
.filter(c -> c.developer() || c.publisher()));
}
private static boolean hasName(IgdbResponses.IdNamed named) {
return named != null && named.id() != null && named.name() != null && !named.name().isBlank();
}
private Map<Long, Edition> editionsOf(Collection<Long> igdbIds) { private Map<Long, Edition> editionsOf(Collection<Long> igdbIds) {
if (igdbIds.isEmpty()) { if (igdbIds.isEmpty()) {
return Map.of(); return Map.of();
@@ -39,6 +39,7 @@ import core_service.modules.library.domain.CatalogHit;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.LinkPair; import core_service.modules.library.domain.LinkPair;
import core_service.modules.library.domain.Mood; import core_service.modules.library.domain.Mood;
import core_service.modules.library.domain.PickSource;
import core_service.modules.library.domain.StatGroup; import core_service.modules.library.domain.StatGroup;
import core_service.modules.library.domain.StatKey; import core_service.modules.library.domain.StatKey;
import core_service.modules.library.domain.SyncChange; import core_service.modules.library.domain.SyncChange;
@@ -96,7 +97,6 @@ public class LibraryController {
private static final int DEFAULT_SIZE = 20; private static final int DEFAULT_SIZE = 20;
private static final int MIN_ACTIVITY_DAYS = 7; private static final int MIN_ACTIVITY_DAYS = 7;
private static final int MAX_ACTIVITY_DAYS = 90; private static final int MAX_ACTIVITY_DAYS = 90;
private static final int MAX_PICKER_GAMES = 10;
private static final int MAX_PICKER_EXCLUDES = 100; private static final int MAX_PICKER_EXCLUDES = 100;
private static final int MAX_BACKLOG_GAMES = 20; private static final int MAX_BACKLOG_GAMES = 20;
private static final Set<String> SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority"); private static final Set<String> SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority");
@@ -488,14 +488,13 @@ public class LibraryController {
@RequestParam(required = false) Integer maxMinutes, @RequestParam(required = false) Integer maxMinutes,
@RequestParam(required = false) Mood mood, @RequestParam(required = false) Mood mood,
@RequestParam(required = false) List<Long> exclude, @RequestParam(required = false) List<Long> exclude,
@RequestParam(defaultValue = "3") int count) { @RequestParam(required = false) PickSource category) {
List<Long> excluded = exclude == null ? List.of() List<Long> excluded = exclude == null ? List.of()
: exclude.stream().filter(Objects::nonNull).limit(MAX_PICKER_EXCLUDES).toList(); : exclude.stream().filter(Objects::nonNull).limit(MAX_PICKER_EXCLUDES).toList();
int safeCount = Math.clamp(count, 1, MAX_PICKER_GAMES);
Integer budget = maxMinutes != null && maxMinutes > 0 ? maxMinutes : null; Integer budget = maxMinutes != null && maxMinutes > 0 ? maxMinutes : null;
Long userId = principal.getIdAsLong(); Long userId = principal.getIdAsLong();
principal.steamIdText().ifPresent(steamId -> syncService.syncIfStale(userId, steamId)); principal.steamIdText().ifPresent(steamId -> syncService.syncIfStale(userId, steamId));
return ResponseEntity.ok(queryService.pickBacklogGames(userId, budget, mood, excluded, safeCount)); return ResponseEntity.ok(queryService.pickBacklogGames(userId, budget, mood, excluded, category));
} }
/** /**
@@ -6,27 +6,36 @@ import core_service.modules.library.domain.UserGame;
import java.time.Instant; import java.time.Instant;
/** /**
* Why the picker chose a game. The anchor fields are set only for SIMILAR, {@code firstSeenAt} only for LONGEST; * Why the picker chose a game. The anchor fields are set only for SIMILAR, {@code firstSeenAt} only for LONGEST,
* the others are null. * {@code seriesName} only for SERIES, {@code studioName} only for STUDIO; the others are null.
* *
* @param anchorName null when the anchor game has no name * @param anchorName null when the anchor game has no name
*/ */
public record PickReasonDto(PickSource source, Long anchorAppid, String anchorName, Integer anchorPlaytimeMinutes, public record PickReasonDto(PickSource source, Long anchorAppid, String anchorName, Integer anchorPlaytimeMinutes,
Instant firstSeenAt) { Instant firstSeenAt, String seriesName, String studioName) {
public static PickReasonDto similar(UserGame anchor, String anchorName) { public static PickReasonDto similar(UserGame anchor, String anchorName) {
return new PickReasonDto(PickSource.SIMILAR, anchor.getAppid(), anchorName, anchor.getPlaytimeMinutes(), null); return new PickReasonDto(PickSource.SIMILAR, anchor.getAppid(), anchorName, anchor.getPlaytimeMinutes(), null,
null, null);
} }
public static PickReasonDto longest(Instant firstSeenAt) { public static PickReasonDto longest(Instant firstSeenAt) {
return new PickReasonDto(PickSource.LONGEST, null, null, null, firstSeenAt); return new PickReasonDto(PickSource.LONGEST, null, null, null, firstSeenAt, null, null);
} }
public static PickReasonDto random() { public static PickReasonDto random() {
return new PickReasonDto(PickSource.RANDOM, null, null, null, null); return new PickReasonDto(PickSource.RANDOM, null, null, null, null, null, null);
} }
public static PickReasonDto nextUp() { public static PickReasonDto nextUp() {
return new PickReasonDto(PickSource.NEXT_UP, null, null, null, null); return new PickReasonDto(PickSource.NEXT_UP, null, null, null, null, null, null);
}
public static PickReasonDto series(String seriesName) {
return new PickReasonDto(PickSource.SERIES, null, null, null, null, seriesName, null);
}
public static PickReasonDto studio(String studioName) {
return new PickReasonDto(PickSource.STUDIO, null, null, null, null, null, studioName);
} }
} }
@@ -0,0 +1,7 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.PickSource;
import java.util.List;
public record PickerCategoryDto(PickSource source, List<PickedGameDto> games) {}
@@ -2,4 +2,4 @@ package core_service.modules.library.api.dto;
import java.util.List; import java.util.List;
public record PickerDto(List<PickedGameDto> games) {} public record PickerDto(List<PickerCategoryDto> categories) {}
@@ -0,0 +1,7 @@
package core_service.modules.library.domain;
public enum CreditKind {
SERIES,
DEVELOPER,
PUBLISHER
}
@@ -0,0 +1,39 @@
package core_service.modules.library.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* One series or company of a library key, from IGDB (V37). A company that developed and published has one DEVELOPER row.
*/
@Entity
@Table(name = "game_credit")
@IdClass(GameCreditId.class)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class GameCredit {
@Id
@Column(name = "appid")
private Long appid;
@Id
@Enumerated(EnumType.STRING)
@Column(name = "kind", nullable = false, length = 16)
private CreditKind kind;
@Id
@Column(name = "igdb_id")
private Long igdbId;
@Column(name = "name", nullable = false, length = 200)
private String name;
}
@@ -0,0 +1,28 @@
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 java.time.Instant;
/**
* When IGDB was asked for the credits of one library key (V37). No {@link GameCredit} rows after a check: IGDB knows none.
*/
@Entity
@Table(name = "game_credit_check")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class GameCreditCheck {
@Id
@Column(name = "appid")
private Long appid;
@Column(name = "checked_at", nullable = false)
private Instant checkedAt;
}
@@ -0,0 +1,20 @@
package core_service.modules.library.domain;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.io.Serializable;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
public class GameCreditId implements Serializable {
private Long appid;
private CreditKind kind;
private Long igdbId;
}
@@ -1,47 +0,0 @@
package core_service.modules.library.domain;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* Mixes the picker's four candidate lists into one list of picks.
*/
public final class PickMix {
public record Pick(UserGame game, PickSource source) {}
private PickMix() {}
/**
* Slot i takes the first unused game of source i modulo the number of sources; when that source has none, the
* first unused random game.
* Stops early when no game is left.
*
* @param count the most picks
*/
public static List<Pick> mix(List<UserGame> similar, List<UserGame> longest, List<UserGame> random,
List<UserGame> nextUp, int count) {
List<List<UserGame>> bySource = List.of(similar, longest, random, nextUp);
Set<Long> used = new HashSet<>();
List<Pick> picks = new ArrayList<>();
PickSource[] sources = PickSource.values();
for (int slot = 0; picks.size() < count; slot++) {
PickSource source = sources[slot % sources.length];
UserGame game = firstUnused(bySource.get(source.ordinal()), used);
if (game == null) {
source = PickSource.RANDOM;
game = firstUnused(random, used);
}
if (game == null) break;
used.add(game.getAppid());
picks.add(new Pick(game, source));
}
return picks;
}
private static UserGame firstUnused(List<UserGame> games, Set<Long> used) {
return games.stream().filter(g -> !used.contains(g.getAppid())).findFirst().orElse(null);
}
}
@@ -1,11 +1,15 @@
package core_service.modules.library.domain; package core_service.modules.library.domain;
/** /**
* Where a picker game came from. The order of the constants is the order of the picker's slots. * Where a picker game came from, and the picker row it is shown in. The order of the constants is the order of the
* rows. SERIES is the next part of a played series, STUDIO a game by a developer the user played. RANDOM is the mood
* row.
*/ */
public enum PickSource { public enum PickSource {
NEXT_UP,
SERIES,
SIMILAR, SIMILAR,
STUDIO,
LONGEST, LONGEST,
RANDOM, RANDOM
NEXT_UP
} }
@@ -0,0 +1,10 @@
package core_service.modules.library.domain;
import java.time.LocalDate;
/**
* A series the user played a part of.
*
* @param lastPlayedRelease release date of the latest played part; null when no played part has a date
*/
public record PlayedSeries(long seriesId, String name, LocalDate lastPlayedRelease) {}
@@ -0,0 +1,6 @@
package core_service.modules.library.domain;
/**
* A developer of games the user played.
*/
public record PlayedStudio(long companyId, String name) {}
@@ -0,0 +1,33 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GameCreditCheck;
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.util.Collection;
import java.util.List;
import java.util.Set;
public interface GameCreditCheckRepository extends JpaRepository<GameCreditCheck, Long> {
@Query("select c.appid from GameCreditCheck c where c.appid in :appids and c.checkedAt > :since")
Set<Long> findFreshAppids(@Param("appids") Collection<Long> appids, @Param("since") Instant since);
@Modifying
@Transactional
@Query(value = "insert into game_credit_check (appid, checked_at) values (:appid, :checkedAt) "
+ "on conflict (appid) do update set checked_at = excluded.checked_at", nativeQuery = true)
void upsert(@Param("appid") Long appid, @Param("checkedAt") Instant checkedAt);
/**
* The listed games (not removed, not hidden by any user row) that were never checked, lowest appid first.
*/
@Query(value = "select distinct ug.appid from user_game ug where ug.removed_at is null and ug.hidden_at is null "
+ "and not exists (select 1 from game_credit_check c where c.appid = ug.appid) "
+ "order by ug.appid limit :limit", nativeQuery = true)
List<Long> findUncheckedAppids(@Param("limit") int limit);
}
@@ -0,0 +1,58 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GameCredit;
import core_service.modules.library.domain.GameCreditId;
import core_service.modules.library.domain.PlayedSeries;
import core_service.modules.library.domain.PlayedStudio;
import org.springframework.data.domain.Pageable;
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.util.List;
public interface GameCreditRepository extends JpaRepository<GameCredit, GameCreditId> {
@Modifying
@Transactional
@Query("delete from GameCredit c where c.appid = :appid")
void deleteByAppid(@Param("appid") Long appid);
@Modifying
@Transactional
@Query(value = "insert into game_credit (appid, kind, igdb_id, name) values (:appid, :kind, :igdbId, :name) "
+ "on conflict (appid, kind, igdb_id) do update set name = excluded.name", nativeQuery = true)
void insert(@Param("appid") Long appid, @Param("kind") String kind, @Param("igdbId") Long igdbId,
@Param("name") String name);
/**
* The series of the games the user played (playing, finished, completed, or dropped with playtime; a backlog game
* never counts), the most played first.
*/
@Query("select new core_service.modules.library.domain.PlayedSeries(c.igdbId, max(c.name), max(r.releaseDate)) " +
"from CountedUserGame ug join GameCredit c on c.appid = ug.appid " +
"left join GameRelease r on r.appid = ug.appid " +
"where ug.userId = :userId and c.kind = core_service.modules.library.domain.CreditKind.SERIES " +
"and (ug.status in (core_service.modules.library.domain.GameStatus.PLAYING, " +
"core_service.modules.library.domain.GameStatus.FINISHED, " +
"core_service.modules.library.domain.GameStatus.COMPLETED) " +
"or (ug.status = core_service.modules.library.domain.GameStatus.DROPPED and ug.playtimeMinutes > 0)) " +
"group by c.igdbId order by sum(ug.playtimeMinutes) desc, c.igdbId")
List<PlayedSeries> findPlayedSeries(@Param("userId") Long userId, Pageable pageable);
/**
* The developers of the games the user played (same rule as the series: a backlog game never counts), the most
* played first.
*/
@Query("select new core_service.modules.library.domain.PlayedStudio(c.igdbId, max(c.name)) " +
"from CountedUserGame ug join GameCredit c on c.appid = ug.appid " +
"where ug.userId = :userId and c.kind = core_service.modules.library.domain.CreditKind.DEVELOPER " +
"and (ug.status in (core_service.modules.library.domain.GameStatus.PLAYING, " +
"core_service.modules.library.domain.GameStatus.FINISHED, " +
"core_service.modules.library.domain.GameStatus.COMPLETED) " +
"or (ug.status = core_service.modules.library.domain.GameStatus.DROPPED and ug.playtimeMinutes > 0)) " +
"group by c.igdbId order by sum(ug.playtimeMinutes) desc, c.igdbId")
List<PlayedStudio> findPlayedStudios(@Param("userId") Long userId, Pageable pageable);
}
@@ -28,7 +28,7 @@ public class GameMergeRepository {
// The shared rows of a game; steam_game last, since user_game refers to it. // 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", private static final List<String> CATALOG_TABLES = List.of("game_mood", "game_speedrun", "game_time_to_beat",
"game_correction", "broken_image", "game_edition", "game_release", "steam_game"); "game_correction", "broken_image", "game_edition", "game_release", "game_credit", "game_credit_check", "steam_game");
// The paid game kinds; a merge moves their ref, an undo moves it back. // 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')"; private static final String GAME_KINDS = "('STARTED', 'FINISHED', 'FINISHED_MANUAL', 'COMPLETED')";
@@ -18,6 +18,7 @@ import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.EnumMap; import java.util.EnumMap;
@@ -245,6 +246,56 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
@Param("excludeAppids") Collection<Long> excludeAppids, @Param("excludeAppids") Collection<Long> excludeAppids,
@Param("count") int count); @Param("count") int count);
/**
* Backlog games of one series, the parts released after {@code afterDate} first (earliest first), then the rest by
* release date, unknown dates last. afterDate null: earliest release first.
*/
default List<UserGame> findSeriesGames(Long userId, long seriesId, LocalDate afterDate, Integer maxMinutes,
Collection<Long> excludeAppids, int count) {
return querySeriesGames(userId, seriesId, afterDate, maxMinutes, null, false, excludedOrNone(excludeAppids),
count);
}
@Query(value = "select ug.* from counted_user_game ug " +
"join game_credit c on c.appid = ug.appid and c.kind = 'SERIES' and c.igdb_id = :seriesId " +
"left join game_release r on r.appid = ug.appid " +
"where " + PICKER_FILTERS +
"order by (r.release_date is not null and (cast(:afterDate as date) is null " +
"or r.release_date > :afterDate)) desc, r.release_date asc nulls last, ug.appid limit :count",
nativeQuery = true)
List<UserGame> querySeriesGames(@Param("userId") Long userId,
@Param("seriesId") long seriesId,
@Param("afterDate") LocalDate afterDate,
@Param("maxMinutes") Integer maxMinutes,
@Param("mood") String mood,
@Param("speedrun") boolean speedrun,
@Param("excludeAppids") Collection<Long> excludeAppids,
@Param("count") int count);
/**
* Backlog games with a developer or publisher credit of one company, developer credits first, then HIGH priority
* first.
*/
default List<UserGame> findStudioGames(Long userId, long companyId, Integer maxMinutes,
Collection<Long> excludeAppids, int count) {
return queryStudioGames(userId, companyId, maxMinutes, null, false, excludedOrNone(excludeAppids), count);
}
// A company is one credit row per game (the developer row wins), so a game is listed once.
@Query(value = "select ug.* from counted_user_game ug " +
"join game_credit c on c.appid = ug.appid and c.igdb_id = :companyId " +
"and c.kind in ('DEVELOPER', 'PUBLISHER') " +
"where " + PICKER_FILTERS +
"order by case c.kind when 'DEVELOPER' then 0 else 1 end, " + PRIORITY_ORDER +
", random() limit :count", nativeQuery = true)
List<UserGame> queryStudioGames(@Param("userId") Long userId,
@Param("companyId") long companyId,
@Param("maxMinutes") Integer maxMinutes,
@Param("mood") String mood,
@Param("speedrun") boolean speedrun,
@Param("excludeAppids") Collection<Long> excludeAppids,
@Param("count") int count);
/** /**
* The games a user played most, any status, never the ones with no playtime. * The games a user played most, any status, never the ones with no playtime.
*/ */
@@ -0,0 +1,109 @@
package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.domain.CreditKind;
import core_service.modules.library.repo.GameCreditCheckRepository;
import core_service.modules.library.repo.GameCreditRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.task.TaskExecutor;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.Instant;
import java.util.Collection;
import java.util.List;
import java.util.Set;
/**
* Stores the IGDB series (collections) and the developers and publishers per library key, the same way
* {@link GameEditionService} stores editions: only for games without a fresh check, and nothing when IGDB fails.
* A company that developed and published a game is stored once, as developer. Companies that only ported or
* supported are not stored.
* <p>
* {@link #backfill} gives the games that were never checked their credits in batches, so the IGDB rate limit holds.
*/
@Service
@Slf4j
public class GameCreditService {
private static final int NAME_LENGTH = 200;
private final IgdbApiService igdbApiService;
private final GameCreditRepository gameCreditRepository;
private final GameCreditCheckRepository gameCreditCheckRepository;
private final TaskExecutor applicationTaskExecutor;
private final TransactionTemplate transaction;
private final long ttlDays;
private final int backfillBatch;
public GameCreditService(IgdbApiService igdbApiService, GameCreditRepository gameCreditRepository,
GameCreditCheckRepository gameCreditCheckRepository, TaskExecutor applicationTaskExecutor,
PlatformTransactionManager transactionManager,
@Value("${app.library.credit-ttl-days:30}") long ttlDays,
@Value("${app.library.credit-backfill-batch:250}") int backfillBatch) {
this.igdbApiService = igdbApiService;
this.gameCreditRepository = gameCreditRepository;
this.gameCreditCheckRepository = gameCreditCheckRepository;
this.applicationTaskExecutor = applicationTaskExecutor;
this.transaction = new TransactionTemplate(transactionManager);
this.ttlDays = ttlDays;
this.backfillBatch = backfillBatch;
}
public void refresh(Collection<Long> appids) {
if (!igdbApiService.isEnabled() || appids.isEmpty()) return;
Set<Long> stale = stale(appids);
if (stale.isEmpty()) return;
IgdbLookup.Answer<IgdbApiService.Credits> answer = IgdbLookup.byAppid(stale, igdbApiService::getCredits,
igdbApiService::getCreditsByGameIds);
if (answer.answered().size() < stale.size()) {
log.warn("Credits of {} games not refreshed, IGDB did not answer", stale.size() - answer.answered().size());
}
Instant now = Instant.now();
for (Long appid : answer.answered()) {
IgdbApiService.Credits credits = answer.found().get(appid);
// Replaced as a whole, in one transaction per game: a reader never sees half of the new credits.
transaction.executeWithoutResult(tx -> {
gameCreditRepository.deleteByAppid(appid);
if (credits != null) {
credits.series().forEach(s -> insert(appid, CreditKind.SERIES, s.id(), s.name()));
credits.companies().forEach(c -> insert(appid,
c.developer() ? CreditKind.DEVELOPER : CreditKind.PUBLISHER, c.id(), c.name()));
}
gameCreditCheckRepository.upsert(appid, now);
});
}
}
/**
* Same as {@link #refresh}, but the request does not wait for IGDB, and an error does not fail it.
*/
public void refreshInBackground(Collection<Long> appids) {
if (!igdbApiService.isEnabled()) return;
Refreshes.staleInBackground(applicationTaskExecutor, "Credits of " + appids, () -> appids, this::stale,
this::refresh);
}
@Scheduled(initialDelayString = "${app.library.credit-backfill-ms:30000}",
fixedDelayString = "${app.library.credit-backfill-ms:30000}")
public void backfill() {
if (!igdbApiService.isEnabled()) return;
List<Long> appids = gameCreditCheckRepository.findUncheckedAppids(backfillBatch);
if (appids.isEmpty()) return;
log.info("Backfilling credits of {} games", appids.size());
Refreshes.quietly("Credit backfill", () -> refresh(appids));
}
private void insert(Long appid, CreditKind kind, long igdbId, String name) {
gameCreditRepository.insert(appid, kind.name(), igdbId,
name.length() > NAME_LENGTH ? name.substring(0, NAME_LENGTH) : name);
}
private Set<Long> stale(Collection<Long> appids) {
return Refreshes.stale(appids, gameCreditCheckRepository::findFreshAppids, ttlDays);
}
}
@@ -66,6 +66,7 @@ public class GameDetailService {
private final MoodService moodService; private final MoodService moodService;
private final ReleaseDateService releaseDateService; private final ReleaseDateService releaseDateService;
private final GameEditionService gameEditionService; private final GameEditionService gameEditionService;
private final GameCreditService gameCreditService;
private final GameSpeedrunRepository gameSpeedrunRepository; private final GameSpeedrunRepository gameSpeedrunRepository;
private final SpeedrunService speedrunService; private final SpeedrunService speedrunService;
private final GameMoodsRepository gameMoodsRepository; private final GameMoodsRepository gameMoodsRepository;
@@ -116,6 +117,7 @@ public class GameDetailService {
// Only backlog games are checked in bulk; any other game gets its link on a later visit. // Only backlog games are checked in bulk; any other game gets its link on a later visit.
speedrunService.refreshInBackground(List.of(appid)); speedrunService.refreshInBackground(List.of(appid));
gameEditionService.refreshInBackground(List.of(appid)); gameEditionService.refreshInBackground(List.of(appid));
gameCreditService.refreshInBackground(List.of(appid));
} }
if (userGame.getSource() == GameSource.MANUAL) { if (userGame.getSource() == GameSource.MANUAL) {
// Users without a Steam ID never sync, so the time to beat of their games is asked again here. // Users without a Steam ID never sync, so the time to beat of their games is asked again here.
@@ -53,6 +53,7 @@ public class GameImportWorker {
private final TimeToBeatService timeToBeatService; private final TimeToBeatService timeToBeatService;
private final MoodService moodService; private final MoodService moodService;
private final ReleaseDateService releaseDateService; private final ReleaseDateService releaseDateService;
private final GameCreditService gameCreditService;
private final Duration stallTime; private final Duration stallTime;
// Only a try that started before this app is one a restart stopped. // Only a try that started before this app is one a restart stopped.
private final Instant startedAt = Instant.now(); private final Instant startedAt = Instant.now();
@@ -61,13 +62,14 @@ public class GameImportWorker {
public GameImportWorker(GameImportRepository importRepository, ManualGameService manualGameService, public GameImportWorker(GameImportRepository importRepository, ManualGameService manualGameService,
TimeToBeatService timeToBeatService, MoodService moodService, TimeToBeatService timeToBeatService, MoodService moodService,
ReleaseDateService releaseDateService, ReleaseDateService releaseDateService, GameCreditService gameCreditService,
@Value("${app.library.import-stall-minutes:5}") long stallMinutes) { @Value("${app.library.import-stall-minutes:5}") long stallMinutes) {
this.importRepository = importRepository; this.importRepository = importRepository;
this.manualGameService = manualGameService; this.manualGameService = manualGameService;
this.timeToBeatService = timeToBeatService; this.timeToBeatService = timeToBeatService;
this.moodService = moodService; this.moodService = moodService;
this.releaseDateService = releaseDateService; this.releaseDateService = releaseDateService;
this.gameCreditService = gameCreditService;
this.stallTime = Duration.ofMinutes(stallMinutes); this.stallTime = Duration.ofMinutes(stallMinutes);
this.matching = new Lane("game-import-match", MATCHING_QUEUE, this::match); this.matching = new Lane("game-import-match", MATCHING_QUEUE, this::match);
this.saving = new Lane("game-import-save", SAVING_QUEUE, this::save); this.saving = new Lane("game-import-save", SAVING_QUEUE, this::save);
@@ -163,9 +165,11 @@ public class GameImportWorker {
timeToBeatService.refresh(appids); timeToBeatService.refresh(appids);
moodService.refresh(appids); moodService.refresh(appids);
releaseDateService.refresh(appids); releaseDateService.refresh(appids);
gameCreditService.refresh(appids);
} catch (RuntimeException e) { } catch (RuntimeException e) {
// The next sync or page view asks again. // The next sync or page view asks again.
log.warn("Time to beat, moods or release dates of import {} not loaded: {}", importId, e.getMessage()); log.warn("Time to beat, moods, release dates or credits of import {} not loaded: {}", importId,
e.getMessage());
} }
if (Thread.currentThread().isInterrupted()) { if (Thread.currentThread().isInterrupted()) {
return; return;
@@ -69,17 +69,20 @@ public class GameMergeService {
private final TimeToBeatService timeToBeatService; private final TimeToBeatService timeToBeatService;
private final MoodService moodService; private final MoodService moodService;
private final ReleaseDateService releaseDateService; private final ReleaseDateService releaseDateService;
private final GameCreditService gameCreditService;
private final TransactionTemplate transaction; private final TransactionTemplate transaction;
public GameMergeService(GameMergeRepository mergeRepository, AdminAuditRepository adminAuditRepository, public GameMergeService(GameMergeRepository mergeRepository, AdminAuditRepository adminAuditRepository,
TimeToBeatService timeToBeatService, TimeToBeatService timeToBeatService,
MoodService moodService, ReleaseDateService releaseDateService, MoodService moodService, ReleaseDateService releaseDateService,
GameCreditService gameCreditService,
PlatformTransactionManager transactionManager) { PlatformTransactionManager transactionManager) {
this.mergeRepository = mergeRepository; this.mergeRepository = mergeRepository;
this.adminAuditRepository = adminAuditRepository; this.adminAuditRepository = adminAuditRepository;
this.timeToBeatService = timeToBeatService; this.timeToBeatService = timeToBeatService;
this.moodService = moodService; this.moodService = moodService;
this.releaseDateService = releaseDateService; this.releaseDateService = releaseDateService;
this.gameCreditService = gameCreditService;
this.transaction = new TransactionTemplate(transactionManager); this.transaction = new TransactionTemplate(transactionManager);
} }
@@ -114,6 +117,7 @@ public class GameMergeService {
timeToBeatService.refreshInBackground(List.of(to)); timeToBeatService.refreshInBackground(List.of(to));
moodService.refreshInBackground(List.of(to)); moodService.refreshInBackground(List.of(to));
releaseDateService.refreshInBackground(List.of(to)); releaseDateService.refreshInBackground(List.of(to));
gameCreditService.refreshInBackground(List.of(to));
} }
return result; return result;
} }
@@ -7,6 +7,7 @@ import core_service.modules.library.api.dto.GameDto;
import core_service.modules.library.api.dto.LibrarySummaryDto; import core_service.modules.library.api.dto.LibrarySummaryDto;
import core_service.modules.library.api.dto.PickReasonDto; import core_service.modules.library.api.dto.PickReasonDto;
import core_service.modules.library.api.dto.PickedGameDto; import core_service.modules.library.api.dto.PickedGameDto;
import core_service.modules.library.api.dto.PickerCategoryDto;
import core_service.modules.library.api.dto.PickerDto; import core_service.modules.library.api.dto.PickerDto;
import core_service.modules.library.api.dto.PublicLibraryDto; import core_service.modules.library.api.dto.PublicLibraryDto;
import core_service.modules.library.api.dto.RecentGameDto; import core_service.modules.library.api.dto.RecentGameDto;
@@ -19,7 +20,6 @@ import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.GameTimeToBeat; import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.Mood; import core_service.modules.library.domain.Mood;
import core_service.modules.library.domain.PickMix;
import core_service.modules.library.domain.PickSource; import core_service.modules.library.domain.PickSource;
import core_service.modules.library.domain.ProfileSection; import core_service.modules.library.domain.ProfileSection;
import core_service.modules.library.domain.StatGroup; import core_service.modules.library.domain.StatGroup;
@@ -29,6 +29,7 @@ import core_service.modules.library.domain.UserGameAchievementMeta;
import core_service.modules.library.domain.UserSteamSync; import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.repo.ComingGameRepository; import core_service.modules.library.repo.ComingGameRepository;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository; import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameCreditRepository;
import core_service.modules.library.repo.GameLinkRepository; import core_service.modules.library.repo.GameLinkRepository;
import core_service.modules.library.repo.GameTimeToBeatRepository; import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.PlaytimeDayRepository; import core_service.modules.library.repo.PlaytimeDayRepository;
@@ -54,14 +55,15 @@ import java.time.LocalDate;
import java.time.ZoneId; import java.time.ZoneId;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.Collections;
import java.util.Comparator; import java.util.Comparator;
import java.util.EnumMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ThreadLocalRandom; import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
@@ -75,8 +77,9 @@ public class LibraryQueryService {
private static final int PUBLIC_FINISHED = 12; private static final int PUBLIC_FINISHED = 12;
private static final int CLOSEST_GAMES = 5; private static final int CLOSEST_GAMES = 5;
private static final int RECENT_GAMES = 5; private static final int RECENT_GAMES = 5;
private static final int ANCHOR_CANDIDATES = 5; private static final int ROW_SIZE = 3;
private static final int NEXT_UP_CANDIDATES = 5; // Played series or studios a row looks at; each gives at most one card.
private static final int ANCHORS = 6;
private final UserGameRepository userGameRepository; private final UserGameRepository userGameRepository;
private final UserSteamSyncRepository userSteamSyncRepository; private final UserSteamSyncRepository userSteamSyncRepository;
@@ -92,6 +95,7 @@ public class LibraryQueryService {
private final ReleaseDateService releaseDateService; private final ReleaseDateService releaseDateService;
private final SpeedrunService speedrunService; private final SpeedrunService speedrunService;
private final GameLinkRepository gameLinkRepository; private final GameLinkRepository gameLinkRepository;
private final GameCreditRepository gameCreditRepository;
@Value("${app.library.zone:Europe/Berlin}") @Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone; private ZoneId zone;
@@ -111,28 +115,37 @@ public class LibraryQueryService {
} }
/** /**
* Mixes four sources, one per slot in turn: a backlog game like a game the user played a lot, the game waiting * One row per {@link PickSource} (next up, next in series, because you played, same studio, longest waiting,
* longest, a random one (HIGH priority first), and a random one of the first five of the user's own backlog * mood), in the order of its constants, each with up to three backlog games. Budget and exclude apply to every
* order. A source with nothing left gives its slot to a random game. * row; the mood only to the RANDOM row. Rows without a game are left out.
* With a mood, also refreshes the backlog's moods or speedrun links in the background for the next pick. * With a mood, also refreshes the backlog's moods or speedrun links in the background for the next pick.
*
* @param only the one row to build (a reroll); null builds all rows
*/ */
public PickerDto pickBacklogGames(Long userId, Integer maxMinutes, Mood mood, List<Long> excludeAppids, int count) { public PickerDto pickBacklogGames(Long userId, Integer maxMinutes, Mood mood, List<Long> excludeAppids,
PickSource only) {
releaseDateService.refreshBacklogInBackground(userId); releaseDateService.refreshBacklogInBackground(userId);
if (mood == Mood.SPEEDRUN) { if (mood == Mood.SPEEDRUN) {
speedrunService.refreshBacklogInBackground(userId); speedrunService.refreshBacklogInBackground(userId);
} else if (mood != null) { } else if (mood != null) {
moodService.refreshBacklogInBackground(userId); moodService.refreshBacklogInBackground(userId);
} }
List<UserGame> random = userGameRepository.findPickerGames(userId, maxMinutes, mood, excludeAppids, count); List<PickSource> sources = only == null ? List.of(PickSource.values()) : List.of(only);
List<UserGame> longest = userGameRepository.findLongestWaitingGames(userId, maxMinutes, mood, excludeAppids, count); Map<PickSource, List<Map.Entry<UserGame, PickReasonDto>>> rows = new EnumMap<>(PickSource.class);
Optional<UserGame> anchor = pickAnchor(userId); for (PickSource source : sources) {
List<UserGame> similar = anchor rows.put(source, switch (source) {
.map(a -> userGameRepository.findSimilarGames(userId, a.getAppid(), maxMinutes, mood, excludeAppids, count)) case NEXT_UP -> withReason(userGameRepository.findNextUpGames(userId, maxMinutes, null,
.orElse(List.of()); excludeAppids, ROW_SIZE), g -> PickReasonDto.nextUp());
List<UserGame> nextUp = new ArrayList<>(userGameRepository.findNextUpGames(userId, maxMinutes, mood, case SERIES -> nextInSeries(userId, maxMinutes, excludeAppids);
excludeAppids, NEXT_UP_CANDIDATES)); case SIMILAR -> becauseYouPlayed(userId, maxMinutes, excludeAppids);
Collections.shuffle(nextUp); case STUDIO -> sameStudio(userId, maxMinutes, excludeAppids);
return new PickerDto(withReasons(PickMix.mix(similar, longest, random, nextUp, count), anchor)); case LONGEST -> withReason(userGameRepository.findLongestWaitingGames(userId, maxMinutes, null,
excludeAppids, ROW_SIZE), g -> PickReasonDto.longest(g.getFirstSeenAt()));
case RANDOM -> withReason(userGameRepository.findPickerGames(userId, maxMinutes, mood,
excludeAppids, ROW_SIZE), g -> PickReasonDto.random());
});
}
return new PickerDto(toCategories(rows));
} }
/** /**
@@ -146,34 +159,78 @@ public class LibraryQueryService {
limit == null ? Integer.MAX_VALUE : limit))); limit == null ? Integer.MAX_VALUE : limit)));
} }
private List<PickedGameDto> withReasons(List<PickMix.Pick> picks, Optional<UserGame> anchor) { private static List<Map.Entry<UserGame, PickReasonDto>> withReason(List<UserGame> games,
List<GameDto> dtos = toDtos(picks.stream().map(PickMix.Pick::game).toList()); Function<UserGame, PickReasonDto> reason) {
String anchorName = picks.stream().anyMatch(p -> p.source() == PickSource.SIMILAR) return games.stream().map(g -> Map.entry(g, reason.apply(g))).toList();
? steamGameRepository.findById(anchor.orElseThrow().getAppid()).map(SteamGame::getName).orElse(null)
: null;
List<PickedGameDto> games = new ArrayList<>();
for (int i = 0; i < picks.size(); i++) {
games.add(new PickedGameDto(dtos.get(i), reason(picks.get(i), anchor, anchorName)));
}
return games;
}
private static PickReasonDto reason(PickMix.Pick pick, Optional<UserGame> anchor, String anchorName) {
return switch (pick.source()) {
case SIMILAR -> PickReasonDto.similar(anchor.orElseThrow(), anchorName);
case LONGEST -> PickReasonDto.longest(pick.game().getFirstSeenAt());
case RANDOM -> PickReasonDto.random();
case NEXT_UP -> PickReasonDto.nextUp();
};
} }
/** /**
* A random one of the user's most played games. * For each of the user's most played games, the first similar backlog game no earlier anchor took.
*/ */
private Optional<UserGame> pickAnchor(Long userId) { private List<Map.Entry<UserGame, PickReasonDto>> becauseYouPlayed(Long userId, Integer maxMinutes,
List<UserGame> played = userGameRepository.findMostPlayedGames(userId, ANCHOR_CANDIDATES); List<Long> excludeAppids) {
if (played.isEmpty()) return Optional.empty(); List<UserGame> anchors = userGameRepository.findMostPlayedGames(userId, ROW_SIZE);
return Optional.of(played.get(ThreadLocalRandom.current().nextInt(played.size()))); Map<Long, String> names = steamGameRepository.findAllById(anchors.stream().map(UserGame::getAppid).toList())
.stream().filter(g -> g.getName() != null)
.collect(Collectors.toMap(SteamGame::getAppid, SteamGame::getName));
return onePerAnchor(anchors,
anchor -> userGameRepository.findSimilarGames(userId, anchor.getAppid(), maxMinutes, null,
excludeAppids, ROW_SIZE),
anchor -> PickReasonDto.similar(anchor, names.get(anchor.getAppid())));
}
/**
* For each series the user played (most played first), the first backlog part no earlier series took: the parts
* released after the latest played part come first.
*/
private List<Map.Entry<UserGame, PickReasonDto>> nextInSeries(Long userId, Integer maxMinutes,
List<Long> excludeAppids) {
return onePerAnchor(gameCreditRepository.findPlayedSeries(userId, PageRequest.of(0, ANCHORS)),
anchor -> userGameRepository.findSeriesGames(userId, anchor.seriesId(), anchor.lastPlayedRelease(),
maxMinutes, excludeAppids, ROW_SIZE),
anchor -> PickReasonDto.series(anchor.name()));
}
/**
* For each developer of the user's played games (most played first), the first backlog game no earlier studio
* took. One card per studio, so a big company adds one card at most.
*/
private List<Map.Entry<UserGame, PickReasonDto>> sameStudio(Long userId, Integer maxMinutes,
List<Long> excludeAppids) {
return onePerAnchor(gameCreditRepository.findPlayedStudios(userId, PageRequest.of(0, ANCHORS)),
anchor -> userGameRepository.findStudioGames(userId, anchor.companyId(), maxMinutes, excludeAppids,
ROW_SIZE),
anchor -> PickReasonDto.studio(anchor.name()));
}
/**
* Per anchor, in order, the first candidate no earlier anchor took, until the row is full. An anchor without a
* candidate is skipped.
*/
private static <A> List<Map.Entry<UserGame, PickReasonDto>> onePerAnchor(
List<A> anchors, Function<A, List<UserGame>> candidates, Function<A, PickReasonDto> reason) {
Set<Long> used = new HashSet<>();
List<Map.Entry<UserGame, PickReasonDto>> picks = new ArrayList<>();
for (A anchor : anchors) {
if (picks.size() == ROW_SIZE) break;
candidates.apply(anchor).stream().filter(g -> used.add(g.getAppid())).findFirst()
.ifPresent(g -> picks.add(Map.entry(g, reason.apply(anchor))));
}
return picks;
}
private List<PickerCategoryDto> toCategories(Map<PickSource, List<Map.Entry<UserGame, PickReasonDto>>> rows) {
List<GameDto> dtos = toDtos(rows.values().stream().flatMap(List::stream).map(Map.Entry::getKey).toList());
List<PickerCategoryDto> categories = new ArrayList<>();
int index = 0;
for (Map.Entry<PickSource, List<Map.Entry<UserGame, PickReasonDto>>> row : rows.entrySet()) {
List<PickedGameDto> games = new ArrayList<>();
for (Map.Entry<UserGame, PickReasonDto> pick : row.getValue()) {
games.add(new PickedGameDto(dtos.get(index++), pick.getValue()));
}
if (!games.isEmpty()) categories.add(new PickerCategoryDto(row.getKey(), games));
}
return categories;
} }
/** /**
@@ -67,6 +67,7 @@ public class LibrarySyncWorker {
private final MoodService moodService; private final MoodService moodService;
private final ReleaseDateService releaseDateService; private final ReleaseDateService releaseDateService;
private final GameEditionService gameEditionService; private final GameEditionService gameEditionService;
private final GameCreditService gameCreditService;
private final ApplicationEventPublisher eventPublisher; private final ApplicationEventPublisher eventPublisher;
private final TransactionTemplate transaction; private final TransactionTemplate transaction;
@@ -158,6 +159,7 @@ public class LibrarySyncWorker {
releaseDateService.refresh(igdbAppids); releaseDateService.refresh(igdbAppids);
moodService.refresh(igdbAppids); moodService.refresh(igdbAppids);
gameEditionService.refresh(igdbAppids); gameEditionService.refresh(igdbAppids);
gameCreditService.refresh(igdbAppids);
finalStatus = Status.OK; finalStatus = Status.OK;
} catch (Exception e) { } catch (Exception e) {
log.error("Sync failed for user {}", userId, e); log.error("Sync failed for user {}", userId, e);
@@ -67,13 +67,15 @@ public class ManualGameService {
private final TimeToBeatService timeToBeatService; private final TimeToBeatService timeToBeatService;
private final MoodService moodService; private final MoodService moodService;
private final GameEditionService gameEditionService; private final GameEditionService gameEditionService;
private final GameCreditService gameCreditService;
private final ReleaseDateService releaseDateService; private final ReleaseDateService releaseDateService;
private final TransactionTemplate transaction; private final TransactionTemplate transaction;
public ManualGameService(IgdbApiService igdbApiService, UserGameRepository userGameRepository, public ManualGameService(IgdbApiService igdbApiService, UserGameRepository userGameRepository,
SteamGameRepository steamGameRepository, GameMergeRepository mergeRepository, SteamGameRepository steamGameRepository, GameMergeRepository mergeRepository,
TimeToBeatService timeToBeatService, MoodService moodService, TimeToBeatService timeToBeatService, MoodService moodService,
GameEditionService gameEditionService, ReleaseDateService releaseDateService, GameEditionService gameEditionService, GameCreditService gameCreditService,
ReleaseDateService releaseDateService,
PlatformTransactionManager transactionManager) { PlatformTransactionManager transactionManager) {
this.igdbApiService = igdbApiService; this.igdbApiService = igdbApiService;
this.userGameRepository = userGameRepository; this.userGameRepository = userGameRepository;
@@ -82,6 +84,7 @@ public class ManualGameService {
this.timeToBeatService = timeToBeatService; this.timeToBeatService = timeToBeatService;
this.moodService = moodService; this.moodService = moodService;
this.gameEditionService = gameEditionService; this.gameEditionService = gameEditionService;
this.gameCreditService = gameCreditService;
this.releaseDateService = releaseDateService; this.releaseDateService = releaseDateService;
this.transaction = new TransactionTemplate(transactionManager); this.transaction = new TransactionTemplate(transactionManager);
} }
@@ -175,6 +178,7 @@ public class ManualGameService {
timeToBeatService.refreshInBackground(List.of(added.appid())); timeToBeatService.refreshInBackground(List.of(added.appid()));
moodService.refreshInBackground(List.of(added.appid())); moodService.refreshInBackground(List.of(added.appid()));
gameEditionService.refreshInBackground(List.of(added.appid())); gameEditionService.refreshInBackground(List.of(added.appid()));
gameCreditService.refreshInBackground(List.of(added.appid()));
releaseDateService.refreshInBackground(List.of(added.appid())); releaseDateService.refreshInBackground(List.of(added.appid()));
} }
return result; return result;
+4
View File
@@ -64,6 +64,10 @@ app:
achievement-ttl-hours: 1 achievement-ttl-hours: 1
schema-ttl-days: 30 schema-ttl-days: 30
speedrun-ttl-days: 30 speedrun-ttl-days: 30
# Existing games get their IGDB series and companies in batches; 2 IGDB requests per batch.
credit-ttl-days: 30
credit-backfill-ms: 30000
credit-backfill-batch: 250
edition-ttl-days: 30 edition-ttl-days: 30
release-ttl-days: 30 release-ttl-days: 30
# Reported broken pictures, repaired after the nightly sync. # Reported broken pictures, repaired after the nightly sync.
@@ -0,0 +1,15 @@
-- The series (IGDB collection) and the companies (developer, publisher) of a game, per library key, shared by all
-- users and refreshed like game_edition. A company that developed and published a game has one DEVELOPER row.
CREATE TABLE game_credit (
appid BIGINT NOT NULL,
kind VARCHAR(16) NOT NULL CHECK (kind IN ('SERIES', 'DEVELOPER', 'PUBLISHER')),
igdb_id BIGINT NOT NULL,
name VARCHAR(200) NOT NULL,
PRIMARY KEY (appid, kind, igdb_id)
);
CREATE INDEX game_credit_igdb_idx ON game_credit (kind, igdb_id);
-- When IGDB was asked for the credits of a game; a row without game_credit rows means IGDB knows none.
CREATE TABLE game_credit_check (
appid BIGINT PRIMARY KEY,
checked_at TIMESTAMPTZ NOT NULL
);
@@ -110,6 +110,71 @@ class IgdbApiServiceTest extends AbstractUnitTest {
Map.entry(220L, new IgdbApiService.GameTags(List.of(), List.of(), List.of()))); Map.entry(220L, new IgdbApiService.GameTags(List.of(), List.of(), List.of())));
} }
@Test
void getCredits_ReturnsSeriesAndCompaniesPerSteamApp() {
expectToken();
igdb.expect(requestTo(containsString("/external_games")))
.andRespond(withSuccess("[{\"id\": 1, \"game\": 72, \"uid\": \"620\"}, {\"id\": 2, \"game\": 233, \"uid\": \"220\"}]",
MediaType.APPLICATION_JSON));
igdb.expect(requestTo(containsString("/games")))
.andExpect(content().string(allOf(
containsString("fields collections.name,involved_companies.company.name,"
+ "involved_companies.developer,involved_companies.publisher;"),
containsString("id = (72,233)"))))
.andRespond(withSuccess("[{\"id\": 72, \"collections\": [{\"id\": 10, \"name\": \"Mass Effect\"}],"
+ " \"involved_companies\": ["
+ "{\"id\": 1, \"company\": {\"id\": 5, \"name\": \"BioWare\"}, \"developer\": true},"
+ "{\"id\": 3, \"company\": {\"id\": 5, \"name\": \"BioWare\"}, \"publisher\": true},"
+ "{\"id\": 2, \"company\": {\"id\": 6, \"name\": \"Port Studio\"}, \"developer\": false, \"publisher\": false}]},"
+ " {\"id\": 233}]", MediaType.APPLICATION_JSON));
Map<Long, IgdbApiService.Credits> credits = service.getCredits(List.of(620L, 220L)).orElseThrow();
assertThat(credits).containsOnly(
Map.entry(620L, new IgdbApiService.Credits(List.of(new IgdbApiService.Series(10, "Mass Effect")),
List.of(new IgdbApiService.Company(5, "BioWare", true, true)))),
Map.entry(220L, new IgdbApiService.Credits(List.of(), List.of())));
}
@Test
void getCredits_TwoIgdbGamesOfOneApp_UnionByIds() {
expectToken();
igdb.expect(requestTo(containsString("/external_games")))
.andRespond(withSuccess("[{\"id\": 1, \"game\": 72, \"uid\": \"620\"}, {\"id\": 2, \"game\": 233, \"uid\": \"620\"}]",
MediaType.APPLICATION_JSON));
igdb.expect(requestTo(containsString("/games")))
.andRespond(withSuccess("[{\"id\": 72, \"collections\": [{\"id\": 10, \"name\": \"Mass Effect\"}],"
+ " \"involved_companies\": [{\"company\": {\"id\": 5, \"name\": \"BioWare\"}, \"developer\": true}]},"
+ " {\"id\": 233, \"collections\": [{\"id\": 10, \"name\": \"Mass Effect\"}],"
+ " \"involved_companies\": [{\"company\": {\"id\": 5, \"name\": \"BioWare\"}, \"publisher\": true}]}]",
MediaType.APPLICATION_JSON));
IgdbApiService.Credits credits = service.getCredits(List.of(620L)).orElseThrow().get(620L);
assertThat(credits.series()).containsExactly(new IgdbApiService.Series(10, "Mass Effect"));
assertThat(credits.companies()).containsExactly(new IgdbApiService.Company(5, "BioWare", true, true));
}
@Test
void getCreditsByGameIds_AsksTheGamesDirectly() {
expectToken();
igdb.expect(requestTo(containsString("/games")))
.andExpect(content().string(containsString("id = (119388)")))
.andRespond(withSuccess("[{\"id\": 119388, \"collections\": [{\"id\": 3, \"name\": \"Zelda\"}]}]",
MediaType.APPLICATION_JSON));
assertThat(service.getCreditsByGameIds(List.of(119388L)).orElseThrow().get(119388L).series())
.containsExactly(new IgdbApiService.Series(3, "Zelda"));
}
@Test
void getCredits_ServerError_IsEmpty() {
expectToken();
igdb.expect(requestTo(containsString("/external_games"))).andRespond(withServerError());
assertThat(service.getCredits(List.of(620L))).isEmpty();
}
@Test @Test
void getGameTags_SteamAppLinkedToTwoIgdbGames_KeepsTheTagsOfBoth() { void getGameTags_SteamAppLinkedToTwoIgdbGames_KeepsTheTagsOfBoth() {
expectToken(); expectToken();
@@ -191,6 +191,20 @@ class GameMergeFlowTest extends AbstractIntegrationTest {
assertThat(audit()).hasSize(1).first().asString().contains("GAME_MERGE"); assertThat(audit()).hasSize(1).first().asString().contains("GAME_MERGE");
} }
@Test
void mergeDropsTheCreditsOfTheMergedKey() throws Exception {
manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG);
jdbc.update("delete from game_credit");
jdbc.update("delete from game_credit_check");
jdbc.update("insert into game_credit (appid, kind, igdb_id, name) values (?, 'SERIES', 10, 'Hades')", MANUAL);
jdbc.update("insert into game_credit_check (appid, checked_at) values (?, now())", MANUAL);
merge(Role.AUDITOR, MANUAL, STEAM).andExpect(status().isOk());
assertThat(jdbc.queryForObject("select count(*) from game_credit where appid = ?", Integer.class, MANUAL)).isZero();
assertThat(jdbc.queryForObject("select count(*) from game_credit_check where appid = ?", Integer.class, MANUAL)).isZero();
}
@Test @Test
void merge_IntoAKeyWithItsOwnSet_JoinsBothSets() throws Exception { void merge_IntoAKeyWithItsOwnSet_JoinsBothSets() throws Exception {
manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG); manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG);
@@ -7,6 +7,8 @@ import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId; import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.GameCreditCheckRepository;
import core_service.modules.library.repo.GameCreditRepository;
import core_service.modules.library.repo.GameMoodsRepository; import core_service.modules.library.repo.GameMoodsRepository;
import core_service.modules.library.repo.GameReleaseRepository; import core_service.modules.library.repo.GameReleaseRepository;
import core_service.modules.library.repo.GameSpeedrunRepository; import core_service.modules.library.repo.GameSpeedrunRepository;
@@ -27,9 +29,12 @@ import java.time.LocalDate;
import java.time.ZoneId; import java.time.ZoneId;
import java.util.Arrays; import java.util.Arrays;
import static org.hamcrest.Matchers.anyOf;
import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.everyItem;
import static org.hamcrest.Matchers.lessThanOrEqualTo;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.is;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -73,9 +78,17 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
@Autowired @Autowired
private UserGameAchievementMetaRepository achievementMetaRepository; private UserGameAchievementMetaRepository achievementMetaRepository;
@Autowired
private GameCreditRepository gameCreditRepository;
@Autowired
private GameCreditCheckRepository gameCreditCheckRepository;
@BeforeEach @BeforeEach
void clean() { void clean() {
gameReleaseRepository.deleteAll(); gameReleaseRepository.deleteAll();
gameCreditRepository.deleteAll();
gameCreditCheckRepository.deleteAll();
// A game with unlocked achievements is never coming; other test classes leave such rows. // A game with unlocked achievements is never coming; other test classes leave such rows.
achievementMetaRepository.deleteAll(); achievementMetaRepository.deleteAll();
userGameRepository.deleteAll(); userGameRepository.deleteAll();
@@ -100,6 +113,25 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
} }
} }
private void credit(long appid, String kind, long igdbId, String name) {
gameCreditRepository.insert(appid, kind, igdbId, name);
}
private void release(long appid, LocalDate date) {
gameReleaseRepository.upsert(appid, date, false, Instant.now());
}
private void putOrder(Long... appids) throws Exception {
mockMvc.perform(put("/api/library/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"appids\":" + Arrays.toString(appids) + "}"))
.andExpect(status().isNoContent());
}
private static String row(String source) {
return "$.categories[?(@.source == '" + source + "')].games[*].appid";
}
@Test @Test
void picker_WithMood_PicksOnlyGamesKnownToHaveIt() throws Exception { void picker_WithMood_PicksOnlyGamesKnownToHaveIt() throws Exception {
game(10L, GameStatus.BACKLOG, null, null); game(10L, GameStatus.BACKLOG, null, null);
@@ -110,11 +142,11 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
gameMoodsRepository.upsert(20L, new String[]{"ACTION"}, new String[0], false, Instant.now()); gameMoodsRepository.upsert(20L, new String[]{"ACTION"}, new String[0], false, Instant.now());
gameMoodsRepository.upsert(40L, new String[]{"CHILL"}, new String[0], false, Instant.now()); gameMoodsRepository.upsert(40L, new String[]{"CHILL"}, new String[0], false, Instant.now());
mockMvc.perform(get("/api/library/picker").param("mood", "CHILL")) mockMvc.perform(get("/api/library/picker").param("category", "RANDOM").param("mood", "CHILL"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10))); .andExpect(jsonPath("$.categories[0].games[*].appid", containsInAnyOrder(10)));
mockMvc.perform(get("/api/library/picker")) mockMvc.perform(get("/api/library/picker").param("category", "RANDOM"))
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10, 20, 30))); .andExpect(jsonPath("$.categories[0].games[*].appid", containsInAnyOrder(10, 20, 30)));
verify(moodService).refreshBacklogInBackground(USER_ID); verify(moodService).refreshBacklogInBackground(USER_ID);
} }
@@ -126,9 +158,9 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
gameSpeedrunRepository.upsert(10L, "o1y9j9v6", "https://www.speedrun.com/celeste", Instant.now()); gameSpeedrunRepository.upsert(10L, "o1y9j9v6", "https://www.speedrun.com/celeste", Instant.now());
gameSpeedrunRepository.upsert(20L, null, null, Instant.now()); gameSpeedrunRepository.upsert(20L, null, null, Instant.now());
mockMvc.perform(get("/api/library/picker").param("mood", "SPEEDRUN")) mockMvc.perform(get("/api/library/picker").param("category", "RANDOM").param("mood", "SPEEDRUN"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10))); .andExpect(jsonPath("$.categories[0].games[*].appid", containsInAnyOrder(10)));
verify(speedrunService).refreshBacklogInBackground(USER_ID); verify(speedrunService).refreshBacklogInBackground(USER_ID);
verifyNoInteractions(moodService); verifyNoInteractions(moodService);
} }
@@ -140,8 +172,9 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
gameMoodsRepository.upsert(10L, new String[]{"STORY"}, new String[0], false, Instant.now()); gameMoodsRepository.upsert(10L, new String[]{"STORY"}, new String[0], false, Instant.now());
gameMoodsRepository.upsert(20L, new String[]{"STORY"}, new String[0], false, Instant.now()); gameMoodsRepository.upsert(20L, new String[]{"STORY"}, new String[0], false, Instant.now());
mockMvc.perform(get("/api/library/picker").param("mood", "STORY").param("maxMinutes", "900")) mockMvc.perform(get("/api/library/picker").param("category", "RANDOM").param("mood", "STORY")
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10))); .param("maxMinutes", "900"))
.andExpect(jsonPath("$.categories[0].games[*].appid", containsInAnyOrder(10)));
} }
@Test @Test
@@ -151,12 +184,18 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
} }
@Test @Test
void picker_EmptyBacklog_ReturnsNoGames() throws Exception { void picker_UnknownCategory_Is400() throws Exception {
mockMvc.perform(get("/api/library/picker").param("category", "FAVOURITES"))
.andExpect(status().isBadRequest());
}
@Test
void picker_EmptyBacklog_ReturnsNoCategories() throws Exception {
game(10L, GameStatus.PLAYING, null, 60); game(10L, GameStatus.PLAYING, null, 60);
mockMvc.perform(get("/api/library/picker")) mockMvc.perform(get("/api/library/picker"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.games", hasSize(0))); .andExpect(jsonPath("$.categories", hasSize(0)));
} }
@Test @Test
@@ -166,10 +205,10 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
game(30L, GameStatus.FINISHED, null, 60); game(30L, GameStatus.FINISHED, null, 60);
game(40L, GameStatus.DROPPED, null, 60); game(40L, GameStatus.DROPPED, null, 60);
mockMvc.perform(get("/api/library/picker")) mockMvc.perform(get("/api/library/picker").param("category", "RANDOM"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10, 20))) .andExpect(jsonPath("$.categories[0].games[*].appid", containsInAnyOrder(10, 20)))
.andExpect(jsonPath("$.games[?(@.appid == 10)].timeToBeatMinutes").value(60)); .andExpect(jsonPath("$.categories[0].games[?(@.appid == 10)].timeToBeatMinutes").value(60));
} }
@Test @Test
@@ -178,9 +217,9 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
game(20L, GameStatus.BACKLOG, null, 301); game(20L, GameStatus.BACKLOG, null, 301);
game(30L, GameStatus.BACKLOG, null, null); game(30L, GameStatus.BACKLOG, null, null);
mockMvc.perform(get("/api/library/picker").param("maxMinutes", "300")) mockMvc.perform(get("/api/library/picker").param("category", "RANDOM").param("maxMinutes", "300"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10))); .andExpect(jsonPath("$.categories[0].games[*].appid", containsInAnyOrder(10)));
} }
@Test @Test
@@ -188,8 +227,8 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
game(10L, GameStatus.BACKLOG, null, 300); game(10L, GameStatus.BACKLOG, null, 300);
game(20L, GameStatus.BACKLOG, null, null); game(20L, GameStatus.BACKLOG, null, null);
mockMvc.perform(get("/api/library/picker").param("maxMinutes", "0")) mockMvc.perform(get("/api/library/picker").param("category", "RANDOM").param("maxMinutes", "0"))
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10, 20))); .andExpect(jsonPath("$.categories[0].games[*].appid", containsInAnyOrder(10, 20)));
} }
@Test @Test
@@ -198,80 +237,279 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
game(20L, GameStatus.BACKLOG, null, 60); game(20L, GameStatus.BACKLOG, null, 60);
game(30L, GameStatus.BACKLOG, null, 60); game(30L, GameStatus.BACKLOG, null, 60);
mockMvc.perform(get("/api/library/picker").param("exclude", "10", "30")) mockMvc.perform(get("/api/library/picker").param("category", "RANDOM").param("exclude", "10", "30"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(20))); .andExpect(jsonPath("$.categories[0].games[*].appid", containsInAnyOrder(20)));
} }
@Test @Test
void picker_FillsSlotsByPriorityFirst() throws Exception { void picker_RandomRow_FillsByPriorityFirst() throws Exception {
game(10L, GameStatus.BACKLOG, null, 60); game(10L, GameStatus.BACKLOG, null, 60);
game(20L, GameStatus.BACKLOG, GamePriority.LOW, 60); game(20L, GameStatus.BACKLOG, GamePriority.LOW, 60);
game(30L, GameStatus.BACKLOG, GamePriority.HIGH, 60); game(30L, GameStatus.BACKLOG, GamePriority.HIGH, 60);
game(40L, GameStatus.BACKLOG, GamePriority.MEDIUM, 60); game(40L, GameStatus.BACKLOG, GamePriority.MEDIUM, 60);
game(50L, GameStatus.BACKLOG, null, 60); game(50L, GameStatus.BACKLOG, null, 60);
mockMvc.perform(get("/api/library/picker").param("count", "2")) mockMvc.perform(get("/api/library/picker").param("category", "RANDOM"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(30, 40))) .andExpect(jsonPath("$.categories[0].games", hasSize(3)))
.andExpect(jsonPath("$.games[0].appid").value(30)); .andExpect(jsonPath("$.categories[0].games[0].appid").value(30))
.andExpect(jsonPath("$.categories[0].games[1].appid").value(40));
} }
@Test @Test
void picker_ThreeSlots_OneGamePerSourceWithReason() throws Exception { void picker_AllRows_InFixedOrderEachAtMostThree() throws Exception {
game(1L, GameStatus.PLAYING, null, null, 600, SEEN); game(1L, GameStatus.PLAYING, null, null, 600, SEEN);
game(10L, GameStatus.BACKLOG, null, null, 0, Instant.parse("2020-01-01T00:00:00Z")); for (long appid = 10; appid <= 15; appid++) {
game(20L, GameStatus.BACKLOG, null, null, 0, SEEN); game(appid, GameStatus.BACKLOG, null, null);
game(30L, GameStatus.BACKLOG, null, null, 0, SEEN); }
gameMoodsRepository.upsert(1L, new String[]{"STORY"}, new String[0], false, Instant.now()); gameMoodsRepository.upsert(1L, new String[]{"STORY"}, new String[0], false, Instant.now());
gameMoodsRepository.upsert(30L, new String[]{"STORY"}, new String[0], false, Instant.now()); gameMoodsRepository.upsert(10L, new String[]{"STORY"}, new String[0], false, Instant.now());
credit(1L, "SERIES", 100L, "Series");
credit(12L, "SERIES", 100L, "Series");
credit(1L, "DEVELOPER", 200L, "Studio");
credit(13L, "DEVELOPER", 200L, "Studio");
mockMvc.perform(get("/api/library/picker")) mockMvc.perform(get("/api/library/picker"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.games", hasSize(3))) .andExpect(jsonPath("$.categories[*].source",
.andExpect(jsonPath("$.games[*].reason.source", contains("SIMILAR", "LONGEST", "RANDOM"))) contains("NEXT_UP", "SERIES", "SIMILAR", "STUDIO", "LONGEST", "RANDOM")))
.andExpect(jsonPath("$.games[0].appid").value(30)) .andExpect(jsonPath("$.categories[*].games.length()", everyItem(lessThanOrEqualTo(3))))
.andExpect(jsonPath("$.games[0].reason.anchorAppid").value(1)) .andExpect(jsonPath("$.categories[0].games[*].reason.source", everyItem(is("NEXT_UP"))))
.andExpect(jsonPath("$.games[0].reason.anchorName").value("Game 1")) .andExpect(jsonPath("$.categories[1].games[0].reason.source").value("SERIES"))
.andExpect(jsonPath("$.games[0].reason.anchorPlaytimeMinutes").value(600)) .andExpect(jsonPath("$.categories[3].games[0].reason.source").value("STUDIO"))
.andExpect(jsonPath("$.games[1].appid").value(10)) .andExpect(jsonPath("$.categories[4].games[0].reason.firstSeenAt").exists());
.andExpect(jsonPath("$.games[1].reason.firstSeenAt").exists())
.andExpect(jsonPath("$.games[2].appid").value(20));
} }
@Test @Test
void picker_NoPlaytime_SimilarSlotFallsBackToRandom() throws Exception { void picker_UpNext_FirstThreeOfOwnOrderInOrder() throws Exception {
game(10L, GameStatus.BACKLOG, null, null, 0, Instant.parse("2020-01-01T00:00:00Z")); for (long appid = 1; appid <= 7; appid++) {
game(20L, GameStatus.BACKLOG, null, null); game(appid, GameStatus.BACKLOG, null, null);
game(30L, GameStatus.BACKLOG, null, null); }
putOrder(7L, 6L, 5L, 4L, 3L, 2L, 1L);
mockMvc.perform(get("/api/library/picker")) mockMvc.perform(get("/api/library/picker").param("category", "NEXT_UP"))
.andExpect(jsonPath("$.games", hasSize(3))) .andExpect(jsonPath("$.categories[0].games[*].appid", contains(7, 6, 5)));
.andExpect(jsonPath("$.games[*].reason.source", containsInAnyOrder("LONGEST", "RANDOM", "RANDOM")));
} }
@Test @Test
void picker_FewerBacklogGamesThanCount_NoDuplicates() throws Exception { void picker_BecauseYouPlayed_OneSimilarGamePerTopPlayedGame() throws Exception {
game(10L, GameStatus.BACKLOG, null, null); game(1L, GameStatus.PLAYING, null, null, 900, SEEN);
game(20L, GameStatus.BACKLOG, null, null); game(2L, GameStatus.PLAYING, null, null, 800, SEEN);
game(3L, GameStatus.PLAYING, null, null, 700, SEEN);
mockMvc.perform(get("/api/library/picker").param("count", "3")) game(11L, GameStatus.BACKLOG, null, null);
.andExpect(jsonPath("$.games", hasSize(2))) game(12L, GameStatus.BACKLOG, null, null);
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10, 20))); game(13L, GameStatus.BACKLOG, null, null);
} String[] moods = {"STORY", "COOP", "ACTION"};
for (int i = 0; i < 3; i++) {
@Test gameMoodsRepository.upsert(1L + i, new String[]{moods[i]}, new String[0], false, Instant.now());
void picker_DefaultsToThreeAndCapsTheCount() throws Exception { gameMoodsRepository.upsert(11L + i, new String[]{moods[i]}, new String[0], false, Instant.now());
for (long appid = 1; appid <= 12; appid++) {
game(appid, GameStatus.BACKLOG, null, 60);
} }
mockMvc.perform(get("/api/library/picker").param("category", "SIMILAR"))
.andExpect(jsonPath("$.categories[0].games", hasSize(3)))
.andExpect(jsonPath("$.categories[0].games[*].appid", contains(11, 12, 13)))
.andExpect(jsonPath("$.categories[0].games[*].reason.anchorAppid", contains(1, 2, 3)))
.andExpect(jsonPath("$.categories[0].games[0].reason.anchorName").value("Game 1"))
.andExpect(jsonPath("$.categories[0].games[0].reason.anchorPlaytimeMinutes").value(900));
}
@Test
void picker_BecauseYouPlayed_SkipsAnchorWithoutCandidateAndNeverRepeats() throws Exception {
game(1L, GameStatus.PLAYING, null, null, 900, SEEN);
game(2L, GameStatus.PLAYING, null, null, 800, SEEN);
game(3L, GameStatus.PLAYING, null, null, 700, SEEN);
game(11L, GameStatus.BACKLOG, null, null);
gameMoodsRepository.upsert(1L, new String[]{"STORY"}, new String[0], false, Instant.now());
gameMoodsRepository.upsert(2L, new String[]{"STORY"}, new String[0], false, Instant.now());
gameMoodsRepository.upsert(11L, new String[]{"STORY"}, new String[0], false, Instant.now());
mockMvc.perform(get("/api/library/picker").param("category", "SIMILAR"))
.andExpect(jsonPath("$.categories[0].games", hasSize(1)))
.andExpect(jsonPath("$.categories[0].games[0].reason.anchorAppid").value(1));
}
@Test
void picker_NoPlayedGames_LeavesOutBecauseYouPlayed() throws Exception {
game(10L, GameStatus.BACKLOG, null, null);
mockMvc.perform(get("/api/library/picker")) mockMvc.perform(get("/api/library/picker"))
.andExpect(jsonPath("$.games", hasSize(3))); .andExpect(jsonPath("$.categories[*].source", contains("NEXT_UP", "LONGEST", "RANDOM")));
mockMvc.perform(get("/api/library/picker").param("count", "500")) }
.andExpect(jsonPath("$.games", hasSize(10)));
mockMvc.perform(get("/api/library/picker").param("count", "0")) @Test
.andExpect(jsonPath("$.games", hasSize(1))); void picker_Mood_AppliesOnlyToMoodRow() throws Exception {
game(10L, GameStatus.BACKLOG, null, null, 0, Instant.parse("2020-01-01T00:00:00Z"));
game(20L, GameStatus.BACKLOG, null, null);
gameMoodsRepository.upsert(20L, new String[]{"CHILL"}, new String[0], false, Instant.now());
mockMvc.perform(get("/api/library/picker").param("mood", "CHILL"))
.andExpect(jsonPath(row("RANDOM"), contains(20)))
.andExpect(jsonPath(row("LONGEST"), hasItem(10)))
.andExpect(jsonPath(row("NEXT_UP"), hasItem(10)));
}
@Test
void picker_BudgetAndExclude_ApplyToAllRows() throws Exception {
game(10L, GameStatus.BACKLOG, null, 60);
game(20L, GameStatus.BACKLOG, null, 3000);
game(30L, GameStatus.BACKLOG, null, 60);
game(5L, GameStatus.FINISHED, null, null, 600, SEEN);
for (long appid : new long[]{5L, 10L, 20L, 30L}) {
credit(appid, "SERIES", 100L, "Series");
credit(appid, "DEVELOPER", 200L, "Studio");
}
mockMvc.perform(get("/api/library/picker").param("maxMinutes", "300").param("exclude", "30"))
.andExpect(jsonPath(row("SERIES"), contains(10)))
.andExpect(jsonPath(row("STUDIO"), contains(10)))
.andExpect(jsonPath("$.categories[*].games[*].appid", everyItem(is(10))))
.andExpect(jsonPath("$.categories[*].games[*].appid", not(hasItem(20))));
}
@Test
void picker_NextInSeries_PicksThePartReleasedAfterThePlayedOne() throws Exception {
game(1L, GameStatus.FINISHED, null, null, 600, SEEN);
game(2L, GameStatus.BACKLOG, null, null);
game(3L, GameStatus.BACKLOG, null, null);
game(4L, GameStatus.BACKLOG, null, null);
for (long appid = 1; appid <= 4; appid++) {
credit(appid, "SERIES", 100L, "Mass Effect");
}
release(1L, LocalDate.of(2007, 11, 20));
release(2L, LocalDate.of(2010, 1, 26));
release(3L, LocalDate.of(2012, 3, 6));
release(4L, LocalDate.of(2005, 1, 1));
mockMvc.perform(get("/api/library/picker").param("category", "SERIES"))
.andExpect(jsonPath("$.categories[0].games[*].appid", contains(2)))
.andExpect(jsonPath("$.categories[0].games[0].reason.source").value("SERIES"))
.andExpect(jsonPath("$.categories[0].games[0].reason.seriesName").value("Mass Effect"));
}
@Test
void picker_NextInSeries_WithoutDates_StillPicksAPart() throws Exception {
game(1L, GameStatus.PLAYING, null, null, 600, SEEN);
game(2L, GameStatus.BACKLOG, null, null);
credit(1L, "SERIES", 100L, "Mass Effect");
credit(2L, "SERIES", 100L, "Mass Effect");
mockMvc.perform(get("/api/library/picker").param("category", "SERIES"))
.andExpect(jsonPath("$.categories[0].games[*].appid", contains(2)));
}
@Test
void picker_NextInSeries_OneGamePerSeriesMostPlayedSeriesFirst() throws Exception {
game(1L, GameStatus.FINISHED, null, null, 100, SEEN);
game(2L, GameStatus.FINISHED, null, null, 900, SEEN);
game(11L, GameStatus.BACKLOG, null, null);
game(12L, GameStatus.BACKLOG, null, null);
game(13L, GameStatus.BACKLOG, null, null);
game(21L, GameStatus.BACKLOG, null, null);
credit(1L, "SERIES", 100L, "Small");
credit(2L, "SERIES", 200L, "Big");
credit(11L, "SERIES", 100L, "Small");
credit(12L, "SERIES", 100L, "Small");
credit(13L, "SERIES", 100L, "Small");
credit(21L, "SERIES", 200L, "Big");
mockMvc.perform(get("/api/library/picker").param("category", "SERIES"))
.andExpect(jsonPath("$.categories[0].games[*].appid", contains(21, 11)))
.andExpect(jsonPath("$.categories[0].games[*].reason.seriesName", contains("Big", "Small")));
}
@Test
void picker_NextInSeries_SeriesOnlyInBacklog_IsNotPlayed() throws Exception {
game(1L, GameStatus.BACKLOG, null, null, 500, SEEN);
game(2L, GameStatus.BACKLOG, null, null);
credit(1L, "SERIES", 100L, "Mass Effect");
credit(2L, "SERIES", 100L, "Mass Effect");
mockMvc.perform(get("/api/library/picker"))
.andExpect(jsonPath("$.categories[*].source", not(hasItem("SERIES"))));
}
@Test
void picker_NextInSeries_NeverPicksAComingGame() throws Exception {
game(1L, GameStatus.FINISHED, null, null, 600, SEEN);
game(2L, GameStatus.BACKLOG, null, null);
credit(1L, "SERIES", 100L, "Mass Effect");
credit(2L, "SERIES", 100L, "Mass Effect");
gameReleaseRepository.upsert(2L, LocalDate.now(ZoneId.of("Europe/Berlin")).plusDays(3), false, Instant.now());
mockMvc.perform(get("/api/library/picker"))
.andExpect(jsonPath("$.categories[*].source", not(hasItem("SERIES"))));
}
@Test
void picker_SameStudio_PrefersDeveloperAndOneGamePerStudio() throws Exception {
game(1L, GameStatus.FINISHED, null, null, 600, SEEN);
game(11L, GameStatus.BACKLOG, null, null);
game(12L, GameStatus.BACKLOG, null, null);
game(13L, GameStatus.BACKLOG, null, null);
credit(1L, "DEVELOPER", 5L, "BioWare");
credit(1L, "PUBLISHER", 6L, "EA");
credit(11L, "DEVELOPER", 5L, "BioWare");
credit(12L, "PUBLISHER", 5L, "BioWare");
credit(13L, "PUBLISHER", 6L, "EA");
mockMvc.perform(get("/api/library/picker").param("category", "STUDIO"))
.andExpect(jsonPath("$.categories[0].games[*].appid", contains(11)))
.andExpect(jsonPath("$.categories[0].games[0].reason.source").value("STUDIO"))
.andExpect(jsonPath("$.categories[0].games[0].reason.studioName").value("BioWare"));
}
@Test
void picker_SameStudio_PublisherOfPlayedGameIsNoAnchor() throws Exception {
game(1L, GameStatus.FINISHED, null, null, 600, SEEN);
game(11L, GameStatus.BACKLOG, null, null);
credit(1L, "PUBLISHER", 6L, "EA");
credit(11L, "DEVELOPER", 6L, "EA");
mockMvc.perform(get("/api/library/picker"))
.andExpect(jsonPath("$.categories[*].source", not(hasItem("STUDIO"))));
}
@Test
void picker_SameStudio_NeverRepeatsAGame() throws Exception {
game(1L, GameStatus.FINISHED, null, null, 600, SEEN);
game(2L, GameStatus.FINISHED, null, null, 500, SEEN);
game(11L, GameStatus.BACKLOG, null, null);
credit(1L, "DEVELOPER", 5L, "BioWare");
credit(2L, "DEVELOPER", 7L, "Other");
credit(11L, "DEVELOPER", 5L, "BioWare");
credit(11L, "DEVELOPER", 7L, "Other");
mockMvc.perform(get("/api/library/picker").param("category", "STUDIO"))
.andExpect(jsonPath("$.categories[0].games", hasSize(1)));
}
@Test
void sameStudio_AStartedBacklogGameIsNotItsOwnAnchor() throws Exception {
game(11L, GameStatus.BACKLOG, null, null, 300, SEEN);
credit(11L, "DEVELOPER", 5L, "BioWare");
mockMvc.perform(get("/api/library/picker"))
.andExpect(jsonPath("$.categories[*].source", not(hasItem("STUDIO"))));
}
@Test
void picker_NoCredits_LeavesOutSeriesAndStudioRows() throws Exception {
game(1L, GameStatus.FINISHED, null, null, 600, SEEN);
game(10L, GameStatus.BACKLOG, null, null);
mockMvc.perform(get("/api/library/picker"))
.andExpect(jsonPath("$.categories[*].source", not(hasItem("SERIES"))))
.andExpect(jsonPath("$.categories[*].source", not(hasItem("STUDIO"))));
}
@Test
void picker_Category_ReturnsOnlyThatRow() throws Exception {
game(10L, GameStatus.BACKLOG, null, null);
mockMvc.perform(get("/api/library/picker").param("category", "LONGEST"))
.andExpect(jsonPath("$.categories", hasSize(1)))
.andExpect(jsonPath("$.categories[0].source").value("LONGEST"));
} }
@Test @Test
@@ -282,8 +520,8 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
removed.setRemovedAt(Instant.now()); removed.setRemovedAt(Instant.now());
userGameRepository.save(removed); userGameRepository.save(removed);
mockMvc.perform(get("/api/library/picker")) mockMvc.perform(get("/api/library/picker").param("category", "RANDOM"))
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10))); .andExpect(jsonPath("$.categories[0].games[*].appid", containsInAnyOrder(10)));
} }
@Test @Test
@@ -292,7 +530,7 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
userGameRepository.save(new UserGame(2L, 99L, 0, Instant.now(), Instant.now())); userGameRepository.save(new UserGame(2L, 99L, 0, Instant.now(), Instant.now()));
mockMvc.perform(get("/api/library/picker")) mockMvc.perform(get("/api/library/picker"))
.andExpect(jsonPath("$.games", hasSize(0))); .andExpect(jsonPath("$.categories", hasSize(0)));
} }
// The dev profile's user has a Steam ID. // The dev profile's user has a Steam ID.
@@ -310,44 +548,13 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
game(20L, GameStatus.BACKLOG, null, null); game(20L, GameStatus.BACKLOG, null, null);
gameReleaseRepository.upsert(20L, berlinToday.plusDays(1), false, Instant.now()); gameReleaseRepository.upsert(20L, berlinToday.plusDays(1), false, Instant.now());
mockMvc.perform(get("/api/library/picker")) mockMvc.perform(get("/api/library/picker").param("category", "RANDOM"))
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10))); .andExpect(jsonPath("$.categories[0].games[*].appid", containsInAnyOrder(10)));
gameReleaseRepository.upsert(20L, berlinToday, false, Instant.now()); gameReleaseRepository.upsert(20L, berlinToday, false, Instant.now());
mockMvc.perform(get("/api/library/picker")) mockMvc.perform(get("/api/library/picker").param("category", "RANDOM"))
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10, 20))); .andExpect(jsonPath("$.categories[0].games[*].appid", containsInAnyOrder(10, 20)));
}
private void putOrder(Long... appids) throws Exception {
mockMvc.perform(put("/api/library/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"appids\":" + Arrays.toString(appids) + "}"))
.andExpect(status().isNoContent());
}
@Test
void picker_FourSlots_NextUpComesFromTopFiveWithReason() throws Exception {
for (long appid = 1; appid <= 7; appid++) {
game(appid, GameStatus.BACKLOG, null, null);
}
putOrder(7L, 6L, 5L, 4L, 3L, 2L, 1L);
mockMvc.perform(get("/api/library/picker").param("count", "4"))
.andExpect(jsonPath("$.games", hasSize(4)))
.andExpect(jsonPath("$.games[3].reason.source").value("NEXT_UP"))
.andExpect(jsonPath("$.games[3].appid", anyOf(is(7), is(6), is(5), is(4), is(3))));
}
@Test
void picker_NextUp_SkipsExcludedAndFilteredGames() throws Exception {
for (long appid = 1; appid <= 12; appid++) {
game(appid, GameStatus.BACKLOG, null, 60);
}
mockMvc.perform(get("/api/library/picker").param("count", "4").param("exclude", "1", "2", "3", "4", "5"))
.andExpect(jsonPath("$.games[3].reason.source").value("NEXT_UP"))
.andExpect(jsonPath("$.games[3].appid", anyOf(is(6), is(7), is(8), is(9), is(10))));
} }
@Test @Test
@@ -357,8 +564,8 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
game(20L, GameStatus.BACKLOG, null, null); game(20L, GameStatus.BACKLOG, null, null);
gameReleaseRepository.upsert(10L, berlinToday.plusDays(1), false, Instant.now()); gameReleaseRepository.upsert(10L, berlinToday.plusDays(1), false, Instant.now());
mockMvc.perform(get("/api/library/picker").param("count", "4")) mockMvc.perform(get("/api/library/picker").param("category", "NEXT_UP"))
.andExpect(jsonPath("$.games", hasSize(1))) .andExpect(jsonPath("$.categories[0].games", hasSize(1)))
.andExpect(jsonPath("$.games[0].appid").value(20)); .andExpect(jsonPath("$.categories[0].games[0].appid").value(20));
} }
} }
@@ -1,97 +0,0 @@
package core_service.modules.library.domain;
import core_service.modules.library.domain.PickMix.Pick;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import static core_service.modules.library.domain.PickSource.LONGEST;
import static core_service.modules.library.domain.PickSource.NEXT_UP;
import static core_service.modules.library.domain.PickSource.RANDOM;
import static core_service.modules.library.domain.PickSource.SIMILAR;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PickMixTest extends AbstractUnitTest {
private static UserGame game(long appid) {
return new UserGame(1L, appid, 0, Instant.now(), Instant.now());
}
private static List<UserGame> games(long... appids) {
return Arrays.stream(appids).mapToObj(PickMixTest::game).toList();
}
private static List<PickSource> sources(List<Pick> picks) {
return picks.stream().map(Pick::source).toList();
}
private static List<Long> appids(List<Pick> picks) {
return picks.stream().map(p -> p.game().getAppid()).toList();
}
@Test
void mix_ThreeSources_OneGameEachInSlotOrder() {
List<Pick> picks = PickMix.mix(games(1), games(2), games(3), List.of(), 3);
assertEquals(List.of(SIMILAR, LONGEST, RANDOM), sources(picks));
assertEquals(List.of(1L, 2L, 3L), appids(picks));
}
@Test
void mix_MoreSlotsThanSources_RotationContinues() {
List<Pick> picks = PickMix.mix(games(1, 5, 9), games(2, 6), games(3, 7), games(4, 8), 9);
assertEquals(List.of(SIMILAR, LONGEST, RANDOM, NEXT_UP, SIMILAR, LONGEST, RANDOM, NEXT_UP, SIMILAR),
sources(picks));
assertEquals(List.of(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L, 9L), appids(picks));
}
@Test
void mix_SameGameFirstInAllLists_AppearsOnce() {
List<Pick> picks = PickMix.mix(games(1, 4), games(1, 5), games(1, 6), List.of(), 3);
assertEquals(List.of(1L, 5L, 6L), appids(picks));
assertEquals(List.of(SIMILAR, LONGEST, RANDOM), sources(picks));
}
@Test
void mix_EmptySimilar_SlotComesFromRandom() {
List<Pick> picks = PickMix.mix(List.of(), games(2), games(3, 4), List.of(), 3);
assertEquals(List.of(RANDOM, LONGEST, RANDOM), sources(picks));
assertEquals(List.of(3L, 2L, 4L), appids(picks));
}
@Test
void mix_ListsShorterThanCount_ReturnsAllDistinctGames() {
List<Pick> picks = PickMix.mix(games(1), games(1, 2), games(2, 3), List.of(), 10);
assertEquals(List.of(1L, 2L, 3L), appids(picks).stream().sorted().toList());
assertEquals(3, picks.size());
}
@Test
void mix_AllEmpty_ReturnsNothing() {
assertTrue(PickMix.mix(List.of(), List.of(), List.of(), List.of(), 3).isEmpty());
}
@Test
void mix_FourSources_NextUpIsTheFourthSlot() {
List<Pick> picks = PickMix.mix(games(1), games(2), games(3), games(4), 4);
assertEquals(List.of(SIMILAR, LONGEST, RANDOM, NEXT_UP), sources(picks));
assertEquals(List.of(1L, 2L, 3L, 4L), appids(picks));
}
@Test
void mix_EmptyNextUp_SlotComesFromRandom() {
List<Pick> picks = PickMix.mix(games(1), games(2), games(3, 4), List.of(), 4);
assertEquals(List.of(SIMILAR, LONGEST, RANDOM, RANDOM), sources(picks));
assertEquals(List.of(1L, 2L, 3L, 4L), appids(picks));
}
}
@@ -0,0 +1,106 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.PlayedSeries;
import core_service.modules.library.domain.PlayedStudio;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.PageRequest;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class GameCreditRepositoryTest extends AbstractPersistenceTest {
private static final long USER_ID = 1L;
private static final PageRequest TOP = PageRequest.of(0, 6);
@Autowired
private GameCreditRepository repository;
@Autowired
private GameCreditCheckRepository checkRepository;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private GameReleaseRepository gameReleaseRepository;
@BeforeEach
void clean() {
userGameRepository.deleteAll();
repository.deleteAll();
checkRepository.deleteAll();
gameReleaseRepository.deleteAll();
}
private UserGame game(long appid, GameStatus status, int playtime) {
steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now()));
UserGame game = new UserGame(USER_ID, appid, playtime, Instant.now(), Instant.now());
game.setStatus(status);
return userGameRepository.saveAndFlush(game);
}
@Test
void findPlayedSeries_MostPlayedFirstWithTheLatestPlayedRelease() {
game(1L, GameStatus.FINISHED, 600);
game(2L, GameStatus.FINISHED, 300);
game(3L, GameStatus.BACKLOG, 5000);
game(4L, GameStatus.DROPPED, 0);
game(5L, GameStatus.PLAYING, 100);
gameReleaseRepository.upsert(1L, LocalDate.of(2007, 11, 20), false, Instant.now());
gameReleaseRepository.upsert(2L, LocalDate.of(2010, 1, 26), false, Instant.now());
repository.insert(1L, "SERIES", 10L, "Mass Effect");
repository.insert(2L, "SERIES", 10L, "Mass Effect");
repository.insert(3L, "SERIES", 10L, "Mass Effect");
repository.insert(4L, "SERIES", 20L, "Dropped Series");
repository.insert(5L, "SERIES", 30L, "Portal");
List<PlayedSeries> series = repository.findPlayedSeries(USER_ID, TOP);
assertThat(series).containsExactly(
new PlayedSeries(10L, "Mass Effect", LocalDate.of(2010, 1, 26)),
new PlayedSeries(30L, "Portal", null));
}
@Test
void findPlayedStudios_DevelopersOfPlayedGamesOnly() {
game(1L, GameStatus.FINISHED, 600);
game(2L, GameStatus.FINISHED, 900);
game(3L, GameStatus.BACKLOG, 0);
repository.insert(1L, "DEVELOPER", 5L, "BioWare");
repository.insert(1L, "PUBLISHER", 6L, "EA");
repository.insert(2L, "PUBLISHER", 6L, "EA");
repository.insert(3L, "DEVELOPER", 7L, "Unplayed Studio");
assertThat(repository.findPlayedStudios(USER_ID, TOP)).containsExactly(new PlayedStudio(5L, "BioWare"));
}
@Test
void findUncheckedAppids_SkipsCheckedRemovedAndHidden() {
game(1L, GameStatus.BACKLOG, 0);
game(2L, GameStatus.BACKLOG, 0);
game(3L, GameStatus.BACKLOG, 0);
game(4L, GameStatus.BACKLOG, 0);
game(5L, GameStatus.BACKLOG, 0);
userGameRepository.findById(new UserGameId(USER_ID, 3L)).orElseThrow().setRemovedAt(Instant.now());
userGameRepository.findById(new UserGameId(USER_ID, 4L)).orElseThrow().setHiddenAt(Instant.now());
userGameRepository.flush();
checkRepository.upsert(2L, Instant.now());
assertThat(checkRepository.findUncheckedAppids(10)).containsExactly(1L, 5L);
assertThat(checkRepository.findUncheckedAppids(1)).containsExactly(1L);
}
}
@@ -0,0 +1,167 @@
package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.repo.GameCreditCheckRepository;
import core_service.modules.library.repo.GameCreditRepository;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.springframework.core.task.SyncTaskExecutor;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.SimpleTransactionStatus;
import java.time.Instant;
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.anyCollection;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class GameCreditServiceTest extends AbstractUnitTest {
@Mock
private IgdbApiService igdbApiService;
@Mock
private GameCreditRepository creditRepository;
@Mock
private GameCreditCheckRepository checkRepository;
private GameCreditService service;
@BeforeEach
void setUp() {
PlatformTransactionManager transactionManager = mock(PlatformTransactionManager.class);
lenient().when(transactionManager.getTransaction(any())).thenReturn(new SimpleTransactionStatus());
service = new GameCreditService(igdbApiService, creditRepository, checkRepository, new SyncTaskExecutor(),
transactionManager, 30, 250);
}
private static IgdbApiService.Credits credits() {
return new IgdbApiService.Credits(List.of(new IgdbApiService.Series(10, "Mass Effect")),
List.of(new IgdbApiService.Company(5, "BioWare", true, false),
new IgdbApiService.Company(6, "EA", false, true)));
}
@Test
void refresh_ReplacesTheCreditsAndWritesTheCheckOfAnsweredGames() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(checkRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getCredits(Set.of(620L, 700L))).thenReturn(Optional.of(Map.of(620L, credits())));
service.refresh(List.of(620L, 700L));
verify(creditRepository).deleteByAppid(620L);
verify(creditRepository).insert(620L, "SERIES", 10L, "Mass Effect");
verify(creditRepository).insert(620L, "DEVELOPER", 5L, "BioWare");
verify(creditRepository).insert(620L, "PUBLISHER", 6L, "EA");
verify(checkRepository).upsert(eq(620L), any(Instant.class));
}
@Test
void refresh_StoresADeveloperThatAlsoPublishedOnce() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(checkRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
IgdbApiService.Credits both = new IgdbApiService.Credits(List.of(),
List.of(new IgdbApiService.Company(5, "BioWare", true, true)));
when(igdbApiService.getCredits(any())).thenReturn(Optional.of(Map.of(620L, both)));
service.refresh(List.of(620L));
verify(creditRepository).insert(620L, "DEVELOPER", 5L, "BioWare");
verify(creditRepository, never()).insert(anyLong(), eq("PUBLISHER"), anyLong(), anyString());
}
@Test
void refresh_GameUnknownToIgdb_WritesOnlyTheCheck() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(checkRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getCredits(any())).thenReturn(Optional.of(Map.of()));
service.refresh(List.of(700L));
verify(creditRepository).deleteByAppid(700L);
verify(creditRepository, never()).insert(anyLong(), anyString(), anyLong(), anyString());
verify(checkRepository).upsert(eq(700L), any(Instant.class));
}
@Test
void refresh_IgdbFailed_WritesNothing() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(checkRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getCredits(any())).thenReturn(Optional.empty());
service.refresh(List.of(700L));
verify(checkRepository, never()).upsert(any(), any());
verifyNoInteractions(creditRepository);
}
@Test
void backfill_RefreshesTheNextUncheckedBatchOnly() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(checkRepository.findUncheckedAppids(250)).thenReturn(List.of(1L, 2L));
when(checkRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getCredits(Set.of(1L, 2L))).thenReturn(Optional.of(Map.of(1L, credits())));
service.backfill();
verify(checkRepository).upsert(eq(1L), any(Instant.class));
verify(checkRepository).upsert(eq(2L), any(Instant.class));
}
@Test
void backfill_NothingUnchecked_CallsNoIgdb() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(checkRepository.findUncheckedAppids(250)).thenReturn(List.of());
service.backfill();
verify(igdbApiService, never()).getCredits(any());
verify(igdbApiService, never()).getCreditsByGameIds(any());
}
@Test
void backfill_IgdbDown_WritesNothingAndDoesNotThrow() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(checkRepository.findUncheckedAppids(250)).thenReturn(List.of(1L));
when(checkRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getCredits(any())).thenThrow(new IllegalStateException("down"));
service.backfill();
verify(checkRepository, never()).upsert(any(), any());
}
@Test
void backfill_IgdbOff_DoesNothing() {
when(igdbApiService.isEnabled()).thenReturn(false);
service.backfill();
verifyNoInteractions(checkRepository);
}
@Test
void refreshInBackground_AllFresh_QueuesNothing() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(checkRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of(620L));
service.refreshInBackground(List.of(620L));
verify(igdbApiService, never()).getCredits(any());
}
}
@@ -56,7 +56,7 @@ class GameImportWorkerTest {
.thenReturn(Optional.of(7L), Optional.empty()); .thenReturn(Optional.of(7L), Optional.empty());
when(repository.findStalled(any())).thenReturn(Map.of()); when(repository.findStalled(any())).thenReturn(Map.of());
GameImportWorker worker = new GameImportWorker(repository, mock(ManualGameService.class), GameImportWorker worker = new GameImportWorker(repository, mock(ManualGameService.class),
mock(TimeToBeatService.class), mock(MoodService.class), mock(ReleaseDateService.class), 5); mock(TimeToBeatService.class), mock(MoodService.class), mock(ReleaseDateService.class), mock(GameCreditService.class), 5);
try { try {
worker.wakeMatching(); worker.wakeMatching();
verify(repository, timeout(2000)).findNext(GameImportWorker.MATCHING_QUEUE); verify(repository, timeout(2000)).findNext(GameImportWorker.MATCHING_QUEUE);
@@ -132,6 +132,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@Mock @Mock
private GameEditionService gameEditionService; private GameEditionService gameEditionService;
@Mock
private GameCreditService gameCreditService;
@Mock @Mock
private ApplicationEventPublisher eventPublisher; private ApplicationEventPublisher eventPublisher;
@@ -576,6 +579,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
verify(moodService).refresh(List.of(220L, -1025L)); verify(moodService).refresh(List.of(220L, -1025L));
verify(releaseDateService).refresh(List.of(220L, -1025L)); verify(releaseDateService).refresh(List.of(220L, -1025L));
verify(gameEditionService).refresh(List.of(220L, -1025L)); verify(gameEditionService).refresh(List.of(220L, -1025L));
verify(gameCreditService).refresh(List.of(220L, -1025L));
} }
@Test @Test
@@ -53,6 +53,9 @@ class ManualGameServiceTest extends AbstractUnitTest {
@Mock @Mock
private GameEditionService gameEditionService; private GameEditionService gameEditionService;
@Mock
private GameCreditService gameCreditService;
@Mock @Mock
private ReleaseDateService releaseDateService; private ReleaseDateService releaseDateService;
@@ -64,7 +67,7 @@ class ManualGameServiceTest extends AbstractUnitTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
service = new ManualGameService(igdbApiService, userGameRepository, steamGameRepository, mergeRepository, timeToBeatService, service = new ManualGameService(igdbApiService, userGameRepository, steamGameRepository, mergeRepository, timeToBeatService,
moodService, gameEditionService, releaseDateService, transactionManager); moodService, gameEditionService, gameCreditService, releaseDateService, transactionManager);
} }
@Test @Test
@@ -30,7 +30,7 @@ import java.util.concurrent.atomic.AtomicReference;
// Every cached context keeps its own pool: small pools keep them all below Postgres' 100 clients. // Every cached context keeps its own pool: small pools keep them all below Postgres' 100 clients.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {"app.library.nightly-sync-cron=-", "app.library.image-repair-cron=-", "app.library.merge-undo-purge-cron=-", "app.inbox.purge-cron=-", "app.follow.digest-ms=86400000", "app.rewards.settle-cron=-", "app.speedrun.enabled=false", properties = {"app.library.nightly-sync-cron=-", "app.library.image-repair-cron=-", "app.library.merge-undo-purge-cron=-", "app.inbox.purge-cron=-", "app.follow.digest-ms=86400000", "app.rewards.settle-cron=-", "app.speedrun.enabled=false",
"app.library.import-watchdog-ms=86400000", "app.library.import-watchdog-ms=86400000", "app.library.credit-backfill-ms=86400000",
"spring.datasource.hikari.maximum-pool-size=4"}) "spring.datasource.hikari.maximum-pool-size=4"})
@ActiveProfiles("dev") @ActiveProfiles("dev")
public abstract class AbstractIntegrationTest { public abstract class AbstractIntegrationTest {