feat(picker): return rows of up to 3 games per source

Picker returns categories in fixed order: NEXT_UP, SIMILAR, LONGEST,
RANDOM, each with up to 3 games. Empty rows are left out.
Param `count` is removed; new optional `category` loads one row (reroll).
Budget and exclude apply to all rows; mood only to RANDOM.
SIMILAR takes the 3 most played games, one similar game each.
Remove PickMix.
This commit is contained in:
lobo committed 2026-10-06 19:41:24 +02:00
1 parent 867dc4c4d1
commit 8dd398321c
9 files changed
+223 -305

No files matched your search

+6 -6
View File
@@ -21,11 +21,11 @@ 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**: four 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), `SIMILAR` ("because you played": for each of the 3 most
most shared moods first), the game waiting longest (oldest `firstSeenAt`), and a random one (HIGH priority first, played games the first backlog game sharing its moods, no game twice), `LONGEST` (oldest `firstSeenAt`) and `RANDOM`
then MEDIUM, then the rest), and a random one of the first 5 games of the user's own backlog order that pass the (the mood row: random, HIGH priority first, then MEDIUM, then the rest). The mood filters only the `RANDOM` row; budget
budget, mood and exclude filters (`NEXT_UP`). A source with nothing left gives its slot to a random game; no game appears twice. and exclude filters apply to every row. A row with no game is left out. A game can show in more than one row.
Every game carries a `reason` (`source`, plus the anchor game for `SIMILAR` or `firstSeenAt` for `LONGEST`). Every game carries a `reason` (`source`, plus the anchor game for `SIMILAR` or `firstSeenAt` for `LONGEST`).
Games already shown can be excluded for a reroll. Moods `STORY`, `COOP`, 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.
@@ -202,7 +202,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 |
@@ -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));
} }
/** /**
@@ -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) {}
@@ -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,12 @@
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. RANDOM is the mood row.
*/ */
public enum PickSource { public enum PickSource {
NEXT_UP,
SIMILAR, SIMILAR,
LONGEST, LONGEST,
RANDOM, RANDOM
NEXT_UP
} }
@@ -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;
@@ -54,14 +54,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 +76,7 @@ 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;
private final UserGameRepository userGameRepository; private final UserGameRepository userGameRepository;
private final UserSteamSyncRepository userSteamSyncRepository; private final UserSteamSyncRepository userSteamSyncRepository;
@@ -111,28 +111,34 @@ 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}, in the order of its constants, each with up to three backlog games. Budget and
* longest, a random one (HIGH priority first), and a random one of the first five of the user's own backlog * exclude apply to every row; the mood only to the RANDOM row. Rows without a game are left out.
* order. A source with nothing left gives its slot to a random game.
* 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 SIMILAR -> becauseYouPlayed(userId, maxMinutes, excludeAppids);
excludeAppids, NEXT_UP_CANDIDATES)); case LONGEST -> withReason(userGameRepository.findLongestWaitingGames(userId, maxMinutes, null,
Collections.shuffle(nextUp); excludeAppids, ROW_SIZE), g -> PickReasonDto.longest(g.getFirstSeenAt()));
return new PickerDto(withReasons(PickMix.mix(similar, longest, random, nextUp, count), anchor)); case RANDOM -> withReason(userGameRepository.findPickerGames(userId, maxMinutes, mood,
excludeAppids, ROW_SIZE), g -> PickReasonDto.random());
});
}
return new PickerDto(toCategories(rows));
} }
/** /**
@@ -146,34 +152,44 @@ 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. An anchor
* without a candidate is skipped.
*/ */
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));
Set<Long> used = new HashSet<>();
List<Map.Entry<UserGame, PickReasonDto>> picks = new ArrayList<>();
for (UserGame anchor : anchors) {
userGameRepository.findSimilarGames(userId, anchor.getAppid(), maxMinutes, null, excludeAppids, ROW_SIZE)
.stream().filter(g -> used.add(g.getAppid())).findFirst()
.ifPresent(g -> picks.add(
Map.entry(g, PickReasonDto.similar(anchor, names.get(anchor.getAppid())))));
}
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;
} }
/** /**
@@ -27,9 +27,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;
@@ -100,6 +103,17 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
} }
} }
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 +124,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 +140,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 +154,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 +166,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 +187,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 +199,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 +209,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 +219,129 @@ 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());
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", contains("NEXT_UP", "SIMILAR", "LONGEST", "RANDOM")))
.andExpect(jsonPath("$.games[*].reason.source", contains("SIMILAR", "LONGEST", "RANDOM"))) .andExpect(jsonPath("$.categories[*].games.length()", everyItem(lessThanOrEqualTo(3))))
.andExpect(jsonPath("$.games[0].appid").value(30)) .andExpect(jsonPath("$.categories[0].games[*].reason.source", everyItem(is("NEXT_UP"))))
.andExpect(jsonPath("$.games[0].reason.anchorAppid").value(1)) .andExpect(jsonPath("$.categories[2].games[0].reason.firstSeenAt").exists());
.andExpect(jsonPath("$.games[0].reason.anchorName").value("Game 1"))
.andExpect(jsonPath("$.games[0].reason.anchorPlaytimeMinutes").value(600))
.andExpect(jsonPath("$.games[1].appid").value(10))
.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);
mockMvc.perform(get("/api/library/picker").param("maxMinutes", "300").param("exclude", "30"))
.andExpect(jsonPath("$.categories[*].games[*].appid", everyItem(is(10))))
.andExpect(jsonPath("$.categories[*].games[*].appid", not(hasItem(20))));
}
@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 +352,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 +362,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 +380,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 +396,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));
}
}