From 86db6f716711bbbb53a5154bd02c190f8147ecaa Mon Sep 17 00:00:00 2001 From: lobo Date: Fri, 25 Sep 2026 09:29:15 +0200 Subject: [PATCH] feat(library): picker for what to play next and a status-only update GET /api/library/picker returns random backlog games for a time budget, HIGH priority first. PUT /games/{appid}/status changes only the status, so the picker does not need the game detail (and its Steam calls) to keep rating, notes and priority. --- readme.md | 6 +- .../library/api/LibraryController.java | 28 +++ .../modules/library/api/dto/PickerDto.java | 5 + .../library/api/dto/StatusUpdateDto.java | 6 + .../library/repo/UserGameRepository.java | 25 +++ .../library/service/BacklogService.java | 16 ++ .../library/service/LibraryQueryService.java | 15 +- .../api/LibraryControllerBacklogTest.java | 50 ++++++ .../api/LibraryControllerPickerTest.java | 166 ++++++++++++++++++ 9 files changed, 314 insertions(+), 3 deletions(-) create mode 100644 src/main/java/core_service/modules/library/api/dto/PickerDto.java create mode 100644 src/main/java/core_service/modules/library/api/dto/StatusUpdateDto.java create mode 100644 src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java diff --git a/readme.md b/readme.md index e4c2430..3ab6110 100644 --- a/readme.md +++ b/readme.md @@ -17,6 +17,8 @@ To Be Played is split into three repositories: sync. Resilience4j retries and rate-limits every Steam call; Caffeine caches game counts and player summaries. - **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), HIGH priority first, then + MEDIUM, then the rest. Games already shown can be excluded for a reroll. - **Playtime history**: one row per game and day, for the activity view and the "most played in the last two weeks" dashboard banner. - **Wishlist**: synced from Steam with store price and discount. @@ -38,7 +40,9 @@ on normal endpoints; the profile ticket is accepted only by `GET /api/public/pro |---|---|---| | `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT | | `PUT` | `/api/library/games/{appid}/backlog` | JWT | +| `PUT` | `/api/library/games/{appid}/status` (status only, keeps rating, notes, priority) | JWT | | `POST` | `/api/library/sync` | JWT | +| `GET` | `/api/library/picker?maxMinutes=&count=&exclude=` | JWT | | `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` | JWT | | `GET` | `/api/public/profiles/library` | profile ticket | | `GET` | `/api/stats/public` | public | @@ -102,7 +106,7 @@ Runs unit, WebMvc, persistence and integration tests plus the ArchUnit rules. Ne ## Roadmap -- "What to play next" picker: time budget and mood on top of the current random suggestion. +- Mood filter for the picker (needs genres or themes from IGDB). - Achievement summary per game and library-wide, "closest to 100 %". - Sale alerts for wishlist games. - Move `steam_adapter` DTOs out of `api`, then turn on strict module independence in ArchUnit. diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java index 1e484a6..ff88789 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -5,6 +5,8 @@ import core_service.modules.library.api.dto.BacklogEntryDto; import core_service.modules.library.api.dto.GameDto; import core_service.modules.library.api.dto.LibrarySummaryDto; import core_service.modules.library.api.dto.PageDto; +import core_service.modules.library.api.dto.PickerDto; +import core_service.modules.library.api.dto.StatusUpdateDto; import core_service.modules.library.api.dto.SteamProfileDto; import core_service.modules.library.api.dto.SyncStatusDto; import core_service.modules.library.api.dto.WishlistDto; @@ -33,7 +35,9 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; @RestController @@ -45,6 +49,8 @@ public class LibraryController { private static final int DEFAULT_SIZE = 20; private static final int MIN_ACTIVITY_DAYS = 7; private static final int MAX_ACTIVITY_DAYS = 90; + private static final int MAX_PICKER_GAMES = 10; + private static final int MAX_PICKER_EXCLUDES = 100; private static final Set SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority"); private final LibrarySyncService syncService; @@ -142,6 +148,16 @@ public class LibraryController { .body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library"))); } + @PutMapping("/games/{appid}/status") + public ResponseEntity updateStatus(@AuthenticationPrincipal UserPrincipal principal, + @PathVariable("appid") Long appid, + @Valid @RequestBody StatusUpdateDto update) { + return backlogService.updateStatus(principal.getIdAsLong(), appid, update.status()) + .>map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library"))); + } + @GetMapping("/activity") public ResponseEntity getActivity(@AuthenticationPrincipal UserPrincipal principal, @RequestParam(defaultValue = "30") int days) { @@ -160,6 +176,18 @@ public class LibraryController { return ResponseEntity.ok(queryService.getSummary(principal.getIdAsLong(), exclude)); } + @GetMapping("/picker") + public ResponseEntity getPicker(@AuthenticationPrincipal UserPrincipal principal, + @RequestParam(required = false) Integer maxMinutes, + @RequestParam(required = false) List exclude, + @RequestParam(defaultValue = "3") int count) { + List excluded = exclude == null ? List.of() + : exclude.stream().filter(Objects::nonNull).limit(MAX_PICKER_EXCLUDES).toList(); + int safeCount = Math.clamp(count, 1, MAX_PICKER_GAMES); + Integer budget = maxMinutes != null && maxMinutes > 0 ? maxMinutes : null; + return ResponseEntity.ok(queryService.pickBacklogGames(principal.getIdAsLong(), budget, excluded, safeCount)); + } + @GetMapping("/sync-status") public ResponseEntity getSyncStatus(@AuthenticationPrincipal UserPrincipal principal) { return queryService.getSyncStatus(principal.getIdAsLong()) 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 new file mode 100644 index 0000000..8dd865d --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/PickerDto.java @@ -0,0 +1,5 @@ +package core_service.modules.library.api.dto; + +import java.util.List; + +public record PickerDto(List games) {} diff --git a/src/main/java/core_service/modules/library/api/dto/StatusUpdateDto.java b/src/main/java/core_service/modules/library/api/dto/StatusUpdateDto.java new file mode 100644 index 0000000..f0b910d --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/StatusUpdateDto.java @@ -0,0 +1,6 @@ +package core_service.modules.library.api.dto; + +import core_service.modules.library.domain.GameStatus; +import jakarta.validation.constraints.NotNull; + +public record StatusUpdateDto(@NotNull GameStatus status) {} 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 45bd2de..f7369a9 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java @@ -14,6 +14,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.time.Instant; +import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Locale; @@ -46,6 +47,30 @@ public interface UserGameRepository extends JpaRepository "and appid <> :excludeAppid order by random() limit 1", nativeQuery = true) Optional findRandomBacklogGame(@Param("userId") Long userId, @Param("excludeAppid") long excludeAppid); + /** + * Random backlog games, HIGH priority first, then MEDIUM, then the rest. + * + * @param maxMinutes only games whose normal time to beat is known and at most this; null for every game + * @param excludeAppids never picked; may be empty + */ + default List findPickerGames(Long userId, Integer maxMinutes, Collection excludeAppids, int count) { + List excluded = new ArrayList<>(excludeAppids); + // "not in ()" is invalid SQL, and no appid is -1 + excluded.add(-1L); + return queryPickerGames(userId, maxMinutes, excluded, count); + } + + @Query(value = "select ug.* from user_game ug where ug.user_id = :userId and ug.removed_at is null " + + "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)) " + + "order by case ug.priority when 'HIGH' then 0 when 'MEDIUM' then 1 else 2 end, random() " + + "limit :count", nativeQuery = true) + List queryPickerGames(@Param("userId") Long userId, + @Param("maxMinutes") Integer maxMinutes, + @Param("excludeAppids") Collection excludeAppids, + @Param("count") int count); + /** * @param status null for every status * @param namePattern a {@link #containsPattern} result, null for every name diff --git a/src/main/java/core_service/modules/library/service/BacklogService.java b/src/main/java/core_service/modules/library/service/BacklogService.java index 4db115b..3522430 100644 --- a/src/main/java/core_service/modules/library/service/BacklogService.java +++ b/src/main/java/core_service/modules/library/service/BacklogService.java @@ -1,6 +1,7 @@ package core_service.modules.library.service; import core_service.modules.library.api.dto.BacklogEntryDto; +import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGameId; import core_service.modules.library.repo.UserGameRepository; @@ -32,6 +33,21 @@ public class BacklogService { }); } + /** + * Changes only the status and keeps rating, notes and priority. + * + * @return empty when the game is not in the user's active library + */ + @Transactional + public Optional updateStatus(Long userId, Long appid, GameStatus status) { + return userGameRepository.findById(new UserGameId(userId, appid)) + .filter(game -> game.getRemovedAt() == null) + .map(game -> { + game.setStatus(status); + return toDto(game); + }); + } + private static BacklogEntryDto toDto(UserGame game) { return new BacklogEntryDto(game.getStatus(), game.getRating(), game.getNotes(), game.getPriority()); } 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 097e983..dc83040 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -3,6 +3,7 @@ package core_service.modules.library.service; 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.PickerDto; import core_service.modules.library.api.dto.PublicLibraryDto; import core_service.modules.library.api.dto.RecentGameDto; import core_service.modules.library.api.dto.WishlistDto; @@ -21,6 +22,7 @@ import core_service.modules.library.repo.WishlistGameRepository; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Pageable; @@ -64,12 +66,21 @@ public class LibraryQueryService { UserGameRepository.querySort(pageable.getSort())); Page page = userGameRepository.findOwnedGames( userId, status, UserGameRepository.containsPattern(nameQuery), maxMinutes, stable); - List appids = page.getContent().stream().map(UserGame::getAppid).toList(); + List games = toDtos(page.getContent()); + return new PageImpl<>(games, page.getPageable(), page.getTotalElements()); + } + + public PickerDto pickBacklogGames(Long userId, Integer maxMinutes, List excludeAppids, int count) { + return new PickerDto(toDtos(userGameRepository.findPickerGames(userId, maxMinutes, excludeAppids, count))); + } + + private List toDtos(List games) { + List appids = games.stream().map(UserGame::getAppid).toList(); Map meta = steamGameRepository.findAllById(appids).stream() .collect(Collectors.toMap(SteamGame::getAppid, g -> g)); Map times = gameTimeToBeatRepository.findAllById(appids).stream() .collect(Collectors.toMap(GameTimeToBeat::getAppid, t -> t)); - return page.map(ug -> toDto(ug, meta.get(ug.getAppid()), times.get(ug.getAppid()))); + return games.stream().map(ug -> toDto(ug, meta.get(ug.getAppid()), times.get(ug.getAppid()))).toList(); } /** diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerBacklogTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerBacklogTest.java index 62a74ea..1ec171c 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerBacklogTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerBacklogTest.java @@ -1,5 +1,6 @@ package core_service.modules.library.api; +import core_service.modules.library.domain.GamePriority; import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.UserGame; @@ -135,4 +136,53 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest { .content("{\"status\":\"PLAYING\"}")) .andExpect(status().isNotFound()); } + + @Test + void updateStatus_KeepsRatingNotesAndPriority() throws Exception { + UserGame game = stored(); + game.setRating(4); + game.setNotes("co-op with Tom"); + game.setPriority(GamePriority.HIGH); + userGameRepository.save(game); + + mockMvc.perform(put("/api/library/games/220/status").contentType(MediaType.APPLICATION_JSON) + .content("{\"status\":\"PLAYING\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("PLAYING")) + .andExpect(jsonPath("$.notes").value("co-op with Tom")); + + UserGame updated = stored(); + assertEquals(GameStatus.PLAYING, updated.getStatus()); + assertEquals(4, updated.getRating()); + assertEquals("co-op with Tom", updated.getNotes()); + assertEquals(GamePriority.HIGH, updated.getPriority()); + } + + @Test + void updateStatus_MissingStatus_Is400() throws Exception { + mockMvc.perform(put("/api/library/games/220/status").contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()); + + assertEquals(GameStatus.BACKLOG, stored().getStatus()); + } + + @Test + void updateStatus_RemovedGame_Is404() throws Exception { + UserGame game = stored(); + game.setRemovedAt(Instant.now()); + userGameRepository.save(game); + + mockMvc.perform(put("/api/library/games/220/status").contentType(MediaType.APPLICATION_JSON) + .content("{\"status\":\"PLAYING\"}")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("GAME_NOT_FOUND")); + } + + @Test + void updateStatus_GameNotOwned_Is404() throws Exception { + mockMvc.perform(put("/api/library/games/999/status").contentType(MediaType.APPLICATION_JSON) + .content("{\"status\":\"PLAYING\"}")) + .andExpect(status().isNotFound()); + } } diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java new file mode 100644 index 0000000..b54f784 --- /dev/null +++ b/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java @@ -0,0 +1,166 @@ +package core_service.modules.library.api; + +import core_service.modules.library.domain.GamePriority; +import core_service.modules.library.domain.GameStatus; +import core_service.modules.library.domain.GameTimeToBeat; +import core_service.modules.library.domain.SteamGame; +import core_service.modules.library.domain.UserGame; +import core_service.modules.library.domain.UserGameId; +import core_service.modules.library.repo.GameTimeToBeatRepository; +import core_service.modules.library.repo.SteamGameRepository; +import core_service.modules.library.repo.UserGameRepository; +import core_service.shared.test.AbstractIntegrationTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Instant; +import java.util.List; + +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * The dev profile authenticates every request as user 1. + */ +@AutoConfigureMockMvc +class LibraryControllerPickerTest extends AbstractIntegrationTest { + + private static final long USER_ID = 1L; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private SteamGameRepository steamGameRepository; + + @Autowired + private UserGameRepository userGameRepository; + + @Autowired + private GameTimeToBeatRepository gameTimeToBeatRepository; + + @BeforeEach + void clean() { + userGameRepository.deleteAll(); + gameTimeToBeatRepository.deleteAll(); + } + + private void game(long appid, GameStatus status, GamePriority priority, Integer normallyMinutes) { + steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now())); + UserGame game = new UserGame(USER_ID, appid, 0, Instant.now(), Instant.now()); + game.setStatus(status); + game.setPriority(priority); + userGameRepository.save(game); + if (normallyMinutes != null) { + gameTimeToBeatRepository.save(new GameTimeToBeat(appid, null, normallyMinutes, null, Instant.now())); + } + } + + @Test + void picker_EmptyBacklog_ReturnsNoGames() throws Exception { + game(10L, GameStatus.PLAYING, null, 60); + + mockMvc.perform(get("/api/library/picker")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.games", hasSize(0))); + } + + @Test + void picker_WithoutBudget_PicksOnlyBacklogGamesIncludingUnknownLength() throws Exception { + game(10L, GameStatus.BACKLOG, null, 60); + game(20L, GameStatus.BACKLOG, null, null); + game(30L, GameStatus.FINISHED, null, 60); + game(40L, GameStatus.DROPPED, null, 60); + + mockMvc.perform(get("/api/library/picker")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10, 20))) + .andExpect(jsonPath("$.games[?(@.appid == 10)].timeToBeatMinutes").value(60)); + } + + @Test + void picker_WithBudget_SkipsLongerAndUnknownGames() throws Exception { + game(10L, GameStatus.BACKLOG, null, 300); + game(20L, GameStatus.BACKLOG, null, 301); + game(30L, GameStatus.BACKLOG, null, null); + + mockMvc.perform(get("/api/library/picker").param("maxMinutes", "300")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10))); + } + + @Test + void picker_ZeroBudgetMeansAnyLength() throws Exception { + game(10L, GameStatus.BACKLOG, null, 300); + game(20L, GameStatus.BACKLOG, null, null); + + mockMvc.perform(get("/api/library/picker").param("maxMinutes", "0")) + .andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10, 20))); + } + + @Test + void picker_SkipsExcludedGames() throws Exception { + game(10L, GameStatus.BACKLOG, null, 60); + game(20L, GameStatus.BACKLOG, null, 60); + game(30L, GameStatus.BACKLOG, null, 60); + + mockMvc.perform(get("/api/library/picker").param("exclude", "10", "30")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(20))); + } + + @Test + void picker_FillsSlotsByPriorityFirst() throws Exception { + game(10L, GameStatus.BACKLOG, null, 60); + game(20L, GameStatus.BACKLOG, GamePriority.LOW, 60); + game(30L, GameStatus.BACKLOG, GamePriority.HIGH, 60); + game(40L, GameStatus.BACKLOG, GamePriority.MEDIUM, 60); + game(50L, GameStatus.BACKLOG, null, 60); + + mockMvc.perform(get("/api/library/picker").param("count", "2")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(30, 40))) + .andExpect(jsonPath("$.games[0].appid").value(30)); + } + + @Test + void picker_DefaultsToThreeAndCapsTheCount() throws Exception { + for (long appid = 1; appid <= 12; appid++) { + game(appid, GameStatus.BACKLOG, null, 60); + } + + mockMvc.perform(get("/api/library/picker")) + .andExpect(jsonPath("$.games", hasSize(3))); + mockMvc.perform(get("/api/library/picker").param("count", "500")) + .andExpect(jsonPath("$.games", hasSize(10))); + mockMvc.perform(get("/api/library/picker").param("count", "0")) + .andExpect(jsonPath("$.games", hasSize(1))); + } + + @Test + void picker_SkipsRemovedGames() throws Exception { + game(10L, GameStatus.BACKLOG, null, 60); + game(20L, GameStatus.BACKLOG, null, 60); + UserGame removed = userGameRepository.findById(new UserGameId(USER_ID, 20L)).orElseThrow(); + removed.setRemovedAt(Instant.now()); + userGameRepository.save(removed); + + mockMvc.perform(get("/api/library/picker")) + .andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10))); + } + + @Test + void picker_OnlyShowsTheCallersGames() throws Exception { + steamGameRepository.save(new SteamGame(99L, "Other", null, Instant.now())); + userGameRepository.save(new UserGame(2L, 99L, 0, Instant.now(), Instant.now())); + + mockMvc.perform(get("/api/library/picker")) + .andExpect(jsonPath("$.games", hasSize(0))); + } +}