From 1cac7de8369bdd065426cae2d2919fba4e72c896 Mon Sep 17 00:00:00 2001 From: lobo Date: Sat, 26 Sep 2026 06:29:57 +0200 Subject: [PATCH] feat(igdb): rank game search by popularity and help with typos IGDB lists every e-Reader card as a game, and its own order puts obscure entries first. Core now drops e-Reader-only games, asks for 50 hits and sorts exact name first, then by rating count. When a search finds fewer than 3 games, Core searches again without doubled letters and with single words, and adds those hits by Levenshtein distance after IGDB's own hits. Also fixes a flaky test that read the time to beat before the background refresh wrote it. --- .../api/dto/response/IgdbResponses.java | 3 +- .../igdb_adapter/service/IgdbApiService.java | 30 +++++- .../modules/library/service/FuzzySearch.java | 78 ++++++++++++++++ .../library/service/ManualGameService.java | 37 +++++++- .../service/IgdbApiServiceTest.java | 29 +++++- .../library/api/ManualGameControllerTest.java | 1 + .../library/service/FuzzySearchTest.java | 43 +++++++++ .../service/ManualGameServiceTest.java | 93 +++++++++++++++++++ 8 files changed, 307 insertions(+), 7 deletions(-) create mode 100644 src/main/java/core_service/modules/library/service/FuzzySearch.java create mode 100644 src/test/java/core_service/modules/library/service/FuzzySearchTest.java diff --git a/src/main/java/core_service/modules/igdb_adapter/api/dto/response/IgdbResponses.java b/src/main/java/core_service/modules/igdb_adapter/api/dto/response/IgdbResponses.java index cebc111..0860334 100644 --- a/src/main/java/core_service/modules/igdb_adapter/api/dto/response/IgdbResponses.java +++ b/src/main/java/core_service/modules/igdb_adapter/api/dto/response/IgdbResponses.java @@ -40,7 +40,8 @@ public final class IgdbResponses { @JsonProperty("artworks") java.util.List artworks, @JsonProperty("screenshots") java.util.List screenshots, @JsonProperty("platforms") java.util.List platforms, - @JsonProperty("external_games") java.util.List externalGames + @JsonProperty("external_games") java.util.List externalGames, + @JsonProperty("total_rating_count") Integer totalRatingCount ) {} @JsonIgnoreProperties(ignoreUnknown = true) diff --git a/src/main/java/core_service/modules/igdb_adapter/service/IgdbApiService.java b/src/main/java/core_service/modules/igdb_adapter/service/IgdbApiService.java index 55822fc..be5f4ce 100644 --- a/src/main/java/core_service/modules/igdb_adapter/service/IgdbApiService.java +++ b/src/main/java/core_service/modules/igdb_adapter/service/IgdbApiService.java @@ -49,13 +49,17 @@ public class IgdbApiService { */ public record Game(long igdbId, String name, Integer year, String imageUrl, List platforms, Long steamAppid) {} + // IGDB's own order puts obscure entries first, so Core asks for more and sorts by popularity itself. + private static final int SEARCH_FETCH_LIMIT = 50; private static final int SEARCH_LIMIT = 20; + // IGDB lists every e-Reader card as a game of its own. + private static final String E_READER = "e-Reader / Card-e Reader"; // IGDB game types a user can own and play on their own: main game, standalone expansion, season, remake, // remaster, expanded game, port. Leaves out DLC, bundles, mods, episodes, packs and updates. private static final String PLAYABLE_GAME_TYPES = "(0,4,7,8,9,10,11)"; private static final String GAME_FIELDS = "fields name,first_release_date,cover.image_id,artworks.image_id," + "screenshots.image_id,platforms.name,platforms.abbreviation,external_games.external_game_source," - + "external_games.uid;"; + + "external_games.uid,total_rating_count;"; private static final String IMAGE_BASE = "https://images.igdb.com/igdb/image/upload/"; // IGDB answers at most 500 rows per query, and one Steam app can have more than one external_games row. @@ -120,9 +124,18 @@ public class IgdbApiService { return Optional.empty(); } try { - IgdbResponses.Game[] games = query("/games", "search \"" + searchText(query) + "\"; " + GAME_FIELDS - + " where version_parent = null & game_type = " + PLAYABLE_GAME_TYPES + "; limit " + SEARCH_LIMIT + ";", IgdbResponses.Game[].class); - return Optional.of(Arrays.stream(games).map(IgdbApiService::toGame).flatMap(Optional::stream).toList()); + String text = searchText(query); + IgdbResponses.Game[] games = query("/games", "search \"" + text + "\"; " + GAME_FIELDS + + " where version_parent = null & game_type = " + PLAYABLE_GAME_TYPES + "; limit " + SEARCH_FETCH_LIMIT + ";", + IgdbResponses.Game[].class); + return Optional.of(Arrays.stream(games) + .filter(game -> !onlyOnTheEReader(game)) + .sorted(Comparator.comparing((IgdbResponses.Game game) -> !text.equalsIgnoreCase(trimmedName(game))) + .thenComparing(game -> game.totalRatingCount() == null ? 0 : game.totalRatingCount(), Comparator.reverseOrder())) + .map(IgdbApiService::toGame) + .flatMap(Optional::stream) + .limit(SEARCH_LIMIT) + .toList()); } catch (Exception e) { log.error("Error searching IGDB games", e); return Optional.empty(); @@ -176,6 +189,15 @@ public class IgdbApiService { } } + private static boolean onlyOnTheEReader(IgdbResponses.Game game) { + return game.platforms() != null && !game.platforms().isEmpty() + && game.platforms().stream().allMatch(platform -> E_READER.equalsIgnoreCase(platform.name())); + } + + private static String trimmedName(IgdbResponses.Game game) { + return game.name() == null ? "" : game.name().trim(); + } + private static Optional toGame(IgdbResponses.Game game) { if (game.id() == null || game.name() == null || game.name().isBlank()) { return Optional.empty(); diff --git a/src/main/java/core_service/modules/library/service/FuzzySearch.java b/src/main/java/core_service/modules/library/service/FuzzySearch.java new file mode 100644 index 0000000..fc06039 --- /dev/null +++ b/src/main/java/core_service/modules/library/service/FuzzySearch.java @@ -0,0 +1,78 @@ +package core_service.modules.library.service; + +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Optional; + +/** + * Typo help for the IGDB game search, which only finds exact words. When a search finds too little, Core searches + * again with these variants and sorts their hits by how close the name is to what the user typed. + */ +final class FuzzySearch { + + private static final int MIN_WORD_LENGTH = 4; + private static final int MAX_WORDS = 2; + + private FuzzySearch() { + } + + /** + * "Superr Mario" is "Super Mario"; empty when no letter is doubled. + */ + static Optional withoutDoubledLetters(String query) { + String text = normalizeSpaces(query); + String without = text.replaceAll("(?i)(\\p{L})\\1+", "$1"); + return without.equals(text) ? Optional.empty() : Optional.of(without); + } + + /** + * The first two words of 4+ letters, to search each alone; none for a single word. + */ + static List words(String query) { + String[] words = normalizeSpaces(query).split(" "); + if (words.length < 2) { + return List.of(); + } + return Arrays.stream(words).filter(word -> word.length() >= MIN_WORD_LENGTH).limit(MAX_WORDS).toList(); + } + + /** + * Compares the query with as many words of the name as the query has: "superr mario" is 1 away from + * "Super Mario Bros. 3". + */ + static int distance(String query, String name) { + List queryWords = lowerWords(query); + List nameWords = lowerWords(name); + String start = String.join(" ", nameWords.subList(0, Math.min(queryWords.size(), nameWords.size()))); + return levenshtein(String.join(" ", queryWords), start); + } + + static int levenshtein(String a, String b) { + int[] previous = new int[b.length() + 1]; + int[] current = new int[b.length() + 1]; + for (int j = 0; j <= b.length(); j++) { + previous[j] = j; + } + for (int i = 1; i <= a.length(); i++) { + current[0] = i; + for (int j = 1; j <= b.length(); j++) { + int change = a.charAt(i - 1) == b.charAt(j - 1) ? 0 : 1; + current[j] = Math.min(Math.min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + change); + } + int[] swap = previous; + previous = current; + current = swap; + } + return previous[b.length()]; + } + + private static String normalizeSpaces(String text) { + return text.trim().replaceAll("\\s+", " "); + } + + private static List lowerWords(String text) { + String cleaned = text.toLowerCase(Locale.ROOT).replaceAll("[^\\p{L}\\p{N}]+", " ").trim(); + return cleaned.isEmpty() ? List.of() : List.of(cleaned.split(" ")); + } +} diff --git a/src/main/java/core_service/modules/library/service/ManualGameService.java b/src/main/java/core_service/modules/library/service/ManualGameService.java index d3e8a20..5893dfb 100644 --- a/src/main/java/core_service/modules/library/service/ManualGameService.java +++ b/src/main/java/core_service/modules/library/service/ManualGameService.java @@ -18,11 +18,14 @@ import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.support.TransactionTemplate; import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.Set; import java.util.function.Consumer; +import java.util.stream.Stream; /** * Games the user adds by hand from IGDB, e.g. owned on PlayStation or Switch. See GameSource for how they live next @@ -44,6 +47,9 @@ public class ManualGameService { public enum ChangeResult { DONE, NOT_FOUND, STEAM_GAME } + private static final int MIN_HITS = 3; + private static final int MAX_HITS = 20; + private final IgdbApiService igdbApiService; private final UserGameRepository userGameRepository; private final SteamGameRepository steamGameRepository; @@ -67,7 +73,7 @@ public class ManualGameService { if (!igdbApiService.isEnabled()) { return Optional.of(new GameSearchDto(false, List.of())); } - return igdbApiService.searchGames(query).map(games -> { + return igdbApiService.searchGames(query).map(found -> withTypoHelp(query, found)).map(games -> { Set owned = new HashSet<>(userGameRepository.findActiveAppids(userId, games.stream().flatMap(game -> possibleKeys(game).stream()).toList())); return new GameSearchDto(true, games.stream() @@ -77,6 +83,35 @@ public class ManualGameService { }); } + // Few hits often mean a typo. IGDB's own hits keep their order; the variant hits follow, closest name first. + // The variants go through the same cache, and one that fails adds nothing. + private List withTypoHelp(String query, List found) { + if (found.size() >= MIN_HITS) { + return found; + } + List variants = new ArrayList<>(); + if (found.isEmpty()) { + FuzzySearch.withoutDoubledLetters(query).ifPresent(variants::add); + } + variants.addAll(FuzzySearch.words(query)); + + Set seen = new HashSet<>(); + found.forEach(game -> seen.add(game.igdbId())); + List extra = new ArrayList<>(); + for (String variant : variants) { + if (found.size() + extra.size() >= MIN_HITS) { + break; + } + igdbApiService.searchGames(variant).orElse(List.of()).stream() + .filter(game -> seen.add(game.igdbId())) + .forEach(extra::add); + } + return Stream.concat(found.stream(), + extra.stream().sorted(Comparator.comparingInt(game -> FuzzySearch.distance(query, game.name())))) + .limit(MAX_HITS) + .toList(); + } + /** * Adds the game under its Steam appid when IGDB knows it on Steam: then the sync takes it over once the user owns * it there, and it keeps one row. A game the user removed before comes back with its old status and notes. diff --git a/src/test/java/core_service/modules/igdb_adapter/service/IgdbApiServiceTest.java b/src/test/java/core_service/modules/igdb_adapter/service/IgdbApiServiceTest.java index 1817d67..cf33a01 100644 --- a/src/test/java/core_service/modules/igdb_adapter/service/IgdbApiServiceTest.java +++ b/src/test/java/core_service/modules/igdb_adapter/service/IgdbApiServiceTest.java @@ -194,7 +194,8 @@ class IgdbApiServiceTest extends AbstractUnitTest { expectToken(); igdb.expect(requestTo(containsString("/games"))) .andExpect(content().string(allOf(containsString("search \"zelda\";"), containsString("version_parent = null"), - containsString("game_type = (0,4,7,8,9,10,11)"), containsString("limit 20")))) + containsString("game_type = (0,4,7,8,9,10,11)"), containsString("total_rating_count"), + containsString("limit 50")))) .andRespond(withSuccess(ZELDA, MediaType.APPLICATION_JSON)); List games = service.searchGames(" zelda ").orElseThrow(); @@ -206,6 +207,32 @@ class IgdbApiServiceTest extends AbstractUnitTest { "https://images.igdb.com/igdb/image/upload/t_screenshot_med/ar1.jpg", List.of("PS5", "PC"), 1245620L)); } + @Test + void searchGames_PutsTheExactNameFirstThenTheGamesWithMoreRatings() { + expectToken(); + igdb.expect(requestTo(containsString("/games"))) + .andRespond(withSuccess("[{\"id\": 1, \"name\": \"Super Super Super Super\"}," + + " {\"id\": 2, \"name\": \"Super Mario Galaxy 2\", \"total_rating_count\": 900}," + + " {\"id\": 3, \"name\": \"Super Mario Bros.\", \"total_rating_count\": 1500}," + + " {\"id\": 4, \"name\": \"Super Mario\", \"total_rating_count\": 5}]", MediaType.APPLICATION_JSON)); + + assertThat(service.searchGames("super mario").orElseThrow()).extracting(IgdbApiService.Game::igdbId) + .containsExactly(4L, 3L, 2L, 1L); + } + + @Test + void searchGames_LeavesOutGamesOnlyOnTheEReader() { + expectToken(); + igdb.expect(requestTo(containsString("/games"))) + .andRespond(withSuccess("[{\"id\": 1, \"name\": \"Super Mario Advance 4: Super Mario Bros. 3-e - Para Beetle Challenge\"," + + " \"platforms\": [{\"id\": 510, \"name\": \"e-Reader / Card-e Reader\"}]}," + + " {\"id\": 2, \"name\": \"Super Mario Advance 4\", \"platforms\": [{\"id\": 24, \"name\": \"Game Boy Advance\", \"abbreviation\": \"GBA\"}," + + " {\"id\": 510, \"name\": \"e-Reader / Card-e Reader\"}]}]", MediaType.APPLICATION_JSON)); + + assertThat(service.searchGames("super mario").orElseThrow()).extracting(IgdbApiService.Game::igdbId) + .containsExactly(2L); + } + @Test void searchGames_OnlyACover_UsesTheCover() { expectToken(); diff --git a/src/test/java/core_service/modules/library/api/ManualGameControllerTest.java b/src/test/java/core_service/modules/library/api/ManualGameControllerTest.java index 8debf89..dcf8365 100644 --- a/src/test/java/core_service/modules/library/api/ManualGameControllerTest.java +++ b/src/test/java/core_service/modules/library/api/ManualGameControllerTest.java @@ -305,6 +305,7 @@ class ManualGameControllerTest extends AbstractIntegrationTest { @Test void gameAddedByHand_ShowsUpInTheLibraryAndItsDetail() throws Exception { add(119388, "Switch"); + timeToBeatStored(-119388L); mockMvc.perform(get("/api/library/games").param("q", "zelda")) .andExpect(status().isOk()) diff --git a/src/test/java/core_service/modules/library/service/FuzzySearchTest.java b/src/test/java/core_service/modules/library/service/FuzzySearchTest.java new file mode 100644 index 0000000..a766a1f --- /dev/null +++ b/src/test/java/core_service/modules/library/service/FuzzySearchTest.java @@ -0,0 +1,43 @@ +package core_service.modules.library.service; + +import core_service.shared.test.AbstractUnitTest; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class FuzzySearchTest extends AbstractUnitTest { + + @Test + void withoutDoubledLetters_KeepsOneLetterOfEachRun() { + assertThat(FuzzySearch.withoutDoubledLetters("Superr Marioo")).contains("Super Mario"); + } + + @Test + void withoutDoubledLetters_NothingDoubled_IsEmpty() { + assertThat(FuzzySearch.withoutDoubledLetters("Zelda Breath")).isEmpty(); + } + + @Test + void words_SkipShortWordsAndStopAtTwo() { + assertThat(FuzzySearch.words("of the Legend Zelda Breath")).containsExactly("Legend", "Zelda"); + } + + @Test + void words_OneWord_HasNone() { + assertThat(FuzzySearch.words("Zelda")).isEmpty(); + } + + @Test + void distance_ComparesWithTheStartOfTheNameIgnoringCase() { + assertThat(FuzzySearch.distance("superr mario", "Super Mario Bros. 3")).isEqualTo(1); + assertThat(FuzzySearch.distance("mass efect", "Mass Effect")).isEqualTo(1); + assertThat(FuzzySearch.distance("mass efect", "Mass Destruction")).isGreaterThan(3); + } + + @Test + void levenshtein_CountsInsertsDeletesAndChanges() { + assertThat(FuzzySearch.levenshtein("kitten", "sitting")).isEqualTo(3); + assertThat(FuzzySearch.levenshtein("", "abc")).isEqualTo(3); + assertThat(FuzzySearch.levenshtein("same", "same")).isZero(); + } +} diff --git a/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java b/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java index 09b8476..46177ee 100644 --- a/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java +++ b/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java @@ -17,6 +17,10 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class ManualGameServiceTest extends AbstractUnitTest { @@ -76,4 +80,93 @@ class ManualGameServiceTest extends AbstractUnitTest { assertThat(result).isEqualTo(new ManualGameService.AddResult.Added(-119388L)); } + + private static IgdbApiService.Game game(long igdbId, String name) { + return new IgdbApiService.Game(igdbId, name, 2000, null, List.of("Switch"), null); + } + + @Test + void search_TypoWithADoubledLetter_FindsTheGameWithoutIt() { + when(igdbApiService.isEnabled()).thenReturn(true); + when(igdbApiService.searchGames("Superr Mario")).thenReturn(Optional.of(List.of())); + when(igdbApiService.searchGames("Super Mario")).thenReturn(Optional.of(List.of( + game(3, "Super Mario Bros. 3"), game(1, "Super Mario Bros."), game(2, "Super Mario 64")))); + + List names = service.search(USER_ID, "Superr Mario").orElseThrow().results().stream() + .map(hit -> hit.name()).toList(); + + assertThat(names).containsExactly("Super Mario Bros. 3", "Super Mario Bros.", "Super Mario 64"); + } + + @Test + void search_TypoInOneWord_SearchesTheWordsAloneAndPutsTheClosestNameFirst() { + when(igdbApiService.isEnabled()).thenReturn(true); + when(igdbApiService.searchGames(anyString())).thenReturn(Optional.of(List.of())); + when(igdbApiService.searchGames("Mass")).thenReturn(Optional.of(List.of( + game(1, "Mass Destruction"), game(2, "Mass Effect")))); + when(igdbApiService.searchGames("Efect")).thenReturn(Optional.empty()); + + List names = service.search(USER_ID, "Mass Efect").orElseThrow().results().stream() + .map(hit -> hit.name()).toList(); + + assertThat(names).containsExactly("Mass Effect", "Mass Destruction"); + } + + @Test + void search_EnoughHits_AsksIgdbOnce() { + when(igdbApiService.isEnabled()).thenReturn(true); + when(igdbApiService.searchGames("zelda")).thenReturn(Optional.of(List.of( + game(1, "Zelda A"), game(2, "Zelda B"), game(3, "Zelda C")))); + + assertThat(service.search(USER_ID, "zelda").orElseThrow().results()).hasSize(3); + verify(igdbApiService, times(1)).searchGames(anyString()); + } + + @Test + void search_TheGameIgdbFoundStaysBeforeTheTypoHits() { + when(igdbApiService.isEnabled()).thenReturn(true); + when(igdbApiService.searchGames("Breath of the Wild")).thenReturn(Optional.of(List.of( + game(1, "The Legend of Zelda: Breath of the Wild")))); + when(igdbApiService.searchGames("Breath")).thenReturn(Optional.of(List.of( + game(2, "Breath of Fire"), game(3, "Breath of Fire II")))); + + List names = service.search(USER_ID, "Breath of the Wild").orElseThrow().results().stream() + .map(hit -> hit.name()).toList(); + + assertThat(names).startsWith("The Legend of Zelda: Breath of the Wild"); + } + + @Test + void search_FewHits_KeepTheOrderOfTheSearch() { + when(igdbApiService.isEnabled()).thenReturn(true); + when(igdbApiService.searchGames(anyString())).thenReturn(Optional.of(List.of())); + when(igdbApiService.searchGames("zelda wild")).thenReturn(Optional.of(List.of( + game(1, "The Legend of Zelda: Breath of the Wild"), game(2, "Zelda: The Wand of Gamelon")))); + + List names = service.search(USER_ID, "zelda wild").orElseThrow().results().stream() + .map(hit -> hit.name()).toList(); + + assertThat(names).containsExactly("The Legend of Zelda: Breath of the Wild", "Zelda: The Wand of Gamelon"); + } + + @Test + void search_IgdbFailed_IsEmptyEvenWithoutAFallback() { + when(igdbApiService.isEnabled()).thenReturn(true); + when(igdbApiService.searchGames(anyString())).thenReturn(Optional.empty()); + + assertThat(service.search(USER_ID, "Superr Mario")).isEmpty(); + } + + @Test + void search_SomeHits_TriesNoDoubledLetterVariantAndAtMostTwoWords() { + when(igdbApiService.isEnabled()).thenReturn(true); + when(igdbApiService.searchGames(anyString())).thenReturn(Optional.of(List.of())); + when(igdbApiService.searchGames("Hollow Knight Silksong")).thenReturn(Optional.of(List.of(game(1, "Hollow Knight")))); + + service.search(USER_ID, "Hollow Knight Silksong"); + + verify(igdbApiService, never()).searchGames("Holow Knight Silksong"); + verify(igdbApiService, never()).searchGames("Silksong"); + verify(igdbApiService, times(3)).searchGames(anyString()); + } }