feat(library): weight the backlog suggestion by priority
Build, Test & Push Core Service / quality-check (push) Successful in 51s
Build, Test & Push Core Service / docker (push) Successful in 1m19s

A HIGH game is 4 times as likely as a LOW or unset one, a
MEDIUM game twice. Not HIGH first like the picker: the
dashboard shuffles by excluding only the current game, so a
strict order would swap between two games. The picker now
starts a stale Steam sync like the library does.
This commit is contained in:
lobo committed 2026-09-26 07:05:57 +02:00
1 parent 1cac7de836
commit 5e7455db05
4 files changed
+46 -2

No files matched your search

@@ -184,7 +184,11 @@ public class LibraryController {
: exclude.stream().filter(Objects::nonNull).limit(MAX_PICKER_EXCLUDES).toList(); : exclude.stream().filter(Objects::nonNull).limit(MAX_PICKER_EXCLUDES).toList();
int safeCount = Math.clamp(count, 1, MAX_PICKER_GAMES); int safeCount = Math.clamp(count, 1, MAX_PICKER_GAMES);
Integer budget = maxMinutes != null && maxMinutes > 0 ? maxMinutes : null; 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") @GetMapping("/sync-status")
@@ -54,10 +54,18 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
List<StatusCount> countByStatus(@Param("userId") Long userId); List<StatusCount> 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 * @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' " + @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<UserGame> findRandomBacklogGame(@Param("userId") Long userId, @Param("excludeAppid") long excludeAppid); Optional<UserGame> findRandomBacklogGame(@Param("userId") Long userId, @Param("excludeAppid") long excludeAppid);
@Query("select new core_service.modules.library.domain.AchievementProgress(ug.appid, sm.achievementsTotal, " + @Query("select new core_service.modules.library.domain.AchievementProgress(ug.appid, sm.achievementsTotal, " +
@@ -9,11 +9,13 @@ import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.GameTimeToBeatRepository; import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.service.LibrarySyncService;
import core_service.shared.test.AbstractIntegrationTest; import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; 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 org.springframework.test.web.servlet.MockMvc;
import java.time.Instant; import java.time.Instant;
@@ -21,6 +23,7 @@ import java.util.List;
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.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 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.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -45,6 +48,9 @@ class LibraryControllerPickerTest extends AbstractIntegrationTest {
@Autowired @Autowired
private GameTimeToBeatRepository gameTimeToBeatRepository; private GameTimeToBeatRepository gameTimeToBeatRepository;
@MockitoBean
private LibrarySyncService syncService;
@BeforeEach @BeforeEach
void clean() { void clean() {
userGameRepository.deleteAll(); userGameRepository.deleteAll();
@@ -163,4 +169,12 @@ class LibraryControllerPickerTest extends AbstractIntegrationTest {
mockMvc.perform(get("/api/library/picker")) mockMvc.perform(get("/api/library/picker"))
.andExpect(jsonPath("$.games", hasSize(0))); .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");
}
} }
@@ -241,6 +241,24 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
assertTrue(userGameRepository.findRandomBacklogGame(99L, -1).isEmpty()); 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 @Test
void findRandomBacklogGame_SkipsTheExcludedGame() { void findRandomBacklogGame_SkipsTheExcludedGame() {
for (int i = 0; i < 5; i++) { for (int i = 0; i < 5; i++) {