feat(speedrun): match speedrun.com games only when sure
Build, Test & Push Core Service / quality-check (push) Successful in 2m32s
Build, Test & Push Core Service / docker (push) Successful in 1m29s

Loose name matches gave wrong links. Now a link needs one sure hit:
same name and within 1 year of IGDB, or one hit when no year is known.
V47 marks old matches (not corrected ones) for one re-check. Refs #8
This commit is contained in:
lobo committed 2026-10-11 17:23:32 +02:00
1 parent bb195a1f79
commit 9efed0ab1d
14 files changed
+601 -79

No files matched your search

+6 -3
View File
@@ -67,10 +67,13 @@ To Be Played is split into three repositories:
N["next up: random of top 5 in order"] --> M N["next up: random of top 5 in order"] --> M
M --> O["games + reason"] M --> O["games + reason"]
``` ```
- **Speedrun games**: a backlog game counts as a speedrun game when speedrun.com has a game with the same name (and - **Speedrun games**: a backlog game counts as a speedrun game when speedrun.com has a game with the same name (case,
a release year at most one year off, when IGDB knows it) with at least one verified run. The game detail returns punctuation, ™ and roman numerals ignored) and a release year at most one year off, when IGDB knows it, with at
least one verified run. An edition that finds nothing tries its IGDB parent game with the parent's year, else its
name without an edition suffix. Several same-name games and no year to decide: no link. The game detail returns
the speedrun.com link as `speedrunUrl`. speedrun.com has no Steam or IGDB link and no key, so one queue works for the speedrun.com link as `speedrunUrl`. speedrun.com has no Steam or IGDB link and no key, so one queue works for
all users, with up to 3 requests per game and 1 request per second; a result is kept 30 days. all users, with up to 3 requests per game and 1 request per second; a result is kept 30 days. Migration V47 asks
every stored match again once at boot, behind user requests; links set by an auditor are never touched.
- **Achievements**: after every library sync a background job fetches the achievement schema of each owned game - **Achievements**: after every library sync a background job fetches the achievement schema of each owned game
and the user's unlocks of each game whose playtime changed. One Steam call per second, so user requests keep half and the user's unlocks of each game whose playtime changed. One Steam call per second, so user requests keep half
of the rate limit; it stops after 3 Steam errors in a row or when Steam hides the achievements. A Steam error never of the rate limit; it stops after 3 Steam errors in a row or when Steam hides the achievements. A Steam error never
@@ -8,7 +8,7 @@ import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
/** /**
* The two pools of {@link BackgroundPools}. A dropped task is a refresh of data with a TTL: the next view or sync * The pools of {@link BackgroundPools}. A dropped task is a refresh of data with a TTL: the next view or sync
* asks again. * asks again.
*/ */
@Configuration @Configuration
@@ -43,4 +43,14 @@ public class AsyncConfig {
executor.setThreadNamePrefix("sync-follow-up-"); executor.setThreadNamePrefix("sync-follow-up-");
return executor; return executor;
} }
// One thread: speedrun.com has one rate limit and the queue has one drainer. The queue holds only drain triggers.
@Bean(name = BackgroundPools.SPEEDRUN)
public ThreadPoolTaskExecutor speedrunExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(1);
executor.setMaxPoolSize(1);
executor.setThreadNamePrefix("speedrun-");
return executor;
}
} }
@@ -30,6 +30,7 @@ import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.TreeSet; import java.util.TreeSet;
import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
@@ -77,6 +78,9 @@ public class IgdbApiService {
*/ */
public record Game(long igdbId, String name, Integer year, String imageUrl, List<String> platforms, Long steamAppid) {} public record Game(long igdbId, String name, Integer year, String imageUrl, List<String> platforms, Long steamAppid) {}
/** @param year null when IGDB knows no release date */
public record NameAndYear(String name, Integer year) {}
// IGDB's own order puts obscure entries first, so Core asks for more and sorts by popularity itself. // 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_FETCH_LIMIT = 50;
private static final int SEARCH_LIMIT = 20; private static final int SEARCH_LIMIT = 20;
@@ -382,18 +386,31 @@ public class IgdbApiService {
* Like {@link #getReleaseDates}, but for IGDB game ids. * Like {@link #getReleaseDates}, but for IGDB game ids.
*/ */
public Optional<Map<Long, LocalDate>> getReleaseDatesByGameIds(Collection<Long> igdbIds) { public Optional<Map<Long, LocalDate>> getReleaseDatesByGameIds(Collection<Long> igdbIds) {
return inBatches(igdbIds, "release dates", this::datesOf);
}
/**
* The name and release year of IGDB games by id. Games IGDB does not know, or without a name, are missing. Empty
* when the adapter is off, nothing was asked or a request failed.
*/
public Optional<Map<Long, NameAndYear>> getNamesAndYears(Collection<Long> igdbIds) {
return inBatches(igdbIds, "game names", this::namesAndYearsOf);
}
private <T> Optional<Map<Long, T>> inBatches(Collection<Long> igdbIds, String what,
Function<List<Long>, Map<Long, T>> batchQuery) {
if (!isEnabled() || igdbIds.isEmpty()) { if (!isEnabled() || igdbIds.isEmpty()) {
return Optional.empty(); return Optional.empty();
} }
try { try {
List<Long> ids = List.copyOf(igdbIds); List<Long> ids = List.copyOf(igdbIds);
Map<Long, LocalDate> result = new HashMap<>(); Map<Long, T> result = new HashMap<>();
for (int from = 0; from < ids.size(); from += BATCH) { for (int from = 0; from < ids.size(); from += BATCH) {
result.putAll(datesOf(ids.subList(from, Math.min(from + BATCH, ids.size())))); result.putAll(batchQuery.apply(ids.subList(from, Math.min(from + BATCH, ids.size()))));
} }
return Optional.of(result); return Optional.of(result);
} catch (Exception e) { } catch (Exception e) {
log.error("Error communicating with IGDB for release dates", e); log.error("Error communicating with IGDB for {}", what, e);
return Optional.empty(); return Optional.empty();
} }
} }
@@ -601,10 +618,7 @@ public class IgdbApiService {
if (igdbIds.isEmpty()) { if (igdbIds.isEmpty()) {
return Map.of(); return Map.of();
} }
String gameIds = igdbIds.stream().map(String::valueOf).collect(Collectors.joining(",")); IgdbResponses.Game[] games = gamesById(igdbIds, "first_release_date");
IgdbResponses.Game[] games = query("/games",
"fields first_release_date; where id = (" + gameIds + "); limit " + LIMIT + ";",
IgdbResponses.Game[].class);
Map<Long, LocalDate> result = new HashMap<>(); Map<Long, LocalDate> result = new HashMap<>();
for (IgdbResponses.Game game : games) { for (IgdbResponses.Game game : games) {
if (game.id() != null && game.firstReleaseDate() != null && igdbIds.contains(game.id())) { if (game.id() != null && game.firstReleaseDate() != null && igdbIds.contains(game.id())) {
@@ -614,6 +628,23 @@ public class IgdbApiService {
return result; return result;
} }
private Map<Long, NameAndYear> namesAndYearsOf(Collection<Long> igdbIds) {
Map<Long, NameAndYear> result = new HashMap<>();
for (IgdbResponses.Game game : gamesById(igdbIds, "name,first_release_date")) {
String name = trimmedName(game);
if (game.id() != null && igdbIds.contains(game.id()) && !name.isEmpty()) {
result.put(game.id(), new NameAndYear(name, game.firstReleaseDate() == null ? null : year(game.firstReleaseDate())));
}
}
return result;
}
private IgdbResponses.Game[] gamesById(Collection<Long> igdbIds, String fields) {
String gameIds = igdbIds.stream().map(String::valueOf).collect(Collectors.joining(","));
return query("/games", "fields " + fields + "; where id = (" + gameIds + "); limit " + LIMIT + ";",
IgdbResponses.Game[].class);
}
private static int year(long unixSeconds) { private static int year(long unixSeconds) {
return date(unixSeconds).getYear(); return date(unixSeconds).getYear();
} }
@@ -9,6 +9,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant; import java.time.Instant;
import java.util.Collection; import java.util.Collection;
import java.util.List;
import java.util.Set; import java.util.Set;
public interface GameSpeedrunRepository extends JpaRepository<GameSpeedrun, Long> { public interface GameSpeedrunRepository extends JpaRepository<GameSpeedrun, Long> {
@@ -48,4 +49,12 @@ public interface GameSpeedrunRepository extends JpaRepository<GameSpeedrun, Long
@Modifying @Modifying
@Query("update GameSpeedrun s set s.checkedAt = :longAgo where s.appid = :appid") @Query("update GameSpeedrun s set s.checkedAt = :longAgo where s.appid = :appid")
void markStale(@Param("appid") Long appid, @Param("longAgo") Instant longAgo); void markStale(@Param("appid") Long appid, @Param("longAgo") Instant longAgo);
/**
* Rows marked for a new check (checked_at <= before) of games some user lists, without an auditor's link.
*/
@Query(value = "select appid from game_speedrun where checked_at <= :before and not " + CORRECTED
+ " and exists (select 1 from listed_user_game ug where ug.appid = game_speedrun.appid) order by appid",
nativeQuery = true)
List<Long> findAppidsToRecheck(@Param("before") Instant before);
} }
@@ -6,21 +6,33 @@ import core_service.modules.speedrun_adapter.service.SpeedrunApiService;
import java.util.Collection; import java.util.Collection;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
/** /**
* Finds a library game among speedrun.com's search hits. speedrun.com has no Steam or IGDB link, so the name must be * Finds a library game among speedrun.com's search hits, or none: a wrong link is worse than no link. speedrun.com has
* the same and, when both years are known, the release year may differ by at most one. * no Steam or IGDB link, so the name must be the same (case, punctuation, trademark signs and roman numerals do not
* count). With known years the year of the hit must be within one of ours. Without a year to decide, only a single
* same-name hit counts.
*/ */
final class SpeedrunMatch { final class SpeedrunMatch {
static final int MAX_CANDIDATES = 2;
static final String WEBLINK_PREFIX = "https://www.speedrun.com/"; static final String WEBLINK_PREFIX = "https://www.speedrun.com/";
// speedrun.com tells remakes apart by the year: "Prey (2017)" next to "Prey". // speedrun.com tells remakes apart by the year: "Prey (2017)" next to "Prey".
private static final Pattern YEAR_SUFFIX = Pattern.compile("^(.*?)\\s*\\((\\d{4})\\)$"); private static final Pattern YEAR_SUFFIX = Pattern.compile("^(.*?)\\s*\\((\\d{4})\\)$");
// Other pages on speedrun.com ("Remastered", "Remake") are left alone on purpose.
private static final Pattern EDITION_SUFFIX = Pattern.compile(
"\\s*[-:\u2013]?\\s*(?:the\\s+)?(?:(?:game of the year|goty|special|definitive|complete|deluxe|digital deluxe|enhanced|gold|"
+ "ultimate|anniversary|legendary|premium) edition|goty|director'?s cut)\\s*$",
Pattern.CASE_INSENSITIVE);
// Index + 2 is the number.
private static final List<String> ROMAN_NUMERALS = List.of("ii", "iii", "iv", "v", "vi", "vii", "viii", "ix", "x",
"xi", "xii", "xiii", "xiv", "xv", "xvi", "xvii", "xviii", "xix", "xx");
// "i" and "x" stay: "Mega Man X" is not "Mega Man 10".
private static final Pattern ROMAN_NUMERAL = Pattern.compile("\\b(?!x\\b)(" + String.join("|", ROMAN_NUMERALS) + ")\\b");
// The column sizes of game_speedrun. // The column sizes of game_speedrun.
private static final int MAX_GAME_ID_LENGTH = 16; private static final int MAX_GAME_ID_LENGTH = 16;
private static final int MAX_WEBLINK_LENGTH = 512; private static final int MAX_WEBLINK_LENGTH = 512;
@@ -39,23 +51,40 @@ final class SpeedrunMatch {
/** /**
* @param years our release years of the game; empty when unknown * @param years our release years of the game; empty when unknown
* @param hits in speedrun.com's order * @param hits in speedrun.com's order
* @return the hits worth asking for runs, best first, at most {@link #MAX_CANDIDATES} * @return the one hit that is this game; empty when no hit is, or when several could be
*/ */
static List<SpeedrunApiService.Game> candidates(String name, Collection<Integer> years, List<SpeedrunApiService.Game> hits) { static Optional<SpeedrunApiService.Game> sure(String name, Collection<Integer> years, List<SpeedrunApiService.Game> hits) {
NameAndYear ours = split(name); NameAndYear ours = split(name);
Set<Integer> ourYears = new HashSet<>(years); Set<Integer> ourYears = new HashSet<>(years);
if (ours.year() != null) ourYears.add(ours.year()); if (ours.year() != null) ourYears.add(ours.year());
return hits.stream() List<SpeedrunApiService.Game> same = hits.stream()
.filter(hit -> hit.id() != null && hit.id().length() <= MAX_GAME_ID_LENGTH) .filter(hit -> hit.id() != null && hit.id().length() <= MAX_GAME_ID_LENGTH)
.filter(hit -> isWeblink(hit.weblink())) .filter(hit -> isWeblink(hit.weblink()))
.filter(hit -> { .filter(hit -> split(hit.name()).name().equals(ours.name()))
NameAndYear theirs = split(hit.name());
Integer year = theirs.year() != null ? theirs.year() : hit.year();
return theirs.name().equals(ours.name()) && (year == null || ourYears.isEmpty()
|| ourYears.stream().anyMatch(ourYear -> Math.abs(ourYear - year) <= 1));
})
.limit(MAX_CANDIDATES)
.toList(); .toList();
if (ourYears.isEmpty()) {
return only(same);
}
List<SpeedrunApiService.Game> dated = same.stream()
.filter(hit -> {
Integer year = yearOf(hit);
return year != null && ourYears.stream().anyMatch(ourYear -> Math.abs(ourYear - year) <= 1);
})
.toList();
if (!dated.isEmpty()) {
return only(dated);
}
return only(same).filter(hit -> yearOf(hit) == null);
}
/**
* @return the name without "Special Edition", "Director's Cut" and the like; empty when it has no such suffix or
* nothing is left
*/
static Optional<String> withoutEditionSuffix(String name) {
String trimmed = name.trim();
String stripped = EDITION_SUFFIX.matcher(trimmed).replaceFirst("").trim();
return stripped.isEmpty() || stripped.equals(trimmed) ? Optional.empty() : Optional.of(stripped);
} }
/** /**
@@ -65,11 +94,25 @@ final class SpeedrunMatch {
return name.replaceAll(GameNames.TRADEMARK_SIGNS, "").trim(); return name.replaceAll(GameNames.TRADEMARK_SIGNS, "").trim();
} }
private static Optional<SpeedrunApiService.Game> only(List<SpeedrunApiService.Game> games) {
return games.size() == 1 ? Optional.of(games.get(0)) : Optional.empty();
}
private static Integer yearOf(SpeedrunApiService.Game hit) {
Integer fromName = split(hit.name()).year();
return fromName != null ? fromName : hit.year();
}
private static String key(String name) {
return ROMAN_NUMERAL.matcher(GameNames.normalize(name)).replaceAll(
match -> String.valueOf(ROMAN_NUMERALS.indexOf(match.group()) + 2));
}
private static NameAndYear split(String name) { private static NameAndYear split(String name) {
Matcher matcher = YEAR_SUFFIX.matcher(name.trim()); Matcher matcher = YEAR_SUFFIX.matcher(name.trim());
return matcher.matches() return matcher.matches()
? new NameAndYear(GameNames.normalize(matcher.group(1)), Integer.parseInt(matcher.group(2))) ? new NameAndYear(key(matcher.group(1)), Integer.parseInt(matcher.group(2)))
: new NameAndYear(GameNames.normalize(name), null); : new NameAndYear(key(name), null);
} }
private record NameAndYear(String name, Integer year) {} private record NameAndYear(String name, Integer year) {}
@@ -1,9 +1,11 @@
package core_service.modules.library.service; package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService; import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.domain.GameEdition;
import core_service.modules.library.domain.GameKeys; import core_service.modules.library.domain.GameKeys;
import core_service.modules.library.domain.Mood; import core_service.modules.library.domain.Mood;
import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.repo.GameEditionRepository;
import core_service.modules.library.repo.GameSpeedrunRepository; import core_service.modules.library.repo.GameSpeedrunRepository;
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;
@@ -12,6 +14,8 @@ import core_service.shared.background.BackgroundPools;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.core.task.TaskExecutor; import org.springframework.core.task.TaskExecutor;
import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -32,7 +36,14 @@ import java.util.concurrent.atomic.AtomicBoolean;
/** /**
* Stores which games have a speedrun.com page with verified runs, for {@link Mood#SPEEDRUN} and the link on the * Stores which games have a speedrun.com page with verified runs, for {@link Mood#SPEEDRUN} and the link on the
* detail page. speedrun.com is searched by name, one game at a time, so all callers share one queue and one thread * detail page. speedrun.com is searched by name, one game at a time, so all callers share one queue and one thread
* works it off: a game in several backlogs is asked once, and the rate limit has one waiter. * (the speedrun executor) works it off: a game in several backlogs is asked once, and the rate limit has one waiter.
* <p>
* A game is looked up by its own name and years; when that gives no sure hit, an edition is looked up by the name and
* year of its IGDB parent game, a game without a parent by its name without an edition suffix. A sure hit without
* verified runs ends the lookup. See {@link SpeedrunMatch} for "sure".
* <p>
* {@link #recheckMarked} asks the rows a migration marked (checked_at = epoch) again. Those games wait behind every
* game a user asked for.
*/ */
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -42,27 +53,53 @@ public class SpeedrunService {
static final int BATCH = 50; static final int BATCH = 50;
// speedrun.com is down or blocks us; the rest of the queue is asked on the next trigger. // speedrun.com is down or blocks us; the rest of the queue is asked on the next trigger.
static final int MAX_ERRORS_IN_A_ROW = 3; static final int MAX_ERRORS_IN_A_ROW = 3;
// A small batch, so a user request waits for a few games and not for 50.
static final int RECHECK_BATCH = 10;
private final SpeedrunApiService speedrunApiService; private final SpeedrunApiService speedrunApiService;
private final IgdbApiService igdbApiService; private final IgdbApiService igdbApiService;
private final GameSpeedrunRepository gameSpeedrunRepository; private final GameSpeedrunRepository gameSpeedrunRepository;
private final GameEditionRepository gameEditionRepository;
private final SteamGameRepository steamGameRepository; private final SteamGameRepository steamGameRepository;
private final UserGameRepository userGameRepository; private final UserGameRepository userGameRepository;
private final TaskExecutor syncFollowUpExecutor; private final TaskExecutor syncFollowUpExecutor;
// Works both queues off on its own thread, so the follow-up pool never waits for speedrun.com.
private final TaskExecutor speedrunExecutor;
private final ConcurrentSkipListSet<Long> pending = new ConcurrentSkipListSet<>(); private final ConcurrentSkipListSet<Long> pending = new ConcurrentSkipListSet<>();
private final ConcurrentSkipListSet<Long> recheck = new ConcurrentSkipListSet<>();
private final AtomicBoolean running = new AtomicBoolean(); private final AtomicBoolean running = new AtomicBoolean();
@Value("${app.library.speedrun-ttl-days:30}") @Value("${app.library.speedrun-ttl-days:30}")
private long ttlDays; private long ttlDays;
/** /**
* Queues the games without a fresh row and, unless another thread already does it, works the queue off on this * Queues the games without a fresh row and returns; the speedrun thread works the queue off. A game takes up to 3
* thread. A game takes up to 3 requests, and there is 1 request per second. * requests, and there is 1 request per second.
*/ */
public void refresh(Collection<Long> appids) { public void refresh(Collection<Long> appids) {
if (!speedrunApiService.isEnabled() || appids.isEmpty()) return; if (!speedrunApiService.isEnabled() || appids.isEmpty()) return;
pending.addAll(appids); pending.addAll(appids);
speedrunExecutor.execute(this::work);
}
/**
* Queues the rows the V47 migration marked, behind the games users asked for.
*/
@EventListener(ApplicationReadyEvent.class)
public void recheckMarked() {
if (!speedrunApiService.isEnabled()) return;
syncFollowUpExecutor.execute(() -> Refreshes.quietly("Speedrun re-check", () -> {
List<Long> marked = gameSpeedrunRepository.findAppidsToRecheck(Instant.EPOCH);
if (marked.isEmpty()) return;
log.info("Speedrun re-check of {} games", marked.size());
recheck.addAll(marked);
speedrunExecutor.execute(this::work);
}));
}
private void work() {
// Another work() is draining: it also takes what was queued meanwhile.
while (running.compareAndSet(false, true)) { while (running.compareAndSet(false, true)) {
try { try {
drain(); drain();
@@ -70,7 +107,7 @@ public class SpeedrunService {
running.set(false); running.set(false);
} }
// Games can be queued after the last poll and before the flag was cleared. // Games can be queued after the last poll and before the flag was cleared.
if (pending.isEmpty()) return; if (pending.isEmpty() && recheck.isEmpty()) return;
} }
} }
@@ -99,12 +136,9 @@ public class SpeedrunService {
private void drain() { private void drain() {
int errorsInARow = 0; int errorsInARow = 0;
while (!pending.isEmpty()) { while (!pending.isEmpty() || !recheck.isEmpty()) {
List<Long> batch = new ArrayList<>(); boolean forUsers = !pending.isEmpty();
Long next; List<Long> batch = forUsers ? poll(pending, BATCH) : poll(recheck, RECHECK_BATCH);
while (batch.size() < BATCH && (next = pending.pollFirst()) != null) {
batch.add(next);
}
batch.removeAll(gameSpeedrunRepository.findFreshAppids(batch, Instant.now().minus(Duration.ofDays(ttlDays)))); batch.removeAll(gameSpeedrunRepository.findFreshAppids(batch, Instant.now().minus(Duration.ofDays(ttlDays))));
if (batch.isEmpty()) continue; if (batch.isEmpty()) continue;
@@ -113,17 +147,24 @@ public class SpeedrunService {
names.put(game.getAppid(), game.getName()); names.put(game.getAppid(), game.getName());
} }
Optional<Map<Long, Set<Integer>>> years = releaseYears(batch); Optional<Map<Long, Set<Integer>>> years = releaseYears(batch);
if (years.isEmpty()) { Optional<Map<Long, IgdbApiService.NameAndYear>> parents = years.isEmpty() ? Optional.empty() : parentNames(batch);
if (parents.isEmpty()) {
// Matching by name only would store a wrong remake for 30 days. // Matching by name only would store a wrong remake for 30 days.
log.warn("Speedrun games of {} not refreshed, IGDB did not answer", batch.size()); log.warn("Speedrun games of {} not refreshed, IGDB did not answer", batch.size());
continue; continue;
} }
for (Long appid : batch) { for (int i = 0; i < batch.size(); i++) {
if (!forUsers && !pending.isEmpty()) {
// A user asked in the meantime: the rest of the re-check goes behind it.
recheck.addAll(batch.subList(i, batch.size()));
break;
}
Long appid = batch.get(i);
String name = names.get(appid); String name = names.get(appid);
if (name == null || name.equals(SteamGame.placeholderName(appid))) continue; if (name == null || name.equals(SteamGame.placeholderName(appid))) continue;
boolean stored; boolean stored;
try { try {
stored = checkAndStore(appid, name, years.get().getOrDefault(appid, Set.of())); stored = checkAndStore(appid, name, years.get().getOrDefault(appid, Set.of()), parents.get().get(appid));
} catch (Exception e) { } catch (Exception e) {
log.warn("Speedrun game of appid={} not stored: {}", appid, e.getMessage()); log.warn("Speedrun game of appid={} not stored: {}", appid, e.getMessage());
stored = false; stored = false;
@@ -133,31 +174,78 @@ public class SpeedrunService {
} else if (++errorsInARow >= MAX_ERRORS_IN_A_ROW) { } else if (++errorsInARow >= MAX_ERRORS_IN_A_ROW) {
log.warn("Speedrun check failed {} times in a row, {} queued games dropped", errorsInARow, pending.size()); log.warn("Speedrun check failed {} times in a row, {} queued games dropped", errorsInARow, pending.size());
pending.clear(); pending.clear();
recheck.clear();
return; return;
} }
} }
} }
} }
private static List<Long> poll(ConcurrentSkipListSet<Long> queue, int size) {
List<Long> batch = new ArrayList<>();
Long next;
while (batch.size() < size && (next = queue.pollFirst()) != null) {
batch.add(next);
}
return batch;
}
/** /**
* @param parent the IGDB game this one is an edition, remaster or port of; null when there is none
* @return false when a speedrun.com call failed; then nothing is stored and the game is asked again later * @return false when a speedrun.com call failed; then nothing is stored and the game is asked again later
*/ */
private boolean checkAndStore(Long appid, String name, Set<Integer> years) { private boolean checkAndStore(Long appid, String name, Set<Integer> years, IgdbApiService.NameAndYear parent) {
Optional<List<SpeedrunApiService.Game>> hits = speedrunApiService.findGames(SpeedrunMatch.searchText(name));
if (hits.isEmpty()) return false;
SpeedrunApiService.Game match = null; SpeedrunApiService.Game match = null;
for (SpeedrunApiService.Game candidate : SpeedrunMatch.candidates(name, years, hits.get())) { for (Lookup lookup : lookups(name, years, parent)) {
Optional<Boolean> hasRuns = speedrunApiService.hasVerifiedRuns(candidate.id()); Optional<List<SpeedrunApiService.Game>> hits = speedrunApiService.findGames(SpeedrunMatch.searchText(lookup.name()));
if (hits.isEmpty()) return false;
Optional<SpeedrunApiService.Game> sure = SpeedrunMatch.sure(lookup.name(), lookup.years(), hits.get());
if (sure.isEmpty()) continue;
Optional<Boolean> hasRuns = speedrunApiService.hasVerifiedRuns(sure.get().id());
if (hasRuns.isEmpty()) return false; if (hasRuns.isEmpty()) return false;
if (hasRuns.get()) { if (hasRuns.get()) match = sure.get();
match = candidate;
break; break;
} }
}
gameSpeedrunRepository.upsert(appid, match == null ? null : match.id(), match == null ? null : match.weblink(), Instant.now()); gameSpeedrunRepository.upsert(appid, match == null ? null : match.id(), match == null ? null : match.weblink(), Instant.now());
return true; return true;
} }
private static List<Lookup> lookups(String name, Set<Integer> years, IgdbApiService.NameAndYear parent) {
List<Lookup> lookups = new ArrayList<>();
lookups.add(new Lookup(name, years));
if (parent != null && !SpeedrunMatch.searchText(parent.name()).equalsIgnoreCase(SpeedrunMatch.searchText(name))) {
lookups.add(new Lookup(parent.name(), parent.year() == null ? Set.of() : Set.of(parent.year())));
} else {
SpeedrunMatch.withoutEditionSuffix(name).ifPresent(plain -> lookups.add(new Lookup(plain, years)));
}
return lookups;
}
private record Lookup(String name, Set<Integer> years) {}
/**
* @return library key to the IGDB game it is an edition, remaster or port of; a game that is its own same game
* has no parent. Empty when IGDB failed.
*/
private Optional<Map<Long, IgdbApiService.NameAndYear>> parentNames(List<Long> appids) {
Map<Long, List<Long>> appidsByParent = new HashMap<>();
for (GameEdition edition : gameEditionRepository.findAllById(appids)) {
if (edition.getIgdbId() != null && edition.getSameGameId() != null
&& !edition.getSameGameId().equals(edition.getIgdbId())) {
appidsByParent.computeIfAbsent(edition.getSameGameId(), id -> new ArrayList<>()).add(edition.getAppid());
}
}
if (appidsByParent.isEmpty() || !igdbApiService.isEnabled()) {
return Optional.of(Map.of());
}
Optional<Map<Long, IgdbApiService.NameAndYear>> found = igdbApiService.getNamesAndYears(appidsByParent.keySet());
if (found.isEmpty()) return Optional.empty();
Map<Long, IgdbApiService.NameAndYear> parents = new HashMap<>();
found.get().forEach((parentId, parent) ->
appidsByParent.get(parentId).forEach(appid -> parents.put(appid, parent)));
return Optional.of(parents);
}
/** /**
* @return empty when IGDB failed. A game IGDB does not know, and every game when IGDB is off, has no years and is * @return empty when IGDB failed. A game IGDB does not know, and every game when IGDB is off, has no years and is
* matched by name only. * matched by name only.
@@ -3,7 +3,7 @@ package core_service.shared.background;
import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration; import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration;
/** /**
* The bean names of the two background pools (configuration.AsyncConfig). Every {@code @Async} names one of them: * The bean names of the background pools (configuration.AsyncConfig). Every {@code @Async} names one of them:
* with the scheduler there are two TaskExecutor beans, and a bare {@code @Async} would run unbounded. * with the scheduler there are two TaskExecutor beans, and a bare {@code @Async} would run unbounded.
*/ */
public final class BackgroundPools { public final class BackgroundPools {
@@ -12,11 +12,16 @@ public final class BackgroundPools {
public static final String DEFAULT = TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME; public static final String DEFAULT = TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME;
/** /**
* The achievement runs after a sync and every speedrun refresh: they hold a thread for minutes (Steam and * The achievement runs after a sync: they hold a thread for minutes (Steam pace), so page views keep the default pool. Small tasks, nothing is dropped.
* speedrun.com pace), so page views keep the default pool. Small tasks, nothing is dropped.
*/ */
public static final String SYNC_FOLLOW_UP = "syncFollowUpExecutor"; public static final String SYNC_FOLLOW_UP = "syncFollowUpExecutor";
/**
* The one thread that works the speedrun queue off (speedrun.com pace, a re-check can take an hour). Other tasks
* never wait for it.
*/
public static final String SPEEDRUN = "speedrunExecutor";
private BackgroundPools() { private BackgroundPools() {
} }
} }
@@ -0,0 +1,4 @@
-- New speedrun.com matching rules (#8): every match not set by an auditor is asked again once. checked_at = epoch is
-- the marker SpeedrunService.recheckMarked queues at boot; the link stays until speedrun.com answers.
UPDATE game_speedrun s SET checked_at = 'epoch'
WHERE NOT EXISTS (SELECT 1 FROM game_correction c WHERE c.appid = s.appid AND c.field = 'SPEEDRUN');
@@ -4,6 +4,8 @@ import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
@@ -45,6 +47,27 @@ class AsyncConfigTest extends AbstractUnitTest {
assertFalse(droppedRan.get()); assertFalse(droppedRan.get());
} }
@Test
void speedrunPool_RunsOnOneNamedThread() throws InterruptedException {
ThreadPoolTaskExecutor executor = new AsyncConfig().speedrunExecutor();
executor.initialize();
CountDownLatch done = new CountDownLatch(2);
Set<String> threads = ConcurrentHashMap.newKeySet();
try {
for (int i = 0; i < 2; i++) {
executor.execute(() -> {
threads.add(Thread.currentThread().getName());
done.countDown();
});
}
assertTrue(done.await(5, TimeUnit.SECONDS));
} finally {
executor.shutdown();
}
assertEquals(Set.of("speedrun-1"), threads);
}
@Test @Test
void followUpPool_KeepsEveryTask() throws InterruptedException { void followUpPool_KeepsEveryTask() throws InterruptedException {
ThreadPoolTaskExecutor executor = new AsyncConfig().syncFollowUpExecutor(); ThreadPoolTaskExecutor executor = new AsyncConfig().syncFollowUpExecutor();
@@ -279,6 +279,28 @@ class IgdbApiServiceTest extends AbstractUnitTest {
assertThat(service.getReleaseDatesByGameIds(List.of(1025L, 1026L))).contains(Map.of(1025L, LocalDate.of(2017, 5, 5))); assertThat(service.getReleaseDatesByGameIds(List.of(1025L, 1026L))).contains(Map.of(1025L, LocalDate.of(2017, 5, 5)));
} }
@Test
void getNamesAndYears_MapsTheIgdbIdToNameAndYear() {
expectToken();
igdb.expect(requestTo(containsString("/games")))
.andExpect(content().string(containsString("fields name,first_release_date; where id = (1025,1026,1027)")))
.andRespond(withSuccess("[{\"id\": 1025, \"name\": \" Skyrim \", \"first_release_date\": 1321833600},"
+ " {\"id\": 1026, \"name\": \"Undated\"}, {\"id\": 1027, \"name\": \" \"}, {\"id\": 9, \"name\": \"Unasked\"}]",
MediaType.APPLICATION_JSON));
assertThat(service.getNamesAndYears(List.of(1025L, 1026L, 1027L))).contains(Map.of(
1025L, new IgdbApiService.NameAndYear("Skyrim", 2011),
1026L, new IgdbApiService.NameAndYear("Undated", null)));
}
@Test
void getNamesAndYears_IgdbError_IsEmpty() {
expectToken();
igdb.expect(requestTo(containsString("/games"))).andRespond(withServerError());
assertThat(service.getNamesAndYears(List.of(1025L))).isEmpty();
}
@Test @Test
void getReleaseDates_IgdbError_IsEmpty() { void getReleaseDates_IgdbError_IsEmpty() {
expectToken(); expectToken();
@@ -1,6 +1,8 @@
package core_service.modules.library.repo; package core_service.modules.library.repo;
import core_service.modules.library.domain.GameSpeedrun; import core_service.modules.library.domain.GameSpeedrun;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.shared.test.AbstractPersistenceTest; import core_service.shared.test.AbstractPersistenceTest;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -22,8 +24,15 @@ class GameSpeedrunRepositoryTest extends AbstractPersistenceTest {
@Autowired @Autowired
private EntityManager entityManager; private EntityManager entityManager;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@BeforeEach @BeforeEach
void clean() { void clean() {
userGameRepository.deleteAll();
gameSpeedrunRepository.deleteAll(); gameSpeedrunRepository.deleteAll();
} }
@@ -64,4 +73,35 @@ class GameSpeedrunRepositoryTest extends AbstractPersistenceTest {
assertThat(row.getSpeedrunGameId()).isNull(); assertThat(row.getSpeedrunGameId()).isNull();
assertThat(row.getCheckedAt()).isAfter(Instant.EPOCH); assertThat(row.getCheckedAt()).isAfter(Instant.EPOCH);
} }
private void list(long appid) {
steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now()));
userGameRepository.saveAndFlush(new UserGame(1L, appid, 0, Instant.now(), Instant.now()));
}
@Test
void findAppidsToRecheck_ListedMarkedRowsOnly() {
gameSpeedrunRepository.writeWeblink(620L, "https://www.speedrun.com/portal", Instant.EPOCH);
gameSpeedrunRepository.upsert(220L, null, null, Instant.now());
gameSpeedrunRepository.writeWeblink(730L, null, Instant.EPOCH);
gameSpeedrunRepository.writeWeblink(440L, null, Instant.EPOCH);
entityManager.createNativeQuery("insert into game_correction (appid, field, value, source_value, corrected_by, "
+ "corrected_at) values (730, 'SPEEDRUN', 'null', 'null', 7, now())").executeUpdate();
list(620);
list(220);
list(730);
assertThat(gameSpeedrunRepository.findAppidsToRecheck(Instant.EPOCH)).containsExactly(620L);
}
@Test
void findAppidsToRecheck_RowAnsweredOnce_IsNotPickedAgain() {
gameSpeedrunRepository.writeWeblink(620L, null, Instant.EPOCH);
list(620);
assertThat(gameSpeedrunRepository.findAppidsToRecheck(Instant.EPOCH)).containsExactly(620L);
gameSpeedrunRepository.upsert(620L, null, null, Instant.now());
assertThat(gameSpeedrunRepository.findAppidsToRecheck(Instant.EPOCH)).isEmpty();
}
} }
@@ -18,48 +18,95 @@ class SpeedrunMatchTest extends AbstractUnitTest {
private static final SpeedrunApiService.Game PREY_2006 = hit("prey", "Prey", 2006); private static final SpeedrunApiService.Game PREY_2006 = hit("prey", "Prey", 2006);
private static final SpeedrunApiService.Game PREY_2017 = hit("prey2017", "Prey (2017)", 2017); private static final SpeedrunApiService.Game PREY_2017 = hit("prey2017", "Prey (2017)", 2017);
@Test private static List<String> ids(String name, Set<Integer> years, List<SpeedrunApiService.Game> hits) {
void candidates_YearSuffixTellsTheRemakeApart() { return SpeedrunMatch.sure(name, years, hits).map(SpeedrunApiService.Game::id).stream().toList();
assertThat(SpeedrunMatch.candidates("Prey", Set.of(2017), List.of(PREY_2006, PREY_2017))).containsExactly(PREY_2017);
assertThat(SpeedrunMatch.candidates("Prey", Set.of(2006), List.of(PREY_2006, PREY_2017))).containsExactly(PREY_2006);
} }
@Test @Test
void candidates_UnknownYear_KeepsSpeedrunOrder() { void sure_IgdbYearPicksTheRemake() {
assertThat(SpeedrunMatch.candidates("Prey", Set.of(), List.of(PREY_2006, PREY_2017))).containsExactly(PREY_2006, PREY_2017); assertThat(SpeedrunMatch.sure("Prey", Set.of(2017), List.of(PREY_2006, PREY_2017))).contains(PREY_2017);
assertThat(SpeedrunMatch.sure("Prey", Set.of(2006), List.of(PREY_2006, PREY_2017))).contains(PREY_2006);
} }
@Test @Test
void candidates_OneYearOffIsStillTheSameGame() { void sure_NoYearAndTwoSameNames_IsEmpty() {
SpeedrunApiService.Game celeste = hit("celeste", "Celeste", 2018); assertThat(SpeedrunMatch.sure("Prey", Set.of(), List.of(PREY_2006, PREY_2017))).isEmpty();
assertThat(SpeedrunMatch.candidates("Celeste", Set.of(2017), List.of(celeste))).containsExactly(celeste);
assertThat(SpeedrunMatch.candidates("Celeste", Set.of(2015), List.of(celeste))).isEmpty();
} }
@Test @Test
void candidates_OnlyTheSameName() { void sure_NoYearAndOneSameName_IsThatGame() {
List<SpeedrunApiService.Game> hits = List.of(hit("snes", "Doom (SNES)", 1995), hit("doom3", "Doom 3", 2004), assertThat(ids("DOOM", Set.of(), List.of(hit("snes", "Doom (SNES)", 1995), hit("doom3", "Doom 3", 2004),
hit("d2016", "DOOM", 2016)); hit("d2016", "DOOM", 2016)))).containsExactly("d2016");
assertThat(SpeedrunMatch.candidates("DOOM", Set.of(), hits)).extracting(SpeedrunApiService.Game::id).containsExactly("d2016");
} }
@Test @Test
void candidates_AtMostTwo() { void sure_TwoGamesInTheYearWindow_IsEmpty() {
List<SpeedrunApiService.Game> hits = List.of(hit("a", "Tetris", null), hit("b", "Tetris", null), hit("c", "Tetris", null)); assertThat(SpeedrunMatch.sure("Tetris", Set.of(2000), List.of(hit("a", "Tetris", 2000), hit("b", "Tetris", 2001)))).isEmpty();
assertThat(SpeedrunMatch.candidates("Tetris", Set.of(), hits)).extracting(SpeedrunApiService.Game::id).containsExactly("a", "b");
} }
@Test @Test
void candidates_RefusesLinksOutsideSpeedrunCom() { void sure_OneYearOff_IsTheGame() {
assertThat(ids("Celeste", Set.of(2017), List.of(hit("celeste", "Celeste", 2018)))).containsExactly("celeste");
}
@Test
void sure_YearTwoOff_IsEmpty() {
assertThat(SpeedrunMatch.sure("Celeste", Set.of(2015), List.of(hit("celeste", "Celeste", 2018)))).isEmpty();
}
@Test
void sure_HitWithoutYear_OnlyWhenItIsTheOnlySameName() {
SpeedrunApiService.Game undated = hit("prey", "Prey", null);
assertThat(SpeedrunMatch.sure("Prey", Set.of(2017), List.of(undated))).contains(undated);
assertThat(SpeedrunMatch.sure("Prey", Set.of(2017), List.of(undated, hit("other", "Prey", 2006)))).isEmpty();
}
@Test
void sure_RomanNumeralsMatchDigits() {
assertThat(ids("Final Fantasy VII", Set.of(1997), List.of(hit("ff7", "Final Fantasy 7", 1997)))).containsExactly("ff7");
assertThat(SpeedrunMatch.sure("Mega Man X", Set.of(), List.of(hit("mm10", "Mega Man 10", 2010)))).isEmpty();
}
@Test
void sure_OtherNamesAreIgnored() {
assertThat(ids("Hades", Set.of(2020), List.of(hit("hades2", "Hades II", 2024), hit("hades", "Hades", 2020))))
.containsExactly("hades");
assertThat(SpeedrunMatch.sure("Hades", Set.of(2024), List.of(hit("hades2", "Hades II", 2024)))).isEmpty();
}
@Test
void sure_RefusesLinksOutsideSpeedrunCom() {
List<SpeedrunApiService.Game> hits = List.of( List<SpeedrunApiService.Game> hits = List.of(
new SpeedrunApiService.Game("a", "Celeste", 2018, "javascript:alert(1)"), new SpeedrunApiService.Game("a", "Celeste", 2018, "javascript:alert(1)"),
new SpeedrunApiService.Game("b", "Celeste", 2018, "https://www.speedrun.com.evil.example/x"), new SpeedrunApiService.Game("b", "Celeste", 2018, "https://www.speedrun.com.evil.example/x"),
new SpeedrunApiService.Game("c", "Celeste", 2018, null), new SpeedrunApiService.Game("c", "Celeste", 2018, null),
new SpeedrunApiService.Game("d", "Celeste", 2018, SpeedrunMatch.WEBLINK_PREFIX + "x".repeat(500))); new SpeedrunApiService.Game("d", "Celeste", 2018, SpeedrunMatch.WEBLINK_PREFIX + "x".repeat(500)));
assertThat(SpeedrunMatch.candidates("Celeste", Set.of(), hits)).isEmpty(); assertThat(SpeedrunMatch.sure("Celeste", Set.of(), hits)).isEmpty();
}
@Test
void withoutEditionSuffix_StripsKnownSuffixes() {
assertThat(SpeedrunMatch.withoutEditionSuffix("The Elder Scrolls V: Skyrim Special Edition"))
.contains("The Elder Scrolls V: Skyrim");
assertThat(SpeedrunMatch.withoutEditionSuffix("The Witcher 3: Wild Hunt - Game of the Year Edition"))
.contains("The Witcher 3: Wild Hunt");
assertThat(SpeedrunMatch.withoutEditionSuffix("Borderlands GOTY")).contains("Borderlands");
assertThat(SpeedrunMatch.withoutEditionSuffix("Death Stranding Director's Cut")).contains("Death Stranding");
assertThat(SpeedrunMatch.withoutEditionSuffix("DOOM Eternal Deluxe Edition")).contains("DOOM Eternal");
assertThat(SpeedrunMatch.withoutEditionSuffix("Mafia: Definitive Edition")).contains("Mafia");
}
@Test
void withoutEditionSuffix_StripsTheLeadingThe() {
assertThat(SpeedrunMatch.withoutEditionSuffix("Grand Theft Auto IV: The Complete Edition")).contains("Grand Theft Auto IV");
}
@Test
void withoutEditionSuffix_KeepsRemasteredAndPlainNames() {
assertThat(SpeedrunMatch.withoutEditionSuffix("Dark Souls Remastered")).isEmpty();
assertThat(SpeedrunMatch.withoutEditionSuffix("Prey")).isEmpty();
assertThat(SpeedrunMatch.withoutEditionSuffix("Gold Edition")).isEmpty();
} }
} }
@@ -1,7 +1,9 @@
package core_service.modules.library.service; package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService; import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.domain.GameEdition;
import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.repo.GameEditionRepository;
import core_service.modules.library.repo.GameSpeedrunRepository; import core_service.modules.library.repo.GameSpeedrunRepository;
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;
@@ -9,7 +11,7 @@ import core_service.modules.speedrun_adapter.service.SpeedrunApiService;
import core_service.shared.test.AbstractUnitTest; import core_service.shared.test.AbstractUnitTest;
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.mockito.InjectMocks; import org.mockito.InOrder;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.Spy; import org.mockito.Spy;
import org.springframework.core.task.SyncTaskExecutor; import org.springframework.core.task.SyncTaskExecutor;
@@ -29,6 +31,9 @@ import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -44,18 +49,23 @@ class SpeedrunServiceTest extends AbstractUnitTest {
@Mock @Mock
private GameSpeedrunRepository gameSpeedrunRepository; private GameSpeedrunRepository gameSpeedrunRepository;
@Mock @Mock
private GameEditionRepository gameEditionRepository;
@Mock
private SteamGameRepository steamGameRepository; private SteamGameRepository steamGameRepository;
@Mock @Mock
private UserGameRepository userGameRepository; private UserGameRepository userGameRepository;
@Spy @Spy
private TaskExecutor syncFollowUpExecutor = new SyncTaskExecutor(); private TaskExecutor syncFollowUpExecutor = new SyncTaskExecutor();
@InjectMocks @Spy
private TaskExecutor speedrunExecutor = new SyncTaskExecutor();
private SpeedrunService service; private SpeedrunService service;
private static final String LINK = "https://www.speedrun.com/prey2017"; private static final String LINK = "https://www.speedrun.com/prey2017";
@BeforeEach @BeforeEach
void setUp() { void setUp() {
service = new SpeedrunService(speedrunApiService, igdbApiService, gameSpeedrunRepository, gameEditionRepository,
steamGameRepository, userGameRepository, syncFollowUpExecutor, speedrunExecutor);
when(speedrunApiService.isEnabled()).thenReturn(true); when(speedrunApiService.isEnabled()).thenReturn(true);
} }
@@ -72,6 +82,23 @@ class SpeedrunServiceTest extends AbstractUnitTest {
}); });
} }
private void game(long appid, String name) {
when(steamGameRepository.findAllById(any())).thenReturn(List.of(new SteamGame(appid, name, null, Instant.EPOCH)));
}
private void edition(long appid, long igdbId, long sameGameId) {
GameEdition edition = mock(GameEdition.class);
lenient().when(edition.getAppid()).thenReturn(appid);
when(edition.getIgdbId()).thenReturn(igdbId);
when(edition.getSameGameId()).thenReturn(sameGameId);
when(gameEditionRepository.findAllById(any())).thenReturn(List.of(edition));
}
private void igdbYears(long appid, Integer... years) {
when(igdbApiService.isEnabled()).thenReturn(true);
when(igdbApiService.getReleaseYears(List.of(appid))).thenReturn(Optional.of(Map.of(appid, Set.of(years))));
}
private static SpeedrunApiService.Game hit(String id, String name, int year) { private static SpeedrunApiService.Game hit(String id, String name, int year) {
return new SpeedrunApiService.Game(id, name, year, "https://www.speedrun.com/" + id); return new SpeedrunApiService.Game(id, name, year, "https://www.speedrun.com/" + id);
} }
@@ -103,18 +130,128 @@ class SpeedrunServiceTest extends AbstractUnitTest {
} }
@Test @Test
void refresh_NoGameWithRuns_StoresAnEmptyRowAfterTwoCandidates() { void refresh_SureGameWithoutRuns_StoresNoLink() {
names(620); names(620);
when(speedrunApiService.findGames("Prey")).thenReturn(Optional.of(List.of( when(speedrunApiService.findGames("Prey")).thenReturn(Optional.of(List.of(hit("prey", "Prey", 2006))));
hit("a", "Prey", 2006), hit("b", "Prey", 2006), hit("c", "Prey", 2006)))); when(speedrunApiService.hasVerifiedRuns("prey")).thenReturn(Optional.of(false));
when(speedrunApiService.hasVerifiedRuns(anyString())).thenReturn(Optional.of(false));
service.refresh(List.of(620L)); service.refresh(List.of(620L));
verify(speedrunApiService, times(2)).hasVerifiedRuns(anyString()); verify(speedrunApiService, times(1)).hasVerifiedRuns(anyString());
verify(gameSpeedrunRepository).upsert(eq(620L), isNull(), isNull(), any()); verify(gameSpeedrunRepository).upsert(eq(620L), isNull(), isNull(), any());
} }
@Test
void refresh_SeveralSameNamesWithoutYear_StoresNoLinkWithoutAskingForRuns() {
names(620);
when(speedrunApiService.findGames("Prey")).thenReturn(Optional.of(List.of(
hit("prey", "Prey", 2006), hit("prey2017", "Prey (2017)", 2017))));
service.refresh(List.of(620L));
verify(speedrunApiService, never()).hasVerifiedRuns(anyString());
verify(gameSpeedrunRepository).upsert(eq(620L), isNull(), isNull(), any());
}
@Test
void refresh_EditionMisses_ParentNameHits() {
game(620, "The Elder Scrolls V: Skyrim Special Edition");
igdbYears(620, 2016);
edition(620, 100, 50);
when(igdbApiService.getNamesAndYears(any())).thenReturn(Optional.of(Map.of(
50L, new IgdbApiService.NameAndYear("The Elder Scrolls V: Skyrim", 2011))));
when(speedrunApiService.findGames("The Elder Scrolls V: Skyrim Special Edition")).thenReturn(Optional.of(List.of()));
when(speedrunApiService.findGames("The Elder Scrolls V: Skyrim")).thenReturn(Optional.of(List.of(
new SpeedrunApiService.Game("skyrim", "The Elder Scrolls V: Skyrim", 2011, "https://www.speedrun.com/skyrim"))));
when(speedrunApiService.hasVerifiedRuns("skyrim")).thenReturn(Optional.of(true));
service.refresh(List.of(620L));
verify(gameSpeedrunRepository).upsert(eq(620L), eq("skyrim"), eq("https://www.speedrun.com/skyrim"), any());
}
@Test
void refresh_NoParent_TriesTheNameWithoutEditionSuffix() {
game(620, "DOOM Eternal Deluxe Edition");
when(speedrunApiService.findGames("DOOM Eternal Deluxe Edition")).thenReturn(Optional.of(List.of()));
when(speedrunApiService.findGames("DOOM Eternal")).thenReturn(Optional.of(List.of(hit("doome", "DOOM Eternal", 2020))));
when(speedrunApiService.hasVerifiedRuns("doome")).thenReturn(Optional.of(true));
service.refresh(List.of(620L));
verify(gameSpeedrunRepository).upsert(eq(620L), eq("doome"), eq("https://www.speedrun.com/doome"), any());
}
@Test
void refresh_RemakeHasNoParent_DoesNotUseTheOriginal() {
game(620, "Resident Evil 2");
igdbYears(620, 2019);
edition(620, 100, 100);
when(speedrunApiService.findGames("Resident Evil 2")).thenReturn(Optional.of(List.of(hit("re2old", "Resident Evil 2", 1998))));
service.refresh(List.of(620L));
verify(speedrunApiService, times(1)).findGames(anyString());
verify(igdbApiService, never()).getNamesAndYears(any());
verify(gameSpeedrunRepository).upsert(eq(620L), isNull(), isNull(), any());
}
@Test
void refresh_RemakeWithEditionSuffix_DoesNotGetTheOriginal() {
game(620, "Mafia: Definitive Edition");
igdbYears(620, 2020);
edition(620, 100, 100);
when(speedrunApiService.findGames("Mafia: Definitive Edition")).thenReturn(Optional.of(List.of(hit("mafia", "Mafia", 2002))));
lenient().when(speedrunApiService.findGames("Mafia")).thenReturn(Optional.of(List.of(hit("mafia", "Mafia", 2002))));
lenient().when(speedrunApiService.hasVerifiedRuns("mafia")).thenReturn(Optional.of(true));
service.refresh(List.of(620L));
verify(gameSpeedrunRepository).upsert(eq(620L), isNull(), isNull(), any());
}
@Test
void refresh_EditionOfAMainGame_TriesTheNameWithoutEditionSuffix() {
game(620, "DOOM Eternal Deluxe Edition");
igdbYears(620, 2020);
edition(620, 100, 100);
when(speedrunApiService.findGames("DOOM Eternal Deluxe Edition")).thenReturn(Optional.of(List.of()));
lenient().when(speedrunApiService.findGames("DOOM Eternal")).thenReturn(Optional.of(List.of(hit("doome", "DOOM Eternal", 2020))));
lenient().when(speedrunApiService.hasVerifiedRuns("doome")).thenReturn(Optional.of(true));
service.refresh(List.of(620L));
verify(gameSpeedrunRepository).upsert(eq(620L), eq("doome"), eq("https://www.speedrun.com/doome"), any());
}
@Test
void refresh_ParentSearchFails_StoresNothing() {
game(620, "The Elder Scrolls V: Skyrim Special Edition");
igdbYears(620, 2016);
edition(620, 100, 50);
when(igdbApiService.getNamesAndYears(any())).thenReturn(Optional.of(Map.of(
50L, new IgdbApiService.NameAndYear("The Elder Scrolls V: Skyrim", 2011))));
when(speedrunApiService.findGames("The Elder Scrolls V: Skyrim Special Edition")).thenReturn(Optional.of(List.of()));
when(speedrunApiService.findGames("The Elder Scrolls V: Skyrim")).thenReturn(Optional.empty());
service.refresh(List.of(620L));
verify(gameSpeedrunRepository, never()).upsert(any(), any(), any(), any());
}
@Test
void refresh_ParentNamesIgdbFailed_StoresNothing() {
game(620, "The Elder Scrolls V: Skyrim Special Edition");
igdbYears(620, 2016);
edition(620, 100, 50);
when(igdbApiService.getNamesAndYears(any())).thenReturn(Optional.empty());
service.refresh(List.of(620L));
verify(speedrunApiService, never()).findGames(anyString());
verify(gameSpeedrunRepository, never()).upsert(any(), any(), any(), any());
}
@Test @Test
void refresh_FailedCall_StoresNothing() { void refresh_FailedCall_StoresNothing() {
names(620); names(620);
@@ -228,6 +365,59 @@ class SpeedrunServiceTest extends AbstractUnitTest {
verifyNoInteractions(gameSpeedrunRepository, steamGameRepository); verifyNoInteractions(gameSpeedrunRepository, steamGameRepository);
} }
@Test
void recheckMarked_QueuesTheMarkedGames() {
names(1, 2);
when(gameSpeedrunRepository.findAppidsToRecheck(Instant.EPOCH)).thenReturn(List.of(1L, 2L));
when(speedrunApiService.findGames("Prey")).thenReturn(Optional.of(List.of()));
service.recheckMarked();
verify(gameSpeedrunRepository).upsert(eq(1L), isNull(), isNull(), any());
verify(gameSpeedrunRepository).upsert(eq(2L), isNull(), isNull(), any());
}
@Test
void recheckMarked_Disabled_DoesNothing() {
when(speedrunApiService.isEnabled()).thenReturn(false);
service.recheckMarked();
verifyNoInteractions(gameSpeedrunRepository, steamGameRepository);
}
@Test
void recheckMarked_UserRequestQueuedAfterManyMarkedGames_IsServedNext() {
long[] marked = LongStream.rangeClosed(1, 30).toArray();
names(LongStream.concat(LongStream.of(999), LongStream.of(marked)).toArray());
when(gameSpeedrunRepository.findAppidsToRecheck(Instant.EPOCH)).thenReturn(LongStream.of(marked).boxed().toList());
when(speedrunApiService.findGames("Prey")).thenAnswer(call -> {
// The first marked game is being asked when a user asks for another one.
service.refresh(List.of(999L));
return Optional.of(List.of());
}).thenReturn(Optional.of(List.of()));
service.recheckMarked();
InOrder order = inOrder(gameSpeedrunRepository);
order.verify(gameSpeedrunRepository).upsert(eq(1L), isNull(), isNull(), any());
order.verify(gameSpeedrunRepository).upsert(eq(999L), isNull(), isNull(), any());
order.verify(gameSpeedrunRepository).upsert(eq(2L), isNull(), isNull(), any());
verify(gameSpeedrunRepository, times(31)).upsert(any(), isNull(), isNull(), any());
}
@Test
void refresh_OnlyQueuesAndLeavesTheWorkToTheSpeedrunExecutor() {
TaskExecutor waiting = mock(TaskExecutor.class);
SpeedrunService queued = new SpeedrunService(speedrunApiService, igdbApiService, gameSpeedrunRepository,
gameEditionRepository, steamGameRepository, userGameRepository, syncFollowUpExecutor, waiting);
queued.refresh(List.of(620L));
verify(waiting).execute(any());
verifyNoInteractions(steamGameRepository);
}
@Test @Test
void onLibrarySynced_RefreshesTheBacklogOnTheListenerThread() { void onLibrarySynced_RefreshesTheBacklogOnTheListenerThread() {
when(userGameRepository.findBacklogAppids(7L)).thenReturn(List.of()); when(userGameRepository.findBacklogAppids(7L)).thenReturn(List.of());
@@ -53,6 +53,13 @@ public abstract class AbstractIntegrationTest {
@TestBean(name = BackgroundPools.SYNC_FOLLOW_UP) @TestBean(name = BackgroundPools.SYNC_FOLLOW_UP)
private TaskExecutor syncFollowUpExecutor; private TaskExecutor syncFollowUpExecutor;
@TestBean(name = BackgroundPools.SPEEDRUN)
private TaskExecutor speedrunExecutor;
static TaskExecutor speedrunExecutor() {
return new SyncTaskExecutor();
}
static TaskExecutor applicationTaskExecutor() { static TaskExecutor applicationTaskExecutor() {
return new SyncTaskExecutor(); return new SyncTaskExecutor();
} }