feat(picker): mix similar, longest waiting and random picks
Build, Test & Push Core Service / quality-check (push) Successful in 2m37s
Build, Test & Push Core Service / docker (push) Successful in 1m45s

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.
This commit is contained in:
lobo committed 2026-10-05 16:13:19 +02:00
1 parent 10b7dc2272
commit 33b230ee91
11 files changed
+448 -22

No files matched your search

+16 -3
View File
@@ -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 |
@@ -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);
}
}
@@ -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) {}
@@ -2,4 +2,4 @@ package core_service.modules.library.api.dto;
import java.util.List;
public record PickerDto(List<GameDto> games) {}
public record PickerDto(List<PickedGameDto> games) {}
@@ -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<Pick> mix(List<UserGame> similar, List<UserGame> longest, List<UserGame> random, int count) {
List<List<UserGame>> bySource = List.of(similar, longest, random);
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);
}
}
@@ -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
}
@@ -120,6 +120,21 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
"and ug.source = core_service.modules.library.domain.GameSource.STEAM")
List<AchievementProgress> 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<UserGame, UserGameId>
* @param excludeAppids never picked; may be empty
*/
default List<UserGame> findPickerGames(Long userId, Integer maxMinutes, Mood mood, Collection<Long> excludeAppids, int count) {
List<Long> 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<UserGame> queryPickerGames(@Param("userId") Long userId,
@Param("maxMinutes") Integer maxMinutes,
@Param("mood") String mood,
@@ -153,6 +155,72 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
@Param("excludeAppids") Collection<Long> 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<UserGame> findLongestWaitingGames(Long userId, Integer maxMinutes, Mood mood, Collection<Long> 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<UserGame> queryLongestWaitingGames(@Param("userId") Long userId,
@Param("maxMinutes") Integer maxMinutes,
@Param("mood") String mood,
@Param("speedrun") boolean speedrun,
@Param("excludeAppids") Collection<Long> 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<UserGame> findSimilarGames(Long userId, long anchorAppid, Integer maxMinutes, Mood mood, Collection<Long> 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<UserGame> querySimilarGames(@Param("userId") Long userId,
@Param("anchorAppid") long anchorAppid,
@Param("maxMinutes") Integer maxMinutes,
@Param("mood") String mood,
@Param("speedrun") boolean speedrun,
@Param("excludeAppids") Collection<Long> excludeAppids,
@Param("count") int count);
/**
* The games a user played most, any status, never the ones with no playtime.
*/
@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<UserGame> findMostPlayedGames(@Param("userId") Long userId, @Param("limit") int limit);
private static List<Long> excludedOrNone(Collection<Long> excludeAppids) {
List<Long> 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
@@ -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<Long> 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<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());
return new PickerDto(withReasons(PickMix.mix(similar, longest, random, count), anchor));
}
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();
};
}
/**
* A random one of the user's most played games.
*/
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())));
}
/**
@@ -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++) {
@@ -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<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), 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, 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<Pick> 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<Pick> 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<Pick> 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());
}
}
@@ -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<Long> ids(List<UserGame> 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<UserGame> 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)));
}
}