Public Access
feat(library): weight the backlog suggestion by priority
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:
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();
|
||||
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")
|
||||
|
||||
@@ -54,10 +54,18 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
||||
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
|
||||
*/
|
||||
@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);
|
||||
|
||||
@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.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");
|
||||
}
|
||||
}
|
||||
@@ -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++) {
|
||||
|
||||
Reference in new issue
Block a user