Public Access
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
117ff61730 | ||
|
|
8dd398321c |
No files matched your search
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user