Public Access
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:
1 parent
867dc4c4d1
commit
8dd398321c
9 files changed
+223
-305
No files matched your search
@@ -39,6 +39,7 @@ import core_service.modules.library.domain.CatalogHit;
|
||||
import core_service.modules.library.domain.GameStatus;
|
||||
import core_service.modules.library.domain.LinkPair;
|
||||
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.StatKey;
|
||||
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 MIN_ACTIVITY_DAYS = 7;
|
||||
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_BACKLOG_GAMES = 20;
|
||||
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) Mood mood,
|
||||
@RequestParam(required = false) List<Long> exclude,
|
||||
@RequestParam(defaultValue = "3") int count) {
|
||||
@RequestParam(required = false) PickSource category) {
|
||||
List<Long> excluded = exclude == null ? List.of()
|
||||
: 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;
|
||||
Long userId = principal.getIdAsLong();
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
NEXT_UP,
|
||||
SIMILAR,
|
||||
LONGEST,
|
||||
RANDOM,
|
||||
NEXT_UP
|
||||
RANDOM
|
||||
}
|
||||
@@ -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.PickReasonDto;
|
||||
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.PublicLibraryDto;
|
||||
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.GameTimeToBeat;
|
||||
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.ProfileSection;
|
||||
import core_service.modules.library.domain.StatGroup;
|
||||
@@ -54,14 +54,15 @@ import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@@ -75,8 +76,7 @@ public class LibraryQueryService {
|
||||
private static final int PUBLIC_FINISHED = 12;
|
||||
private static final int CLOSEST_GAMES = 5;
|
||||
private static final int RECENT_GAMES = 5;
|
||||
private static final int ANCHOR_CANDIDATES = 5;
|
||||
private static final int NEXT_UP_CANDIDATES = 5;
|
||||
private static final int ROW_SIZE = 3;
|
||||
|
||||
private final UserGameRepository userGameRepository;
|
||||
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
|
||||
* longest, a random one (HIGH priority first), and a random one of the first five of the user's own backlog
|
||||
* order. A source with nothing left gives its slot to a random game.
|
||||
* One row per {@link PickSource}, in the order of its constants, each with up to three backlog games. Budget and
|
||||
* exclude apply to every 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.
|
||||
*
|
||||
* @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);
|
||||
if (mood == Mood.SPEEDRUN) {
|
||||
speedrunService.refreshBacklogInBackground(userId);
|
||||
} else if (mood != null) {
|
||||
moodService.refreshBacklogInBackground(userId);
|
||||
}
|
||||
List<UserGame> random = userGameRepository.findPickerGames(userId, maxMinutes, mood, excludeAppids, count);
|
||||
List<UserGame> longest = userGameRepository.findLongestWaitingGames(userId, maxMinutes, mood, excludeAppids, count);
|
||||
Optional<UserGame> anchor = pickAnchor(userId);
|
||||
List<UserGame> similar = anchor
|
||||
.map(a -> userGameRepository.findSimilarGames(userId, a.getAppid(), maxMinutes, mood, excludeAppids, count))
|
||||
.orElse(List.of());
|
||||
List<UserGame> nextUp = new ArrayList<>(userGameRepository.findNextUpGames(userId, maxMinutes, mood,
|
||||
excludeAppids, NEXT_UP_CANDIDATES));
|
||||
Collections.shuffle(nextUp);
|
||||
return new PickerDto(withReasons(PickMix.mix(similar, longest, random, nextUp, count), anchor));
|
||||
List<PickSource> sources = only == null ? List.of(PickSource.values()) : List.of(only);
|
||||
Map<PickSource, List<Map.Entry<UserGame, PickReasonDto>>> rows = new EnumMap<>(PickSource.class);
|
||||
for (PickSource source : sources) {
|
||||
rows.put(source, switch (source) {
|
||||
case NEXT_UP -> withReason(userGameRepository.findNextUpGames(userId, maxMinutes, null,
|
||||
excludeAppids, ROW_SIZE), g -> PickReasonDto.nextUp());
|
||||
case SIMILAR -> becauseYouPlayed(userId, maxMinutes, excludeAppids);
|
||||
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 +152,44 @@ public class LibraryQueryService {
|
||||
limit == null ? Integer.MAX_VALUE : limit)));
|
||||
}
|
||||
|
||||
private List<PickedGameDto> withReasons(List<PickMix.Pick> picks, Optional<UserGame> anchor) {
|
||||
List<GameDto> dtos = toDtos(picks.stream().map(PickMix.Pick::game).toList());
|
||||
String anchorName = picks.stream().anyMatch(p -> p.source() == PickSource.SIMILAR)
|
||||
? 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();
|
||||
};
|
||||
private static List<Map.Entry<UserGame, PickReasonDto>> withReason(List<UserGame> games,
|
||||
Function<UserGame, PickReasonDto> reason) {
|
||||
return games.stream().map(g -> Map.entry(g, reason.apply(g))).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
List<UserGame> played = userGameRepository.findMostPlayedGames(userId, ANCHOR_CANDIDATES);
|
||||
if (played.isEmpty()) return Optional.empty();
|
||||
return Optional.of(played.get(ThreadLocalRandom.current().nextInt(played.size())));
|
||||
private List<Map.Entry<UserGame, PickReasonDto>> becauseYouPlayed(Long userId, Integer maxMinutes,
|
||||
List<Long> excludeAppids) {
|
||||
List<UserGame> anchors = userGameRepository.findMostPlayedGames(userId, ROW_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.util.Arrays;
|
||||
|
||||
import static org.hamcrest.Matchers.anyOf;
|
||||
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.hasItem;
|
||||
import static org.hamcrest.Matchers.hasSize;
|
||||
import static org.hamcrest.Matchers.is;
|
||||
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
|
||||
void picker_WithMood_PicksOnlyGamesKnownToHaveIt() throws Exception {
|
||||
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(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(jsonPath("$.games[*].appid", containsInAnyOrder(10)));
|
||||
mockMvc.perform(get("/api/library/picker"))
|
||||
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10, 20, 30)));
|
||||
.andExpect(jsonPath("$.categories[0].games[*].appid", containsInAnyOrder(10)));
|
||||
mockMvc.perform(get("/api/library/picker").param("category", "RANDOM"))
|
||||
.andExpect(jsonPath("$.categories[0].games[*].appid", containsInAnyOrder(10, 20, 30)));
|
||||
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(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(jsonPath("$.games[*].appid", containsInAnyOrder(10)));
|
||||
.andExpect(jsonPath("$.categories[0].games[*].appid", containsInAnyOrder(10)));
|
||||
verify(speedrunService).refreshBacklogInBackground(USER_ID);
|
||||
verifyNoInteractions(moodService);
|
||||
}
|
||||
@@ -140,8 +154,9 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
|
||||
gameMoodsRepository.upsert(10L, 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"))
|
||||
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10)));
|
||||
mockMvc.perform(get("/api/library/picker").param("category", "RANDOM").param("mood", "STORY")
|
||||
.param("maxMinutes", "900"))
|
||||
.andExpect(jsonPath("$.categories[0].games[*].appid", containsInAnyOrder(10)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -151,12 +166,18 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
|
||||
}
|
||||
|
||||
@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);
|
||||
|
||||
mockMvc.perform(get("/api/library/picker"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.games", hasSize(0)));
|
||||
.andExpect(jsonPath("$.categories", hasSize(0)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -166,10 +187,10 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
|
||||
game(30L, GameStatus.FINISHED, 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(jsonPath("$.games[*].appid", containsInAnyOrder(10, 20)))
|
||||
.andExpect(jsonPath("$.games[?(@.appid == 10)].timeToBeatMinutes").value(60));
|
||||
.andExpect(jsonPath("$.categories[0].games[*].appid", containsInAnyOrder(10, 20)))
|
||||
.andExpect(jsonPath("$.categories[0].games[?(@.appid == 10)].timeToBeatMinutes").value(60));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -178,9 +199,9 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
|
||||
game(20L, GameStatus.BACKLOG, null, 301);
|
||||
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(jsonPath("$.games[*].appid", containsInAnyOrder(10)));
|
||||
.andExpect(jsonPath("$.categories[0].games[*].appid", containsInAnyOrder(10)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -188,8 +209,8 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
|
||||
game(10L, GameStatus.BACKLOG, null, 300);
|
||||
game(20L, GameStatus.BACKLOG, null, null);
|
||||
|
||||
mockMvc.perform(get("/api/library/picker").param("maxMinutes", "0"))
|
||||
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10, 20)));
|
||||
mockMvc.perform(get("/api/library/picker").param("category", "RANDOM").param("maxMinutes", "0"))
|
||||
.andExpect(jsonPath("$.categories[0].games[*].appid", containsInAnyOrder(10, 20)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -198,80 +219,129 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
|
||||
game(20L, 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(jsonPath("$.games[*].appid", containsInAnyOrder(20)));
|
||||
.andExpect(jsonPath("$.categories[0].games[*].appid", containsInAnyOrder(20)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void picker_FillsSlotsByPriorityFirst() throws Exception {
|
||||
void picker_RandomRow_FillsByPriorityFirst() throws Exception {
|
||||
game(10L, GameStatus.BACKLOG, null, 60);
|
||||
game(20L, GameStatus.BACKLOG, GamePriority.LOW, 60);
|
||||
game(30L, GameStatus.BACKLOG, GamePriority.HIGH, 60);
|
||||
game(40L, GameStatus.BACKLOG, GamePriority.MEDIUM, 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(jsonPath("$.games[*].appid", containsInAnyOrder(30, 40)))
|
||||
.andExpect(jsonPath("$.games[0].appid").value(30));
|
||||
.andExpect(jsonPath("$.categories[0].games", hasSize(3)))
|
||||
.andExpect(jsonPath("$.categories[0].games[0].appid").value(30))
|
||||
.andExpect(jsonPath("$.categories[0].games[1].appid").value(40));
|
||||
}
|
||||
|
||||
@Test
|
||||
void picker_ThreeSlots_OneGamePerSourceWithReason() throws Exception {
|
||||
void picker_AllRows_InFixedOrderEachAtMostThree() throws Exception {
|
||||
game(1L, GameStatus.PLAYING, null, null, 600, SEEN);
|
||||
game(10L, GameStatus.BACKLOG, null, null, 0, Instant.parse("2020-01-01T00:00:00Z"));
|
||||
game(20L, GameStatus.BACKLOG, null, null, 0, SEEN);
|
||||
game(30L, GameStatus.BACKLOG, null, null, 0, SEEN);
|
||||
for (long appid = 10; appid <= 15; appid++) {
|
||||
game(appid, GameStatus.BACKLOG, null, null);
|
||||
}
|
||||
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"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.games", hasSize(3)))
|
||||
.andExpect(jsonPath("$.games[*].reason.source", contains("SIMILAR", "LONGEST", "RANDOM")))
|
||||
.andExpect(jsonPath("$.games[0].appid").value(30))
|
||||
.andExpect(jsonPath("$.games[0].reason.anchorAppid").value(1))
|
||||
.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));
|
||||
.andExpect(jsonPath("$.categories[*].source", contains("NEXT_UP", "SIMILAR", "LONGEST", "RANDOM")))
|
||||
.andExpect(jsonPath("$.categories[*].games.length()", everyItem(lessThanOrEqualTo(3))))
|
||||
.andExpect(jsonPath("$.categories[0].games[*].reason.source", everyItem(is("NEXT_UP"))))
|
||||
.andExpect(jsonPath("$.categories[2].games[0].reason.firstSeenAt").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
void picker_NoPlaytime_SimilarSlotFallsBackToRandom() throws Exception {
|
||||
game(10L, GameStatus.BACKLOG, null, null, 0, Instant.parse("2020-01-01T00:00:00Z"));
|
||||
game(20L, GameStatus.BACKLOG, null, null);
|
||||
game(30L, GameStatus.BACKLOG, null, null);
|
||||
void picker_UpNext_FirstThreeOfOwnOrderInOrder() 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"))
|
||||
.andExpect(jsonPath("$.games", hasSize(3)))
|
||||
.andExpect(jsonPath("$.games[*].reason.source", containsInAnyOrder("LONGEST", "RANDOM", "RANDOM")));
|
||||
mockMvc.perform(get("/api/library/picker").param("category", "NEXT_UP"))
|
||||
.andExpect(jsonPath("$.categories[0].games[*].appid", contains(7, 6, 5)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void picker_FewerBacklogGamesThanCount_NoDuplicates() throws Exception {
|
||||
game(10L, GameStatus.BACKLOG, null, null);
|
||||
game(20L, GameStatus.BACKLOG, null, null);
|
||||
|
||||
mockMvc.perform(get("/api/library/picker").param("count", "3"))
|
||||
.andExpect(jsonPath("$.games", hasSize(2)))
|
||||
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10, 20)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void picker_DefaultsToThreeAndCapsTheCount() throws Exception {
|
||||
for (long appid = 1; appid <= 12; appid++) {
|
||||
game(appid, GameStatus.BACKLOG, null, 60);
|
||||
void picker_BecauseYouPlayed_OneSimilarGamePerTopPlayedGame() 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);
|
||||
game(12L, GameStatus.BACKLOG, null, null);
|
||||
game(13L, GameStatus.BACKLOG, null, null);
|
||||
String[] moods = {"STORY", "COOP", "ACTION"};
|
||||
for (int i = 0; i < 3; i++) {
|
||||
gameMoodsRepository.upsert(1L + i, new String[]{moods[i]}, new String[0], false, Instant.now());
|
||||
gameMoodsRepository.upsert(11L + i, new String[]{moods[i]}, new String[0], false, Instant.now());
|
||||
}
|
||||
|
||||
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"))
|
||||
.andExpect(jsonPath("$.games", hasSize(3)));
|
||||
mockMvc.perform(get("/api/library/picker").param("count", "500"))
|
||||
.andExpect(jsonPath("$.games", hasSize(10)));
|
||||
mockMvc.perform(get("/api/library/picker").param("count", "0"))
|
||||
.andExpect(jsonPath("$.games", hasSize(1)));
|
||||
.andExpect(jsonPath("$.categories[*].source", contains("NEXT_UP", "LONGEST", "RANDOM")));
|
||||
}
|
||||
|
||||
@Test
|
||||
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
|
||||
@@ -282,8 +352,8 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
|
||||
removed.setRemovedAt(Instant.now());
|
||||
userGameRepository.save(removed);
|
||||
|
||||
mockMvc.perform(get("/api/library/picker"))
|
||||
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10)));
|
||||
mockMvc.perform(get("/api/library/picker").param("category", "RANDOM"))
|
||||
.andExpect(jsonPath("$.categories[0].games[*].appid", containsInAnyOrder(10)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -292,7 +362,7 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
|
||||
userGameRepository.save(new UserGame(2L, 99L, 0, Instant.now(), Instant.now()));
|
||||
|
||||
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.
|
||||
@@ -310,44 +380,13 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
|
||||
game(20L, GameStatus.BACKLOG, null, null);
|
||||
gameReleaseRepository.upsert(20L, berlinToday.plusDays(1), false, Instant.now());
|
||||
|
||||
mockMvc.perform(get("/api/library/picker"))
|
||||
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10)));
|
||||
mockMvc.perform(get("/api/library/picker").param("category", "RANDOM"))
|
||||
.andExpect(jsonPath("$.categories[0].games[*].appid", containsInAnyOrder(10)));
|
||||
|
||||
gameReleaseRepository.upsert(20L, berlinToday, false, Instant.now());
|
||||
|
||||
mockMvc.perform(get("/api/library/picker"))
|
||||
.andExpect(jsonPath("$.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))));
|
||||
mockMvc.perform(get("/api/library/picker").param("category", "RANDOM"))
|
||||
.andExpect(jsonPath("$.categories[0].games[*].appid", containsInAnyOrder(10, 20)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -357,8 +396,8 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
|
||||
game(20L, GameStatus.BACKLOG, null, null);
|
||||
gameReleaseRepository.upsert(10L, berlinToday.plusDays(1), false, Instant.now());
|
||||
|
||||
mockMvc.perform(get("/api/library/picker").param("count", "4"))
|
||||
.andExpect(jsonPath("$.games", hasSize(1)))
|
||||
.andExpect(jsonPath("$.games[0].appid").value(20));
|
||||
mockMvc.perform(get("/api/library/picker").param("category", "NEXT_UP"))
|
||||
.andExpect(jsonPath("$.categories[0].games", hasSize(1)))
|
||||
.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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user