From 33b230ee91ac1607ac168291a4173d1d4917082e Mon Sep 17 00:00:00 2001 From: lobo Date: Mon, 5 Oct 2026 16:13:19 +0200 Subject: [PATCH] feat(picker): mix similar, longest waiting and random picks Picker slots rotate three sources: backlog games that share moods with one of the top 5 played games, oldest first seen, and random. Each game carries a reason (source, anchor game, first seen). Empty slots fall back to random. Issue #28. --- readme.md | 19 +++- .../library/api/dto/PickReasonDto.java | 28 +++++ .../library/api/dto/PickedGameDto.java | 5 + .../modules/library/api/dto/PickerDto.java | 2 +- .../modules/library/domain/PickMix.java | 45 ++++++++ .../modules/library/domain/PickSource.java | 10 ++ .../library/repo/UserGameRepository.java | 100 +++++++++++++++--- .../library/service/LibraryQueryService.java | 46 +++++++- .../api/LibraryControllerPickerTest.java | 53 +++++++++- .../modules/library/domain/PickMixTest.java | 79 ++++++++++++++ .../library/repo/UserGameRepositoryTest.java | 83 +++++++++++++++ 11 files changed, 448 insertions(+), 22 deletions(-) create mode 100644 src/main/java/core_service/modules/library/api/dto/PickReasonDto.java create mode 100644 src/main/java/core_service/modules/library/api/dto/PickedGameDto.java create mode 100644 src/main/java/core_service/modules/library/domain/PickMix.java create mode 100644 src/main/java/core_service/modules/library/domain/PickSource.java create mode 100644 src/test/java/core_service/modules/library/domain/PickMixTest.java diff --git a/readme.md b/readme.md index 7ee4f7f..b511af2 100644 --- a/readme.md +++ b/readme.md @@ -21,9 +21,22 @@ 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 random backlog games for a time budget (IGDB normal time to beat) and a mood, HIGH priority - first, then MEDIUM, then the rest. Games already shown can be excluded for a reroll. Moods `STORY`, `COOP`, +- **Picker**: up to 10 backlog games for a time budget (IGDB normal time to beat) and a mood, mixed from three + 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). A source with nothing left gives its slot to a random game; no game appears twice. + 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. + + ```mermaid + flowchart LR + A["anchor: random of top 5 played"] --> S[similar] + S --> M["PickMix: rotate, dedupe, random fallback"] + L[longest waiting] --> M + R[random] --> M + M --> O["games + reason"] + ``` - **Speedrun games**: a backlog game counts as a speedrun game when speedrun.com has a game with the same name (and a release year at most one year off, when IGDB knows it) with at least one verified run. The game detail returns the speedrun.com link as `speedrunUrl`. speedrun.com has no Steam or IGDB link and no key, so one queue works for @@ -168,7 +181,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut | `GET`, `DELETE` | `/api/library/imports/{id}/undo-check` (`{games, changed}`), `/api/library/imports/{id}/games` (undo; `204`, `409 IMPORT_NOT_SAVED`) | JWT | | `POST` | `/api/library/sync` | JWT | | `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | JWT | -| `GET` | `/api/library/picker?maxMinutes=&mood=&count=&exclude=` | JWT | +| `GET` | `/api/library/picker?maxMinutes=&mood=&count=&exclude=` (each game has a `reason` object) | 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/dto/PickReasonDto.java b/src/main/java/core_service/modules/library/api/dto/PickReasonDto.java new file mode 100644 index 0000000..99fbbd8 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/PickReasonDto.java @@ -0,0 +1,28 @@ +package core_service.modules.library.api.dto; + +import core_service.modules.library.domain.PickSource; +import core_service.modules.library.domain.UserGame; + +import java.time.Instant; + +/** + * Why the picker chose a game. The anchor fields are set only for SIMILAR, {@code firstSeenAt} only for LONGEST; + * the others are null. + * + * @param anchorName null when the anchor game has no name + */ +public record PickReasonDto(PickSource source, Long anchorAppid, String anchorName, Integer anchorPlaytimeMinutes, + Instant firstSeenAt) { + + public static PickReasonDto similar(UserGame anchor, String anchorName) { + return new PickReasonDto(PickSource.SIMILAR, anchor.getAppid(), anchorName, anchor.getPlaytimeMinutes(), null); + } + + public static PickReasonDto longest(Instant firstSeenAt) { + return new PickReasonDto(PickSource.LONGEST, null, null, null, firstSeenAt); + } + + public static PickReasonDto random() { + return new PickReasonDto(PickSource.RANDOM, null, null, null, null); + } +} diff --git a/src/main/java/core_service/modules/library/api/dto/PickedGameDto.java b/src/main/java/core_service/modules/library/api/dto/PickedGameDto.java new file mode 100644 index 0000000..13d191a --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/PickedGameDto.java @@ -0,0 +1,5 @@ +package core_service.modules.library.api.dto; + +import com.fasterxml.jackson.annotation.JsonUnwrapped; + +public record PickedGameDto(@JsonUnwrapped GameDto game, PickReasonDto reason) {} 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 8dd865d..280b43f 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 games) {} diff --git a/src/main/java/core_service/modules/library/domain/PickMix.java b/src/main/java/core_service/modules/library/domain/PickMix.java new file mode 100644 index 0000000..271988f --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/PickMix.java @@ -0,0 +1,45 @@ +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 three 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 3; 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, int count) { + List> bySource = List.of(similar, longest, random); + 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 new file mode 100644 index 0000000..6862084 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/PickSource.java @@ -0,0 +1,10 @@ +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. + */ +public enum PickSource { + SIMILAR, + LONGEST, + RANDOM +} diff --git a/src/main/java/core_service/modules/library/repo/UserGameRepository.java b/src/main/java/core_service/modules/library/repo/UserGameRepository.java index d9eaf78..b0ccd6d 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java @@ -120,6 +120,21 @@ public interface UserGameRepository extends JpaRepository "and ug.source = core_service.modules.library.domain.GameSource.STEAM") List findAchievementProgress(@Param("userId") Long userId); + /** + * The WHERE tail every picker query shares. Needs the alias {@code ug} and the parameters {@code :userId}, + * {@code :excludeAppids}, {@code :maxMinutes}, {@code :mood} and {@code :speedrun}. + */ + String PICKER_FILTERS = "ug.user_id = :userId " + + "and ug.status = 'BACKLOG' and ug.appid not in (:excludeAppids) " + + "and (cast(:maxMinutes as integer) is null or exists (select 1 from game_time_to_beat t " + + " where t.appid = ug.appid and t.normally_minutes <= :maxMinutes)) " + + "and (cast(:mood as varchar) is null or exists (select 1 from game_mood m " + + " where m.appid = ug.appid and cast(:mood as varchar) = any(m.moods))) " + + "and (:speedrun = false or exists (select 1 from game_speedrun s " + + " where s.appid = ug.appid and s.weblink is not null)) "; + + String PRIORITY_ORDER = "case ug.priority when 'HIGH' then 0 when 'MEDIUM' then 1 else 2 end"; + /** * Random backlog games, HIGH priority first, then MEDIUM, then the rest. * @@ -128,24 +143,11 @@ public interface UserGameRepository extends JpaRepository * @param excludeAppids never picked; may be empty */ default List findPickerGames(Long userId, Integer maxMinutes, Mood mood, Collection excludeAppids, int count) { - List excluded = new ArrayList<>(excludeAppids); - // "not in ()" is invalid SQL - excluded.add(NO_GAME); - boolean speedrun = mood == Mood.SPEEDRUN; - String storedMood = mood == null || speedrun ? null : mood.name(); - return queryPickerGames(userId, maxMinutes, storedMood, speedrun, excluded, count); + return queryPickerGames(userId, maxMinutes, storedMood(mood), mood == Mood.SPEEDRUN, excludedOrNone(excludeAppids), count); } - @Query(value = "select ug.* from counted_user_game ug where ug.user_id = :userId " + - "and ug.status = 'BACKLOG' and ug.appid not in (:excludeAppids) " + - "and (cast(:maxMinutes as integer) is null or exists (select 1 from game_time_to_beat t " + - " where t.appid = ug.appid and t.normally_minutes <= :maxMinutes)) " + - "and (cast(:mood as varchar) is null or exists (select 1 from game_mood m " + - " where m.appid = ug.appid and cast(:mood as varchar) = any(m.moods))) " + - "and (:speedrun = false or exists (select 1 from game_speedrun s " + - " where s.appid = ug.appid and s.weblink is not null)) " + - "order by case ug.priority when 'HIGH' then 0 when 'MEDIUM' then 1 else 2 end, random() " + - "limit :count", nativeQuery = true) + @Query(value = "select ug.* from counted_user_game ug where " + PICKER_FILTERS + + "order by " + PRIORITY_ORDER + ", random() limit :count", nativeQuery = true) List queryPickerGames(@Param("userId") Long userId, @Param("maxMinutes") Integer maxMinutes, @Param("mood") String mood, @@ -153,6 +155,72 @@ public interface UserGameRepository extends JpaRepository @Param("excludeAppids") Collection excludeAppids, @Param("count") int count); + /** + * Backlog games that wait the longest, oldest first seen first, then HIGH priority first. + * + * @param maxMinutes only games whose normal time to beat is known and at most this; null for every game + * @param mood only games known to have this mood; null for every game + * @param excludeAppids never picked; may be empty + */ + default List findLongestWaitingGames(Long userId, Integer maxMinutes, Mood mood, Collection excludeAppids, int count) { + return queryLongestWaitingGames(userId, maxMinutes, storedMood(mood), mood == Mood.SPEEDRUN, excludedOrNone(excludeAppids), count); + } + + @Query(value = "select ug.* from counted_user_game ug where " + PICKER_FILTERS + + "order by ug.first_seen_at, " + PRIORITY_ORDER + ", random() limit :count", nativeQuery = true) + List queryLongestWaitingGames(@Param("userId") Long userId, + @Param("maxMinutes") Integer maxMinutes, + @Param("mood") String mood, + @Param("speedrun") boolean speedrun, + @Param("excludeAppids") Collection excludeAppids, + @Param("count") int count); + + /** + * Backlog games that share a mood with the anchor game, the most shared moods first, then HIGH priority first. + * Empty when the anchor has no known moods. + * + * @param anchorAppid never returned + * @param maxMinutes only games whose normal time to beat is known and at most this; null for every game + * @param mood only games known to have this mood; null for every game + * @param excludeAppids never picked; may be empty + */ + default List findSimilarGames(Long userId, long anchorAppid, Integer maxMinutes, Mood mood, Collection excludeAppids, int count) { + return querySimilarGames(userId, anchorAppid, maxMinutes, storedMood(mood), mood == Mood.SPEEDRUN, excludedOrNone(excludeAppids), count); + } + + @Query(value = "select ug.* from counted_user_game ug " + + "join game_mood gm on gm.appid = ug.appid " + + "join game_mood am on am.appid = :anchorAppid " + + "where " + PICKER_FILTERS + + "and ug.appid <> :anchorAppid and gm.moods && am.moods " + + "order by cardinality(array(select unnest(gm.moods) intersect select unnest(am.moods))) desc, " + + PRIORITY_ORDER + ", random() limit :count", nativeQuery = true) + List querySimilarGames(@Param("userId") Long userId, + @Param("anchorAppid") long anchorAppid, + @Param("maxMinutes") Integer maxMinutes, + @Param("mood") String mood, + @Param("speedrun") boolean speedrun, + @Param("excludeAppids") Collection excludeAppids, + @Param("count") int count); + + /** + * The games a user played most, any status, never the ones with no playtime. + */ + @Query(value = "select ug.* from counted_user_game ug where ug.user_id = :userId and ug.playtime_minutes > 0 " + + "order by ug.playtime_minutes desc, ug.appid limit :limit", nativeQuery = true) + List findMostPlayedGames(@Param("userId") Long userId, @Param("limit") int limit); + + private static List excludedOrNone(Collection excludeAppids) { + List excluded = new ArrayList<>(excludeAppids); + // "not in ()" is invalid SQL + excluded.add(NO_GAME); + return excluded; + } + + private static String storedMood(Mood mood) { + return mood == null || mood == Mood.SPEEDRUN ? null : mood.name(); + } + /** * @param hidden true for only the hidden games, false for only the listed ones * @param status null for every status 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 a05ea3f..50bd428 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -4,6 +4,8 @@ import core_service.modules.library.api.dto.AchievementSummaryDto; import core_service.modules.library.api.dto.ActivityDto; 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.PickerDto; import core_service.modules.library.api.dto.PublicLibraryDto; import core_service.modules.library.api.dto.RecentGameDto; @@ -15,6 +17,8 @@ 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; import core_service.modules.library.domain.SteamGame; @@ -45,6 +49,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import java.time.ZoneId; +import java.util.ArrayList; import java.util.Collection; import java.util.Comparator; import java.util.List; @@ -52,6 +57,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.concurrent.ThreadLocalRandom; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -65,6 +71,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 final UserGameRepository userGameRepository; private final UserSteamSyncRepository userSteamSyncRepository; @@ -97,6 +104,8 @@ public class LibraryQueryService { } /** + * Mixes three sources, one per slot in turn: a backlog game like a game the user played a lot, the game waiting + * longest, and a random one (HIGH priority first). 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. */ public PickerDto pickBacklogGames(Long userId, Integer maxMinutes, Mood mood, List excludeAppids, int count) { @@ -105,7 +114,42 @@ public class LibraryQueryService { } else if (mood != null) { moodService.refreshBacklogInBackground(userId); } - return new PickerDto(toDtos(userGameRepository.findPickerGames(userId, maxMinutes, mood, excludeAppids, count))); + 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()); + return new PickerDto(withReasons(PickMix.mix(similar, longest, random, count), anchor)); + } + + 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(); + }; + } + + /** + * A random one of the user's most played games. + */ + 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()))); } /** 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 1bdc43a..d6a58ca 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java @@ -20,6 +20,7 @@ import org.springframework.test.web.servlet.MockMvc; import java.time.Instant; +import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.hasSize; import static org.mockito.Mockito.verify; @@ -35,6 +36,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest { private static final long USER_ID = 1L; + // One shared first import, so the longest-waiting slot does not depend on the insert order. + private static final Instant SEEN = Instant.parse("2024-03-12T10:00:00Z"); @Autowired private MockMvc mockMvc; @@ -63,8 +66,13 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest { } private void game(long appid, GameStatus status, GamePriority priority, Integer normallyMinutes) { + game(appid, status, priority, normallyMinutes, 0, SEEN); + } + + private void game(long appid, GameStatus status, GamePriority priority, Integer normallyMinutes, int playtime, + Instant firstSeenAt) { steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now())); - UserGame game = new UserGame(USER_ID, appid, 0, Instant.now(), Instant.now()); + UserGame game = new UserGame(USER_ID, appid, playtime, firstSeenAt, Instant.now()); game.setStatus(status); game.setPriority(priority); userGameRepository.save(game); @@ -190,6 +198,49 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest { .andExpect(jsonPath("$.games[0].appid").value(30)); } + @Test + void picker_ThreeSlots_OneGamePerSourceWithReason() 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); + gameMoodsRepository.upsert(1L, new String[]{"STORY"}, new String[0], false, Instant.now()); + gameMoodsRepository.upsert(30L, 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)); + } + + @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); + + mockMvc.perform(get("/api/library/picker")) + .andExpect(jsonPath("$.games", hasSize(3))) + .andExpect(jsonPath("$.games[*].reason.source", containsInAnyOrder("LONGEST", "RANDOM", "RANDOM"))); + } + + @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++) { diff --git a/src/test/java/core_service/modules/library/domain/PickMixTest.java b/src/test/java/core_service/modules/library/domain/PickMixTest.java new file mode 100644 index 0000000..a810164 --- /dev/null +++ b/src/test/java/core_service/modules/library/domain/PickMixTest.java @@ -0,0 +1,79 @@ +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.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), 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, 4, 7), games(2, 5), games(3, 6), 7); + + assertEquals(List.of(SIMILAR, LONGEST, RANDOM, SIMILAR, LONGEST, RANDOM, SIMILAR), sources(picks)); + assertEquals(List.of(1L, 2L, 3L, 4L, 5L, 6L, 7L), appids(picks)); + } + + @Test + void mix_SameGameFirstInAllLists_AppearsOnce() { + List picks = PickMix.mix(games(1, 4), games(1, 5), games(1, 6), 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), 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), 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(), 3).isEmpty()); + } +} diff --git a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java index cd06236..e1bce8e 100644 --- a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java @@ -4,6 +4,7 @@ import core_service.modules.library.domain.GamePriority; import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.GameTimeToBeat; import core_service.modules.library.domain.GameStatus; +import core_service.modules.library.domain.Mood; import core_service.modules.library.domain.StatusCount; import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.UserGame; @@ -47,6 +48,9 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { @Autowired private GameTimeToBeatRepository gameTimeToBeatRepository; + @Autowired + private GameMoodsRepository gameMoodsRepository; + @Autowired private PlatformTransactionManager transactionManager; @@ -464,4 +468,83 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { steamGameRepository.deleteAll(); } } + + private void addGame(long appid, GameStatus status, GamePriority priority, Instant firstSeenAt, int playtime, String... moods) { + steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now())); + UserGame game = new UserGame(USER_ID, appid, playtime, firstSeenAt, Instant.now()); + game.setStatus(status); + game.setPriority(priority); + userGameRepository.save(game); + if (moods.length > 0) { + gameMoodsRepository.writeMoods(appid, moods, Instant.now()); + } + userGameRepository.flush(); + } + + private static List ids(List games) { + return games.stream().map(UserGame::getAppid).toList(); + } + + @Test + void findSimilarGames_OrdersBySharedMoodsAndNeverReturnsTheAnchor() { + hide(380L, 700L); + Instant now = Instant.now(); + addGame(1001L, GameStatus.BACKLOG, null, now, 0, "STORY", "COOP"); + addGame(1002L, GameStatus.BACKLOG, null, now, 0, "STORY"); + addGame(1003L, GameStatus.BACKLOG, null, now, 0, "STORY", "COOP", "CHILL"); + addGame(1004L, GameStatus.BACKLOG, null, now, 0, "SCARY"); + addGame(1005L, GameStatus.BACKLOG, null, now, 0); + + List similar = userGameRepository.findSimilarGames(USER_ID, 1001L, null, null, List.of(), 10); + + assertEquals(List.of(1003L, 1002L), ids(similar)); + } + + @Test + void findSimilarGames_AnchorWithoutMoods_ReturnsNothing() { + hide(380L, 700L); + addGame(1001L, GameStatus.PLAYING, null, Instant.now(), 100); + addGame(1002L, GameStatus.BACKLOG, null, Instant.now(), 0, "STORY"); + + assertTrue(userGameRepository.findSimilarGames(USER_ID, 1001L, null, null, List.of(), 10).isEmpty()); + } + + @Test + void findLongestWaitingGames_OldestFirstThenPriority() { + hide(380L, 700L); + Instant old = Instant.parse("2020-01-01T00:00:00Z"); + Instant newer = Instant.parse("2024-01-01T00:00:00Z"); + addGame(1001L, GameStatus.BACKLOG, GamePriority.LOW, newer, 0); + addGame(1002L, GameStatus.BACKLOG, null, old, 0); + addGame(1003L, GameStatus.BACKLOG, GamePriority.HIGH, old, 0); + + assertEquals(List.of(1003L, 1002L, 1001L), + ids(userGameRepository.findLongestWaitingGames(USER_ID, null, null, List.of(), 10))); + assertEquals(List.of(1003L), + ids(userGameRepository.findLongestWaitingGames(USER_ID, null, null, List.of(), 1))); + } + + @Test + void findMostPlayedGames_TopByPlaytimeAnyStatusWithoutZeroPlaytime() { + addGame(1001L, GameStatus.FINISHED, null, Instant.now(), 5000); + addGame(1002L, GameStatus.PLAYING, null, Instant.now(), 100); + addGame(1003L, GameStatus.BACKLOG, null, Instant.now(), 9000); + hide(1003L); + + assertEquals(List.of(1001L, 620L), ids(userGameRepository.findMostPlayedGames(USER_ID, 2))); + assertEquals(List.of(1001L, 620L, 220L, 1002L), ids(userGameRepository.findMostPlayedGames(USER_ID, 10))); + } + + @Test + void pickerSources_ApplyTheMoodFilterAndExcludes() { + hide(380L, 700L); + Instant now = Instant.now(); + addGame(1001L, GameStatus.PLAYING, null, now, 100, "STORY", "COOP"); + addGame(1002L, GameStatus.BACKLOG, null, now, 0, "STORY", "COOP"); + addGame(1003L, GameStatus.BACKLOG, null, now, 0, "STORY", "COOP"); + addGame(1004L, GameStatus.BACKLOG, null, now, 0, "STORY"); + + assertEquals(List.of(1003L), ids(userGameRepository.findSimilarGames(USER_ID, 1001L, null, Mood.COOP, List.of(1002L), 10))); + assertEquals(List.of(1003L), ids(userGameRepository.findLongestWaitingGames(USER_ID, null, Mood.COOP, List.of(1002L), 10))); + } }