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 846da6d..8c0cb0d 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -184,7 +184,11 @@ public class LibraryController { : 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)); + Long userId = principal.getIdAsLong(); + if (principal.steamId() != null) { + syncService.syncIfStale(userId, String.valueOf(principal.steamId())); + } + return ResponseEntity.ok(queryService.pickBacklogGames(userId, budget, excluded, safeCount)); } @GetMapping("/sync-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 519dd55..e287b52 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java @@ -54,10 +54,18 @@ public interface UserGameRepository extends JpaRepository List countByStatus(@Param("userId") Long userId); /** + * A random backlog game; a HIGH priority game is 4 times as likely as a LOW one or one without priority, a MEDIUM + * one twice. + * Weighted, not HIGH first like the picker: the dashboard shuffles by excluding only the current game, so a + * strict order would swap between two games. + * * @param excludeAppid never picked; {@link #NO_GAME} excludes nothing */ @Query(value = "select * from user_game where user_id = :userId and removed_at is null and status = 'BACKLOG' " + - "and appid <> :excludeAppid order by random() limit 1", nativeQuery = true) + "and appid <> :excludeAppid " + + // Weighted random pick: the smallest -ln(u) / weight wins. + "order by -ln(1 - random()) / case priority when 'HIGH' then 4 when 'MEDIUM' then 2 else 1 end " + + "limit 1", nativeQuery = true) Optional findRandomBacklogGame(@Param("userId") Long userId, @Param("excludeAppid") long excludeAppid); @Query("select new core_service.modules.library.domain.AchievementProgress(ug.appid, sm.achievementsTotal, " + 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 b54f784..d5eff67 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java @@ -9,11 +9,13 @@ 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.modules.library.service.LibrarySyncService; 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.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import java.time.Instant; @@ -21,6 +23,7 @@ import java.util.List; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.hasSize; +import static org.mockito.Mockito.verify; 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; @@ -45,6 +48,9 @@ class LibraryControllerPickerTest extends AbstractIntegrationTest { @Autowired private GameTimeToBeatRepository gameTimeToBeatRepository; + @MockitoBean + private LibrarySyncService syncService; + @BeforeEach void clean() { userGameRepository.deleteAll(); @@ -163,4 +169,12 @@ class LibraryControllerPickerTest extends AbstractIntegrationTest { mockMvc.perform(get("/api/library/picker")) .andExpect(jsonPath("$.games", hasSize(0))); } + + // The dev profile's user has a Steam ID. + @Test + void picker_StartsAStaleSyncLikeTheLibrary() throws Exception { + mockMvc.perform(get("/api/library/picker")).andExpect(status().isOk()); + + verify(syncService).syncIfStale(USER_ID, "76561198000000001"); + } } 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 d9016b5..088a1f5 100644 --- a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java @@ -241,6 +241,24 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { assertTrue(userGameRepository.findRandomBacklogGame(99L, -1).isEmpty()); } + @Test + void findRandomBacklogGame_PicksHighPriorityGamesMoreOftenButNotOnly() { + UserGame high = userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow(); + high.setPriority(GamePriority.HIGH); + userGameRepository.flush(); + + int highPicks = 0; + for (int i = 0; i < 200; i++) { + if (userGameRepository.findRandomBacklogGame(USER_ID, UserGameRepository.NO_GAME).orElseThrow().getAppid() == 700L) { + highPicks++; + } + } + + // HIGH weighs 4 against 1: about 160 of 200. Without weights about 100; 130 is far from both. + assertTrue(highPicks > 130, "HIGH picked " + highPicks + " of 200"); + assertTrue(highPicks < 200, "the other backlog game was never picked"); + } + @Test void findRandomBacklogGame_SkipsTheExcludedGame() { for (int i = 0; i < 5; i++) {