Public Access
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.
This commit is contained in:
1 parent
10b7dc2272
commit
33b230ee91
11 files changed
+448
-22
No files matched your search
@@ -21,9 +21,22 @@ To Be Played is split into three repositories:
|
|||||||
can be taken over; its own finish then changes nothing.
|
can be taken over; its own finish then changes nothing.
|
||||||
- **Backlog**: status (`BACKLOG`, `PLAYING`, `FINISHED`, `COMPLETED` = 100 %, `DROPPED`), rating 1–5, notes, priority,
|
- **Backlog**: status (`BACKLOG`, `PLAYING`, `FINISHED`, `COMPLETED` = 100 %, `DROPPED`), rating 1–5, notes, priority,
|
||||||
filters and sorting. "Pile of Shame" summary with a random suggestion of what to play.
|
filters and sorting. "Pile of Shame" summary with a random suggestion of what to play.
|
||||||
- **Picker**: up to 10 random backlog games for a time budget (IGDB normal time to beat) and a mood, HIGH priority
|
- **Picker**: up to 10 backlog games for a time budget (IGDB normal time to beat) and a mood, mixed from three
|
||||||
first, then MEDIUM, then the rest. Games already shown can be excluded for a reroll. Moods `STORY`, `COOP`,
|
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.
|
`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
|
- **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
|
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
|
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 |
|
| `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/sync` | JWT |
|
||||||
| `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | JWT |
|
| `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | JWT |
|
||||||
| `GET` | `/api/library/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?groups=` (`OVERVIEW`, `PILE`, `TOP_GAMES`, `TASTE`, `HABITS`, `FINISHED`, `TREND`, `PLATFORMS`; none = all) | JWT |
|
||||||
| `GET` | `/api/library/stats/games?key=&arg=&page=` (the games behind a stats number; `400 BAD_ARG`) | JWT |
|
| `GET` | `/api/library/stats/games?key=&arg=&page=` (the games behind a stats number; `400 BAD_ARG`) | JWT |
|
||||||
| `GET` | `/api/library/achievements/summary` | JWT |
|
| `GET` | `/api/library/achievements/summary` | JWT |
|
||||||
|
|||||||
@@ -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;
|
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")
|
"and ug.source = core_service.modules.library.domain.GameSource.STEAM")
|
||||||
List<AchievementProgress> findAchievementProgress(@Param("userId") Long userId);
|
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.
|
* 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
|
* @param excludeAppids never picked; may be empty
|
||||||
*/
|
*/
|
||||||
default List<UserGame> findPickerGames(Long userId, Integer maxMinutes, Mood mood, Collection<Long> excludeAppids, int count) {
|
default List<UserGame> findPickerGames(Long userId, Integer maxMinutes, Mood mood, Collection<Long> excludeAppids, int count) {
|
||||||
List<Long> excluded = new ArrayList<>(excludeAppids);
|
return queryPickerGames(userId, maxMinutes, storedMood(mood), mood == Mood.SPEEDRUN, excludedOrNone(excludeAppids), count);
|
||||||
// "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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Query(value = "select ug.* from counted_user_game ug where ug.user_id = :userId " +
|
@Query(value = "select ug.* from counted_user_game ug where " + PICKER_FILTERS +
|
||||||
"and ug.status = 'BACKLOG' and ug.appid not in (:excludeAppids) " +
|
"order by " + PRIORITY_ORDER + ", random() limit :count", nativeQuery = true)
|
||||||
"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)
|
|
||||||
List<UserGame> queryPickerGames(@Param("userId") Long userId,
|
List<UserGame> queryPickerGames(@Param("userId") Long userId,
|
||||||
@Param("maxMinutes") Integer maxMinutes,
|
@Param("maxMinutes") Integer maxMinutes,
|
||||||
@Param("mood") String mood,
|
@Param("mood") String mood,
|
||||||
@@ -153,6 +155,72 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
|||||||
@Param("excludeAppids") Collection<Long> excludeAppids,
|
@Param("excludeAppids") Collection<Long> excludeAppids,
|
||||||
@Param("count") int count);
|
@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 hidden true for only the hidden games, false for only the listed ones
|
||||||
* @param status null for every status
|
* @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.ActivityDto;
|
||||||
import core_service.modules.library.api.dto.GameDto;
|
import core_service.modules.library.api.dto.GameDto;
|
||||||
import core_service.modules.library.api.dto.LibrarySummaryDto;
|
import core_service.modules.library.api.dto.LibrarySummaryDto;
|
||||||
|
import core_service.modules.library.api.dto.PickReasonDto;
|
||||||
|
import core_service.modules.library.api.dto.PickedGameDto;
|
||||||
import core_service.modules.library.api.dto.PickerDto;
|
import core_service.modules.library.api.dto.PickerDto;
|
||||||
import core_service.modules.library.api.dto.PublicLibraryDto;
|
import core_service.modules.library.api.dto.PublicLibraryDto;
|
||||||
import core_service.modules.library.api.dto.RecentGameDto;
|
import core_service.modules.library.api.dto.RecentGameDto;
|
||||||
@@ -15,6 +17,8 @@ import core_service.modules.library.domain.GameSource;
|
|||||||
import core_service.modules.library.domain.GameStatus;
|
import core_service.modules.library.domain.GameStatus;
|
||||||
import core_service.modules.library.domain.GameTimeToBeat;
|
import core_service.modules.library.domain.GameTimeToBeat;
|
||||||
import core_service.modules.library.domain.Mood;
|
import core_service.modules.library.domain.Mood;
|
||||||
|
import core_service.modules.library.domain.PickMix;
|
||||||
|
import core_service.modules.library.domain.PickSource;
|
||||||
import core_service.modules.library.domain.ProfileSection;
|
import core_service.modules.library.domain.ProfileSection;
|
||||||
import core_service.modules.library.domain.StatGroup;
|
import core_service.modules.library.domain.StatGroup;
|
||||||
import core_service.modules.library.domain.SteamGame;
|
import core_service.modules.library.domain.SteamGame;
|
||||||
@@ -45,6 +49,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -52,6 +57,7 @@ import java.util.Map;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
@@ -65,6 +71,7 @@ public class LibraryQueryService {
|
|||||||
private static final int PUBLIC_FINISHED = 12;
|
private static final int PUBLIC_FINISHED = 12;
|
||||||
private static final int CLOSEST_GAMES = 5;
|
private static final int CLOSEST_GAMES = 5;
|
||||||
private static final int RECENT_GAMES = 5;
|
private static final int RECENT_GAMES = 5;
|
||||||
|
private static final int ANCHOR_CANDIDATES = 5;
|
||||||
|
|
||||||
private final UserGameRepository userGameRepository;
|
private final UserGameRepository userGameRepository;
|
||||||
private final UserSteamSyncRepository userSteamSyncRepository;
|
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.
|
* 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) {
|
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) {
|
} else if (mood != null) {
|
||||||
moodService.refreshBacklogInBackground(userId);
|
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 java.time.Instant;
|
||||||
|
|
||||||
|
import static org.hamcrest.Matchers.contains;
|
||||||
import static org.hamcrest.Matchers.containsInAnyOrder;
|
import static org.hamcrest.Matchers.containsInAnyOrder;
|
||||||
import static org.hamcrest.Matchers.hasSize;
|
import static org.hamcrest.Matchers.hasSize;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
@@ -35,6 +36,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
|
class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
|
||||||
|
|
||||||
private static final long USER_ID = 1L;
|
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
|
@Autowired
|
||||||
private MockMvc mockMvc;
|
private MockMvc mockMvc;
|
||||||
@@ -63,8 +66,13 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void game(long appid, GameStatus status, GamePriority priority, Integer normallyMinutes) {
|
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()));
|
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.setStatus(status);
|
||||||
game.setPriority(priority);
|
game.setPriority(priority);
|
||||||
userGameRepository.save(game);
|
userGameRepository.save(game);
|
||||||
@@ -190,6 +198,49 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
|
|||||||
.andExpect(jsonPath("$.games[0].appid").value(30));
|
.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
|
@Test
|
||||||
void picker_DefaultsToThreeAndCapsTheCount() throws Exception {
|
void picker_DefaultsToThreeAndCapsTheCount() throws Exception {
|
||||||
for (long appid = 1; appid <= 12; appid++) {
|
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.GameSource;
|
||||||
import core_service.modules.library.domain.GameTimeToBeat;
|
import core_service.modules.library.domain.GameTimeToBeat;
|
||||||
import core_service.modules.library.domain.GameStatus;
|
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.StatusCount;
|
||||||
import core_service.modules.library.domain.SteamGame;
|
import core_service.modules.library.domain.SteamGame;
|
||||||
import core_service.modules.library.domain.UserGame;
|
import core_service.modules.library.domain.UserGame;
|
||||||
@@ -47,6 +48,9 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private GameTimeToBeatRepository gameTimeToBeatRepository;
|
private GameTimeToBeatRepository gameTimeToBeatRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private GameMoodsRepository gameMoodsRepository;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private PlatformTransactionManager transactionManager;
|
private PlatformTransactionManager transactionManager;
|
||||||
|
|
||||||
@@ -464,4 +468,83 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
|||||||
steamGameRepository.deleteAll();
|
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)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user