diff --git a/readme.md b/readme.md index 5a29f7f..9bad1c1 100644 --- a/readme.md +++ b/readme.md @@ -21,11 +21,11 @@ To Be Played is split into three repositories: can be taken over; its own finish then changes nothing. - **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. -- **Picker**: up to 10 backlog games for a time budget (IGDB normal time to beat) and a mood, mixed from four - sources, one per slot in turn: a game sharing moods with a random one of the user's 5 most played games (the - most shared moods first), the game waiting longest (oldest `firstSeenAt`), and a random one (HIGH priority first, - then MEDIUM, then the rest), and a random one of the first 5 games of the user's own backlog order that pass the - budget, mood and exclude filters (`NEXT_UP`). A source with nothing left gives its slot to a random game; no game appears twice. +- **Picker**: four rows of up to 3 backlog games each, for a time budget (IGDB normal time to beat), in this order: + `NEXT_UP` (the first games of the user's own backlog order), `SIMILAR` ("because you played": for each of the 3 most + played games the first backlog game sharing its moods, no game twice), `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. 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`, `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 | | `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 | -| `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/games?key=&arg=&page=` (the games behind a stats number; `400 BAD_ARG`) | JWT | | `GET` | `/api/library/achievements/summary` | JWT | diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java index 8b76428..b10e925 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -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 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 exclude, - @RequestParam(defaultValue = "3") int count) { + @RequestParam(required = false) PickSource category) { List 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)); } /** diff --git a/src/main/java/core_service/modules/library/api/dto/PickerCategoryDto.java b/src/main/java/core_service/modules/library/api/dto/PickerCategoryDto.java new file mode 100644 index 0000000..3a89504 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/PickerCategoryDto.java @@ -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 games) {} diff --git a/src/main/java/core_service/modules/library/api/dto/PickerDto.java b/src/main/java/core_service/modules/library/api/dto/PickerDto.java index 280b43f..f2a2036 100644 --- a/src/main/java/core_service/modules/library/api/dto/PickerDto.java +++ b/src/main/java/core_service/modules/library/api/dto/PickerDto.java @@ -2,4 +2,4 @@ package core_service.modules.library.api.dto; import java.util.List; -public record PickerDto(List games) {} +public record PickerDto(List categories) {} diff --git a/src/main/java/core_service/modules/library/domain/PickMix.java b/src/main/java/core_service/modules/library/domain/PickMix.java deleted file mode 100644 index 6b20f26..0000000 --- a/src/main/java/core_service/modules/library/domain/PickMix.java +++ /dev/null @@ -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 mix(List similar, List longest, List random, - List nextUp, int count) { - List> bySource = List.of(similar, longest, random, nextUp); - Set used = new HashSet<>(); - List 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 games, Set used) { - return games.stream().filter(g -> !used.contains(g.getAppid())).findFirst().orElse(null); - } -} diff --git a/src/main/java/core_service/modules/library/domain/PickSource.java b/src/main/java/core_service/modules/library/domain/PickSource.java index 937bb50..ab8d5ef 100644 --- a/src/main/java/core_service/modules/library/domain/PickSource.java +++ b/src/main/java/core_service/modules/library/domain/PickSource.java @@ -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 } diff --git a/src/main/java/core_service/modules/library/service/LibraryQueryService.java b/src/main/java/core_service/modules/library/service/LibraryQueryService.java index 13f6080..5ba93c1 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -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 excludeAppids, int count) { + public PickerDto pickBacklogGames(Long userId, Integer maxMinutes, Mood mood, List excludeAppids, + PickSource only) { releaseDateService.refreshBacklogInBackground(userId); if (mood == Mood.SPEEDRUN) { speedrunService.refreshBacklogInBackground(userId); } else if (mood != null) { moodService.refreshBacklogInBackground(userId); } - List random = userGameRepository.findPickerGames(userId, maxMinutes, mood, excludeAppids, count); - List longest = userGameRepository.findLongestWaitingGames(userId, maxMinutes, mood, excludeAppids, count); - Optional anchor = pickAnchor(userId); - List similar = anchor - .map(a -> userGameRepository.findSimilarGames(userId, a.getAppid(), maxMinutes, mood, excludeAppids, count)) - .orElse(List.of()); - List 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 sources = only == null ? List.of(PickSource.values()) : List.of(only); + Map>> 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 withReasons(List picks, Optional anchor) { - List 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 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 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> withReason(List games, + Function 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 pickAnchor(Long userId) { - List played = userGameRepository.findMostPlayedGames(userId, ANCHOR_CANDIDATES); - if (played.isEmpty()) return Optional.empty(); - return Optional.of(played.get(ThreadLocalRandom.current().nextInt(played.size()))); + private List> becauseYouPlayed(Long userId, Integer maxMinutes, + List excludeAppids) { + List anchors = userGameRepository.findMostPlayedGames(userId, ROW_SIZE); + Map names = steamGameRepository.findAllById(anchors.stream().map(UserGame::getAppid).toList()) + .stream().filter(g -> g.getName() != null) + .collect(Collectors.toMap(SteamGame::getAppid, SteamGame::getName)); + Set used = new HashSet<>(); + List> 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 toCategories(Map>> rows) { + List dtos = toDtos(rows.values().stream().flatMap(List::stream).map(Map.Entry::getKey).toList()); + List categories = new ArrayList<>(); + int index = 0; + for (Map.Entry>> row : rows.entrySet()) { + List games = new ArrayList<>(); + for (Map.Entry pick : row.getValue()) { + games.add(new PickedGameDto(dtos.get(index++), pick.getValue())); + } + if (!games.isEmpty()) categories.add(new PickerCategoryDto(row.getKey(), games)); + } + return categories; } /** diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java index 98cfb32..88c2933 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java @@ -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)); } } diff --git a/src/test/java/core_service/modules/library/domain/PickMixTest.java b/src/test/java/core_service/modules/library/domain/PickMixTest.java deleted file mode 100644 index 01ae4de..0000000 --- a/src/test/java/core_service/modules/library/domain/PickMixTest.java +++ /dev/null @@ -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 games(long... appids) { - return Arrays.stream(appids).mapToObj(PickMixTest::game).toList(); - } - - private static List sources(List picks) { - return picks.stream().map(Pick::source).toList(); - } - - private static List appids(List picks) { - return picks.stream().map(p -> p.game().getAppid()).toList(); - } - - @Test - void mix_ThreeSources_OneGameEachInSlotOrder() { - List 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 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 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 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 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 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 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)); - } -}