feat(library): finish games from the ending achievement or suggest it
Build, Test & Push Core Service / quality-check (push) Successful in 58s
Build, Test & Push Core Service / docker (push) Successful in 1m23s

This commit is contained in:
lobo committed 2026-09-26 19:00:57 +02:00
1 parent c2d17d0bc7
commit b959542e23
31 files changed
+1072 -34

No files matched your search

@@ -4,6 +4,7 @@ import core_service.modules.library.api.dto.AchievementSummaryDto;
import core_service.modules.library.api.dto.ActivityDto;
import core_service.modules.library.api.dto.BacklogEntryDto;
import core_service.modules.library.api.dto.BrokenImageReport;
import core_service.modules.library.api.dto.FinishedHintDismissal;
import core_service.modules.library.api.dto.GameDto;
import core_service.modules.library.api.dto.HiddenUpdateDto;
import core_service.modules.library.api.dto.LibrarySummaryDto;
@@ -177,6 +178,17 @@ public class LibraryController {
};
}
@PutMapping("/games/{appid}/finished-hint")
public ResponseEntity<?> dismissFinishedHint(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("appid") Long appid,
@Valid @RequestBody FinishedHintDismissal dismissal) {
if (!backlogService.dismissFinishedHint(principal.getIdAsLong(), appid, dismissal.dismissed())) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library"));
}
return ResponseEntity.noContent().build();
}
@PutMapping("/games/{appid}/status")
public ResponseEntity<?> updateStatus(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("appid") Long appid,
@@ -0,0 +1,6 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.FinishedHintType;
import jakarta.validation.constraints.NotNull;
public record FinishedHintDismissal(@NotNull FinishedHintType dismissed) {}
@@ -0,0 +1,8 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.FinishedHintType;
/**
* @param achievement the display name of the ending achievement; null for PLAYTIME
*/
public record FinishedHintDto(FinishedHintType type, String achievement) {}
@@ -33,7 +33,9 @@ public record GameDetailDto(
// null until a check finds a speedrun.com game with verified runs
String speedrunUrl,
// see GameDto#hidden
boolean hidden
boolean hidden,
// null when there is nothing to ask
FinishedHintDto finishedHint
) {
/**
@@ -0,0 +1,75 @@
package core_service.modules.library.domain;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.regex.Pattern;
/**
* The one achievement of a game that means "the story is over". Measured on production 2026-09-26 (ROADMAP item 9):
* 12 story ends in 35 games with a keyword hit, no known wrong hit. The early-step words and the names that are no
* ending were added after review, without a new measurement: they can only cost a hint. Steam sends the schema texts
* in English.
*
* @param certain the text says it, and no other achievement could be the ending: only then Core sets FINISHED by
* itself. A name-only match or a pick among several stays a hint.
*/
public record EndingAchievement(GameAchievementSchema achievement, boolean certain) {
static final double MIN_PERCENT = 1;
// Tutorials and first steps are unlocked by 90 to 99 % of the players.
static final double MAX_PERCENT = 80;
// More candidates mean a game without one end, e.g. Civilization: "beat the game as <leader>" for every leader.
static final int MAX_CANDIDATES = 3;
private static final int FLAGS = Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE;
private static final Pattern ENDING_TEXT = Pattern.compile(
"\\b(complete|completed|finish|finished|beat|clear|cleared)\\b.{0,20}\\b(the )?(main )?(game|story|campaign)\\b"
+ "|\\breach(ed)? .{0,40}\\bending\\b|\\b(roll|see|watch)(ed)? the credits\\b|\\bcredits roll\\b", FLAGS);
// A harder or narrower variant of the ending, or a step on the way to it.
private static final Pattern VARIANT = Pattern.compile(
"tutorial|session|\\d+\\s*(times|hp|%|weeks?|days?|hours?|minutes?)|\\bwith(out)?\\b|\\bunder\\b"
+ "|\\bno (damage|hints?|deaths?)\\b"
+ "|\\bon (easy|normal|hard|harder|hardest|expert|nightmare|insane|veteran|legendary|apocalyptic|brutal)\\b"
+ "|co-?op|after entering|twice|again|new game|\\bng\\+|on the \\w+ difficulty|\\bon an? \\w+ map"
+ "|prologue|chapter|\\bact\\b|\\blevels?\\b|mission|episode|\\bfirst\\b|\\ba game\\b", FLAGS);
// Steam sends no description for a hidden achievement; then the name alone must say "end".
private static final Pattern ENDING_NAME = Pattern.compile(
"\\b(the )?end(ing)?\\b|\\bconclusion\\b|\\bepilogue\\b|\\bfinale\\b", FLAGS);
private static final Pattern NOT_ENDING_NAME = Pattern.compile("beginning|dead end|\\bend(s)? here\\b", FLAGS);
/**
* @param schema all achievements of one game
* @return the ending achievement; empty when none fits, when the rates are unknown, or for an open game
*/
public static Optional<EndingAchievement> of(List<GameAchievementSchema> schema) {
// Counted without the rate window: rare siblings of an open game still show that it has no one end.
long endingShaped = schema.stream().filter(EndingAchievement::hasEndingShape).count();
if (endingShaped > MAX_CANDIDATES) return Optional.empty();
// The easiest variant ("on any difficulty") has the highest rate.
return schema.stream().filter(EndingAchievement::isCandidate)
.max(Comparator.comparingDouble(GameAchievementSchema::getGlobalPercent))
.map(best -> new EndingAchievement(best, endingShaped == 1 && matchesText(best)));
}
static boolean isCandidate(GameAchievementSchema achievement) {
Double percent = achievement.getGlobalPercent();
return percent != null && percent >= MIN_PERCENT && percent <= MAX_PERCENT && hasEndingShape(achievement);
}
private static boolean hasEndingShape(GameAchievementSchema achievement) {
if (matchesText(achievement)) return true;
String name = nonNull(achievement.getDisplayName());
return nonNull(achievement.getDescription()).isBlank()
&& ENDING_NAME.matcher(name).find() && !NOT_ENDING_NAME.matcher(name).find();
}
private static boolean matchesText(GameAchievementSchema achievement) {
String text = nonNull(achievement.getDisplayName()) + " " + nonNull(achievement.getDescription());
return ENDING_TEXT.matcher(text).find() && !VARIANT.matcher(text).find();
}
private static String nonNull(String value) {
return value == null ? "" : value;
}
}
@@ -0,0 +1,52 @@
package core_service.modules.library.domain;
import java.util.List;
import java.util.Optional;
import java.util.Set;
/**
* The "finished?" hint on the game page (ROADMAP item 9). Only a hint: the user decides.
*
* @param achievement the display name of the ending achievement; null for {@link FinishedHintType#PLAYTIME}
*/
public record FinishedHint(FinishedHintType type, String achievement) {
// Measured: 4 of 5 finished games reached 0.9 x IGDB's normal time to beat.
static final double PLAYTIME_SHARE = 0.9;
/**
* @param schema all achievements of the game, with their global rates; empty for a game added by hand
* @param statsVisible false when Steam hides the user's achievements: then nothing is known about the ending
* @param normallyMinutes IGDB's normal time to beat; null when unknown
* @param endless see {@link Mood#endless}; null when IGDB does not know the game
* @param dismissed the hint the user answered with "Not yet"; null for none
*/
public record Facts(GameStatus status, GameSource source, List<GameAchievementSchema> schema, Set<String> unlocked,
boolean statsVisible, int playtimeMinutes, Integer normallyMinutes, Boolean endless,
FinishedHintType dismissed) {}
public static Optional<FinishedHint> of(Facts facts) {
if (facts.status() != GameStatus.BACKLOG && facts.status() != GameStatus.PLAYING) return Optional.empty();
if (facts.dismissed() == FinishedHintType.ENDING) return Optional.empty();
if (facts.statsVisible() && !facts.schema().isEmpty() && facts.unlocked().size() >= facts.schema().size()) {
// Every achievement unlocked: the 100 % hint asks instead.
return Optional.empty();
}
if (facts.source() == GameSource.STEAM && facts.statsVisible()) {
Optional<GameAchievementSchema> ending = EndingAchievement.of(facts.schema()).map(EndingAchievement::achievement);
if (ending.isPresent()) {
// A locked ending says "not finished" better than any playtime.
return facts.unlocked().contains(ending.get().getApiName())
? Optional.of(new FinishedHint(FinishedHintType.ENDING, ending.get().getDisplayName()))
: Optional.empty();
}
}
if (facts.dismissed() == FinishedHintType.PLAYTIME) return Optional.empty();
if (facts.normallyMinutes() == null || facts.normallyMinutes() <= 0 || !Boolean.FALSE.equals(facts.endless())) {
return Optional.empty();
}
return facts.playtimeMinutes() >= PLAYTIME_SHARE * facts.normallyMinutes()
? Optional.of(new FinishedHint(FinishedHintType.PLAYTIME, null))
: Optional.empty();
}
}
@@ -0,0 +1,12 @@
package core_service.modules.library.domain;
/**
* Why Core thinks a game in the backlog is finished. ENDING is the stronger sign: a user who answered "Not yet" to
* PLAYTIME still gets ENDING later.
*/
public enum FinishedHintType {
// The user unlocked the achievement for the story end, see EndingAchievement.
ENDING,
// No ending achievement is known, and the user played longer than most players need.
PLAYTIME
}
@@ -41,6 +41,10 @@ public class GameAchievementSchema {
@Column(name = "hidden", nullable = false)
private boolean hidden;
/** Share of all Steam players who unlocked it, in percent; null when Steam has not told yet. */
@Column(name = "global_percent")
private Double globalPercent;
public GameAchievementSchema(Long appid, String apiName, String displayName, String description,
String iconUrl, String iconGrayUrl, boolean hidden) {
this.appid = appid;
@@ -32,4 +32,8 @@ public class GameMoods {
@Column(name = "checked_at", nullable = false)
private Instant checkedAt;
/** See {@link Mood#endless}; null when IGDB has no tags for the game, or for a row from before this column. */
@Column(name = "endless")
private Boolean endless;
}
@@ -25,6 +25,9 @@ public enum Mood {
private static final Set<String> ACTION_GENRES = Set.of("shooter", "fighting", "hack and slash/beat 'em up", "platform");
private static final Set<String> ACTION_THEMES = Set.of("action");
private static final Set<String> SCARY_THEMES = Set.of("horror");
private static final Set<String> ENDLESS_GENRES = Set.of("simulator");
private static final Set<String> ENDLESS_THEMES = Set.of("sandbox", "survival", "4x (explore, expand, exploit, and exterminate)");
private static final Set<String> ENDLESS_MODES = Set.of("massively multiplayer online (mmo)");
public static Set<Mood> of(Collection<String> genres, Collection<String> themes, Collection<String> modes) {
Set<String> genre = lowerCase(genres);
@@ -42,6 +45,15 @@ public enum Mood {
return moods;
}
/**
* A game without an end, where a long playtime does not mean "finished". Strategy is no sign: Dawn of War II has
* a campaign.
*/
public static boolean endless(Collection<String> genres, Collection<String> themes, Collection<String> modes) {
return anyIn(lowerCase(genres), ENDLESS_GENRES) || anyIn(lowerCase(themes), ENDLESS_THEMES)
|| anyIn(lowerCase(modes), ENDLESS_MODES);
}
private static Set<String> lowerCase(Collection<String> names) {
return names.stream().map(name -> name.trim().toLowerCase(Locale.ROOT)).collect(Collectors.toSet());
}
@@ -48,6 +48,15 @@ public class UserGame {
@Column(name = "removed_at")
private Instant removedAt;
/** True once the user chose the status; until then Core may set FINISHED by itself. */
@Column(name = "status_set_by_user", nullable = false)
private boolean statusSetByUser;
/** The finished hint the user answered with "Not yet"; see {@link FinishedHintType}. */
@Enumerated(EnumType.STRING)
@Column(name = "finished_hint_dismissed", length = 16)
private FinishedHintType finishedHintDismissed;
/** Set for a Steam game the user hid; the sync never changes it. */
@Column(name = "hidden_at")
private Instant hiddenAt;
@@ -89,6 +98,7 @@ public class UserGame {
game.source = GameSource.MANUAL;
game.platform = platform;
game.status = status;
game.statusSetByUser = true;
return game;
}
}
@@ -22,8 +22,11 @@ public interface GameMoodsRepository extends JpaRepository<GameMoods, Long> {
// An upsert, not save(): two users' syncs can store the same new game at the same time.
@Modifying
@Transactional
@Query(value = "insert into game_mood (appid, moods, checked_at) values (:appid, cast(:moods as varchar(16)[]), :checkedAt) " +
"on conflict (appid) do update set moods = excluded.moods, checked_at = excluded.checked_at",
@Query(value = "insert into game_mood (appid, moods, endless, checked_at) " +
"values (:appid, cast(:moods as varchar(16)[]), :endless, :checkedAt) " +
"on conflict (appid) do update set moods = excluded.moods, endless = excluded.endless, " +
"checked_at = excluded.checked_at",
nativeQuery = true)
void upsert(@Param("appid") Long appid, @Param("moods") String[] moods, @Param("checkedAt") Instant checkedAt);
void upsert(@Param("appid") Long appid, @Param("moods") String[] moods, @Param("endless") Boolean endless,
@Param("checkedAt") Instant checkedAt);
}
@@ -14,6 +14,7 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.ArrayList;
@@ -189,6 +190,23 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
return "%" + escaped + "%";
}
/**
* Sets FINISHED for a listed Steam game whose status the user never chose and whose finished hint they never
* answered with "Not yet". One statement, so a status the user picks at the same time always wins.
*
* @return 1 when the status changed
*/
// Clears the persistence context: with open-in-view, a later findById in the same request would see the old status.
@Modifying(clearAutomatically = true)
@Transactional
@Query("update UserGame ug set ug.status = core_service.modules.library.domain.GameStatus.FINISHED " +
"where ug.userId = :userId and ug.appid = :appid and ug.removedAt is null and ug.hiddenAt is null " +
"and ug.statusSetByUser = false and ug.finishedHintDismissed is null " +
"and ug.source = core_service.modules.library.domain.GameSource.STEAM " +
"and ug.status in (core_service.modules.library.domain.GameStatus.BACKLOG, " +
"core_service.modules.library.domain.GameStatus.PLAYING)")
int finishIfUntouched(@Param("userId") Long userId, @Param("appid") Long appid);
/**
* Removes the Steam games the last sync did not see. Games added by hand never come from Steam and stay.
*/
@@ -1,8 +1,10 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.GameAchievementSchema;
import core_service.modules.library.domain.EndingAchievement;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievement;
import core_service.modules.library.domain.UserGameAchievementMeta;
@@ -26,6 +28,7 @@ import org.springframework.transaction.support.TransactionTemplate;
import java.time.Duration;
import java.time.Instant;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -121,17 +124,35 @@ public class AchievementSyncService {
public void refreshSchemaIfStale(Long appid) {
Optional<GameAchievementSchemaMeta> existing = schemaMetaRepository.findById(appid);
if (existing.isEmpty() || isStale(existing.get())) {
refreshSchema(appid);
refreshSchema(appid, false, false);
}
}
/**
* Sets FINISHED when the user unlocked the ending achievement (see {@link EndingAchievement}) of a Steam game whose
* status they never chose. Uses stored rows only.
*
* @return true when the status changed
*/
public boolean finishIfEndingUnlocked(Long userId, Long appid) {
Optional<EndingAchievement> ending = EndingAchievement.of(schemaRepository.findByAppid(appid))
.filter(EndingAchievement::certain);
if (ending.isEmpty()) return false;
String apiName = ending.get().achievement().getApiName();
boolean unlocked = userAchievementRepository.findByUserIdAndAppid(userId, appid).stream()
.anyMatch(a -> a.isUnlocked() && a.getApiName().equals(apiName));
if (!unlocked || userGameRepository.finishIfUntouched(userId, appid) == 0) return false;
log.info("User {} finished appid {}: ending achievement {} unlocked", userId, appid, apiName);
return true;
}
/**
* Ignores the schema TTL, e.g. after an icon did not load. An empty answer changes nothing: storing it would
* hide the achievements for the whole TTL.
*/
public void refreshSchemaNow(Long appid) throws InterruptedException {
pause();
refreshSchema(appid, true);
refreshSchema(appid, true, true);
}
public void refreshUserIfStale(Long userId, String steamId, Long appid, int playtimeMinutes, Duration ttl) {
@@ -171,6 +192,11 @@ public class AchievementSyncService {
continue;
}
errorsInARow = 0;
if (!game.isStatusSetByUser()
&& (game.getStatus() == GameStatus.BACKLOG || game.getStatus() == GameStatus.PLAYING)) {
// Also for an unchanged playtime: the rates can come later than the unlock.
finishIfEndingUnlocked(userId, game.getAppid());
}
if (outcome == Outcome.VISIBLE) {
anyVisible = true;
hiddenInARow = 0;
@@ -187,7 +213,7 @@ public class AchievementSyncService {
boolean hasSchema;
if (schema == null || isStale(schema)) {
pause();
refreshSchema(appid);
refreshSchema(appid, false, true);
hasSchema = schemaMetaRepository.findById(appid).map(GameAchievementSchemaMeta::isHasSchema).orElse(false);
} else {
hasSchema = schema.isHasSchema();
@@ -200,29 +226,58 @@ public class AchievementSyncService {
return refreshUser(userId, steamId, appid, playtime) ? Outcome.VISIBLE : Outcome.HIDDEN;
}
private void refreshSchema(Long appid) {
refreshSchema(appid, false);
}
private void refreshSchema(Long appid, boolean keepOnEmpty) {
/**
* @param paced true for the background runs: the rates call waits for its own Steam slot
*/
private void refreshSchema(Long appid, boolean keepOnEmpty, boolean paced) {
Optional<SteamSchemaForGameResponse.Game> schema = steamApiService.getSchemaForGame(appid);
List<SteamSchemaForGameResponse.Achievement> achievements = schema
.map(SteamSchemaForGameResponse.Game::availableGameStats)
.map(SteamSchemaForGameResponse.AvailableGameStats::achievements)
.orElse(List.of());
if (keepOnEmpty && achievements.isEmpty()) return;
Map<String, Double> percents = achievements.isEmpty() ? Map.of() : fetchPercents(appid, paced);
writeLocked(0L, appid, () -> {
if (!achievements.isEmpty()) {
// A failed rates call keeps the rates of the last refresh.
Map<String, Double> known = percents.isEmpty() ? storedPercents(appid) : percents;
schemaRepository.deleteByAppid(appid);
schemaRepository.saveAll(achievements.stream()
.map(a -> new GameAchievementSchema(appid, a.name(), a.displayName(), a.description(),
a.icon(), a.iconGray(), a.hidden() != null && a.hidden() == 1))
.map(a -> {
GameAchievementSchema row = new GameAchievementSchema(appid, a.name(), a.displayName(),
a.description(), a.icon(), a.iconGray(), a.hidden() != null && a.hidden() == 1);
row.setGlobalPercent(known.get(a.name()));
return row;
})
.toList());
}
schemaMetaRepository.save(new GameAchievementSchemaMeta(appid, achievements.size(), !achievements.isEmpty(), Instant.now()));
});
}
private Map<String, Double> fetchPercents(Long appid, boolean paced) {
try {
if (paced) pause();
return steamApiService.getGlobalAchievementPercentages(appid);
} catch (InterruptedException e) {
// The run stops at its next pause.
Thread.currentThread().interrupt();
return Map.of();
} catch (Exception e) {
// Also a full rate limiter: the schema is still stored, with the rates of the last refresh.
log.warn("Global achievement rates of appid={} not fetched: {}", appid, e.getMessage());
return Map.of();
}
}
private Map<String, Double> storedPercents(Long appid) {
Map<String, Double> percents = new HashMap<>();
for (GameAchievementSchema row : schemaRepository.findByAppid(appid)) {
if (row.getGlobalPercent() != null) percents.put(row.getApiName(), row.getGlobalPercent());
}
return percents;
}
/**
* @return false when Steam hides the achievements of this user
*/
@@ -1,6 +1,7 @@
package core_service.modules.library.service;
import core_service.modules.library.api.dto.BacklogEntryDto;
import core_service.modules.library.domain.FinishedHintType;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.UserGame;
@@ -28,6 +29,7 @@ public class BacklogService {
.filter(game -> game.getRemovedAt() == null)
.map(game -> {
game.setStatus(entry.status());
game.setStatusSetByUser(true);
game.setRating(entry.rating());
game.setNotes(entry.notes() == null || entry.notes().isBlank() ? null : entry.notes());
game.setPriority(entry.priority());
@@ -46,6 +48,7 @@ public class BacklogService {
.filter(game -> game.getRemovedAt() == null)
.map(game -> {
game.setStatus(status);
game.setStatusSetByUser(true);
return toDto(game);
});
}
@@ -72,6 +75,22 @@ public class BacklogService {
return HideResult.DONE;
}
/**
* "Not yet" on the finished hint. A dismissed ENDING also covers PLAYTIME, so it is never replaced by PLAYTIME.
*
* @return false when the game is not in the user's active library
*/
@Transactional
public boolean dismissFinishedHint(Long userId, Long appid, FinishedHintType dismissed) {
Optional<UserGame> game = userGameRepository.findById(new UserGameId(userId, appid))
.filter(found -> found.getRemovedAt() == null);
if (game.isEmpty()) return false;
if (game.get().getFinishedHintDismissed() != FinishedHintType.ENDING) {
game.get().setFinishedHintDismissed(dismissed);
}
return true;
}
private static BacklogEntryDto toDto(UserGame game) {
return new BacklogEntryDto(game.getStatus(), game.getRating(), game.getNotes(), game.getPriority());
}
@@ -1,10 +1,14 @@
package core_service.modules.library.service;
import core_service.modules.library.api.dto.AchievementDto;
import core_service.modules.library.api.dto.FinishedHintDto;
import core_service.modules.library.api.dto.GameDetailDto;
import core_service.modules.library.domain.AchievementProgress;
import core_service.modules.library.domain.GameAchievementSchema;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.FinishedHint;
import core_service.modules.library.domain.GameMoods;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameSpeedrun;
import core_service.modules.library.domain.SteamGame;
@@ -13,6 +17,7 @@ import core_service.modules.library.domain.UserGameAchievement;
import core_service.modules.library.domain.UserGameAchievementMeta;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameMoodsRepository;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.GameAchievementSchemaRepository;
import core_service.modules.library.repo.GameSpeedrunRepository;
@@ -30,6 +35,8 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@@ -48,6 +55,7 @@ public class GameDetailService {
private final MoodService moodService;
private final GameSpeedrunRepository gameSpeedrunRepository;
private final SpeedrunService speedrunService;
private final GameMoodsRepository gameMoodsRepository;
@Value("${app.library.achievement-ttl-hours:1}")
private long achievementTtlHours;
@@ -100,6 +108,10 @@ public class GameDetailService {
}
}
if (!hidden && !userGame.isStatusSetByUser() && achievementSyncService.finishIfEndingUnlocked(userId, appid)) {
userGame = userGameRepository.findById(new UserGameId(userId, appid)).orElse(userGame);
}
UserGameAchievementMeta userMeta = userAchievementMetaRepository.findByUserIdAndAppid(userId, appid).orElse(null);
boolean statsVisible = userMeta == null || userMeta.isStatsVisible();
int unlocked = userMeta != null ? userMeta.getAchievementsUnlocked() : 0;
@@ -128,11 +140,26 @@ public class GameDetailService {
.toList();
return Optional.of(buildDto(userGame, meta, statsVisible, schemaList.size(),
AchievementProgress.capped(unlocked, schemaList.size()), achievementDtos));
AchievementProgress.capped(unlocked, schemaList.size()), achievementDtos, schemaList));
}
private GameDetailDto buildDto(UserGame userGame, SteamGame meta, boolean statsVisible,
int total, int unlocked, List<AchievementDto> achievements) {
return buildDto(userGame, meta, statsVisible, total, unlocked, achievements, List.of());
}
private GameDetailDto buildDto(UserGame userGame, SteamGame meta, boolean statsVisible, int total, int unlocked,
List<AchievementDto> achievements, List<GameAchievementSchema> schema) {
Optional<GameTimeToBeat> timeToBeat = gameTimeToBeatRepository.findById(userGame.getAppid());
Set<String> unlockedNames = achievements.stream().filter(AchievementDto::unlocked).map(AchievementDto::apiName)
.collect(Collectors.toSet());
FinishedHintDto finishedHint = FinishedHint.of(new FinishedHint.Facts(userGame.getStatus(), userGame.getSource(),
schema, unlockedNames, statsVisible, userGame.getPlaytimeMinutes(),
timeToBeat.map(GameTimeToBeat::getNormallyMinutes).orElse(null),
gameMoodsRepository.findById(userGame.getAppid()).map(GameMoods::getEndless).orElse(null),
userGame.getFinishedHintDismissed()))
.map(hint -> new FinishedHintDto(hint.type(), hint.achievement()))
.orElse(null);
boolean hasAchievements = total > 0;
int remaining = Math.max(0, total - unlocked);
double pct = total == 0 ? 0.0 : (unlocked * 100.0 / total);
@@ -155,14 +182,15 @@ public class GameDetailService {
remaining,
Math.round(pct * 10.0) / 10.0,
achievements,
gameTimeToBeatRepository.findById(userGame.getAppid())
timeToBeat
.map(t -> new GameDetailDto.TimeToBeatDto(t.getHastilyMinutes(), t.getNormallyMinutes(), t.getCompletelyMinutes()))
.orElse(null),
userGame.getSource(),
userGame.getPlatform(),
meta != null ? meta.getImageUrl() : null,
gameSpeedrunRepository.findById(userGame.getAppid()).map(GameSpeedrun::getWeblink).orElse(null),
userGame.getHiddenAt() != null
userGame.getHiddenAt() != null,
finishedHint
);
}
}
@@ -167,6 +167,7 @@ public class ManualGameService {
revived.setSource(GameSource.MANUAL);
revived.setPlatform(platform);
revived.setStatus(status);
revived.setStatusSetByUser(true);
revived.setPlaytimeMinutes(playtimeMinutes);
revived.setPlaytime2Weeks(0);
revived.setLastSeenAt(now);
@@ -72,7 +72,8 @@ public class MoodService {
for (Long appid : answered) {
IgdbApiService.GameTags gameTags = tags.get(appid);
Set<Mood> moods = gameTags == null ? Set.of() : Mood.of(gameTags.genres(), gameTags.themes(), gameTags.modes());
gameMoodsRepository.upsert(appid, moods.stream().map(Mood::name).sorted().toArray(String[]::new), now);
Boolean endless = gameTags == null ? null : Mood.endless(gameTags.genres(), gameTags.themes(), gameTags.modes());
gameMoodsRepository.upsert(appid, moods.stream().map(Mood::name).sorted().toArray(String[]::new), endless, now);
}
}
@@ -0,0 +1,26 @@
package core_service.modules.steam_adapter.api.dto.response;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public record SteamGlobalAchievementPercentagesResponse(
@JsonProperty("achievementpercentages") AchievementPercentages achievementPercentages
) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record AchievementPercentages(
@JsonProperty("achievements") List<Achievement> achievements
) {}
/**
* @param percent Steam sends it as a string ("74.1") for most apps and as a number for some
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record Achievement(
@JsonProperty("name") String name,
@JsonProperty("percent") Double percent
) {}
}
@@ -1,5 +1,6 @@
package core_service.modules.steam_adapter.service;
import core_service.modules.steam_adapter.api.dto.response.SteamGlobalAchievementPercentagesResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerSummaryResponse;
@@ -308,6 +309,37 @@ public class SteamApiService {
return Optional.empty();
}
/**
* Share of all players who unlocked each achievement, by api name. Needs no key. Empty on any failure: the rates
* are only a hint, and the caller keeps what it stored before.
*/
@RateLimiter(name = "steamApi")
public Map<String, Double> getGlobalAchievementPercentages(long appid) {
try {
SteamGlobalAchievementPercentagesResponse response = steamRestClient.get()
.uri(uriBuilder -> uriBuilder
.path("/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/")
.queryParam("gameid", appid)
.build())
.retrieve()
.body(SteamGlobalAchievementPercentagesResponse.class);
if (response == null || response.achievementPercentages() == null
|| response.achievementPercentages().achievements() == null) {
return Map.of();
}
Map<String, Double> percents = new HashMap<>();
for (SteamGlobalAchievementPercentagesResponse.Achievement achievement : response.achievementPercentages().achievements()) {
if (achievement.name() != null && achievement.percent() != null) {
percents.put(achievement.name(), achievement.percent());
}
}
return percents;
} catch (Exception e) {
log.warn("No global achievement rates for appid {}: {}", appid, e.getMessage());
return Map.of();
}
}
/**
* Per-user achievement state for a single game.
* Returns empty when profile/stats are private (HTTP 403), and no achievements when Steam answers 400 "Requested
@@ -0,0 +1,23 @@
-- Share of all Steam players who unlocked the achievement, from GetGlobalAchievementPercentagesForApp. Null until
-- the next schema refresh fetched it.
ALTER TABLE game_achievement_schema ADD COLUMN global_percent DOUBLE PRECISION;
-- A game without an end (sandbox, survival, 4X, simulator, MMO), from IGDB: playtime says nothing about "finished".
-- Null until the next mood refresh, and when IGDB has no tags for the game.
ALTER TABLE game_mood ADD COLUMN endless BOOLEAN;
-- The strongest finished hint the user answered with "Not yet": ENDING or PLAYTIME.
ALTER TABLE user_game ADD COLUMN finished_hint_dismissed VARCHAR(16);
-- The next library sync fetches every schema and mood again, so the new columns get filled.
UPDATE game_achievement_schema_meta SET refreshed_at = TIMESTAMPTZ 'epoch' WHERE has_schema;
UPDATE game_mood SET checked_at = TIMESTAMPTZ 'epoch';
-- True once the user chose the status (by hand, or when adding a game by hand). Core sets FINISHED by itself only
-- while it is false. For the rows of today: the sync gives BACKLOG (first seen unplayed, the status stays when the
-- game is played later) or PLAYING (first seen played). Any other status, PLAYING without playtime, or a rating,
-- note or priority counts as chosen.
ALTER TABLE user_game ADD COLUMN status_set_by_user BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE user_game SET status_set_by_user = TRUE
WHERE source = 'MANUAL' OR rating IS NOT NULL OR notes IS NOT NULL OR priority IS NOT NULL
OR NOT (status = 'BACKLOG' OR (status = 'PLAYING' AND playtime_minutes > 0));
@@ -0,0 +1,205 @@
package core_service.modules.library.api;
import core_service.configuration.security.ClientHeaderFilter;
import core_service.modules.library.domain.FinishedHintType;
import core_service.modules.library.domain.GameAchievementSchema;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievement;
import core_service.modules.library.domain.UserGameAchievementMeta;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameAchievementSchemaRepository;
import core_service.modules.library.repo.GameMoodsRepository;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameAchievementRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import java.time.Instant;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The dev profile authenticates every request as user 1. Fresh schemas, so the detail page asks Steam nothing.
*/
@AutoConfigureMockMvc
class LibraryControllerFinishedHintTest extends AbstractIntegrationTest {
private static final long USER_ID = 1L;
private static final long ENDING_GAME = 322110L;
private static final long MANUAL_GAME = -1942L;
@Autowired
private MockMvc mockMvc;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private GameAchievementSchemaRepository schemaRepository;
@Autowired
private GameAchievementSchemaMetaRepository schemaMetaRepository;
@Autowired
private UserGameAchievementRepository userAchievementRepository;
@Autowired
private UserGameAchievementMetaRepository userAchievementMetaRepository;
@Autowired
private GameTimeToBeatRepository gameTimeToBeatRepository;
@Autowired
private GameMoodsRepository gameMoodsRepository;
@BeforeEach
void seed() {
userGameRepository.deleteAll();
schemaRepository.deleteAll();
schemaMetaRepository.deleteAll();
userAchievementRepository.deleteAll();
userAchievementMetaRepository.deleteAll();
steamGameRepository.save(new SteamGame(ENDING_GAME, "20XX", null, Instant.now()));
steamGameRepository.save(new SteamGame(MANUAL_GAME, "Zelda", null, Instant.now()));
GameAchievementSchema ending = new GameAchievementSchema(ENDING_GAME, "ACH_SURVIVOR", "Survivor", "Complete the game.", null, null, false);
ending.setGlobalPercent(19.2);
GameAchievementSchema other = new GameAchievementSchema(ENDING_GAME, "ACH_KILL", "First Blood", "Kill an enemy", null, null, false);
other.setGlobalPercent(90.0);
schemaRepository.saveAll(List.of(ending, other));
schemaMetaRepository.save(new GameAchievementSchemaMeta(ENDING_GAME, 2, true, Instant.now()));
userAchievementRepository.save(new UserGameAchievement(USER_ID, ENDING_GAME, "ACH_SURVIVOR", true, Instant.now()));
userAchievementMetaRepository.save(new UserGameAchievementMeta(USER_ID, ENDING_GAME, Instant.now(), true, 1, 600));
gameTimeToBeatRepository.save(new GameTimeToBeat(MANUAL_GAME, null, 600, null, Instant.now()));
gameMoodsRepository.upsert(MANUAL_GAME, new String[]{"STORY"}, false, Instant.now());
}
private UserGame steamGame(boolean statusSetByUser) {
UserGame game = new UserGame(USER_ID, ENDING_GAME, 600, Instant.now(), Instant.now());
game.setStatusSetByUser(statusSetByUser);
return userGameRepository.save(game);
}
private UserGame stored(long appid) {
return userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow();
}
private ResultActions dismiss(long appid, String body) throws Exception {
return mockMvc.perform(put("/api/library/games/" + appid + "/finished-hint").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content(body));
}
@Test
void detail_UntouchedGameWithTheEndingUnlocked_IsFinishedByCore() throws Exception {
steamGame(false);
mockMvc.perform(get("/api/library/games/" + ENDING_GAME))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("FINISHED"))
.andExpect(jsonPath("$.finishedHint").doesNotExist());
assertEquals(GameStatus.FINISHED, stored(ENDING_GAME).getStatus());
}
@Test
void detail_UntouchedGameAfterNotYet_IsNotFinishedByCore() throws Exception {
UserGame game = steamGame(false);
game.setFinishedHintDismissed(FinishedHintType.PLAYTIME);
userGameRepository.save(game);
mockMvc.perform(get("/api/library/games/" + ENDING_GAME))
.andExpect(jsonPath("$.status").value("PLAYING"))
.andExpect(jsonPath("$.finishedHint.type").value("ENDING"));
}
@Test
void detail_StatusChosenByTheUser_KeepsItAndShowsTheEndingHint() throws Exception {
steamGame(true);
mockMvc.perform(get("/api/library/games/" + ENDING_GAME))
.andExpect(jsonPath("$.status").value("PLAYING"))
.andExpect(jsonPath("$.finishedHint.type").value("ENDING"))
.andExpect(jsonPath("$.finishedHint.achievement").value("Survivor"));
assertEquals(GameStatus.PLAYING, stored(ENDING_GAME).getStatus());
}
@Test
void detail_GameAddedByHand_GetsThePlaytimeHint() throws Exception {
userGameRepository.save(UserGame.manual(USER_ID, MANUAL_GAME, "Switch", GameStatus.PLAYING, 600, Instant.now()));
mockMvc.perform(get("/api/library/games/" + MANUAL_GAME))
.andExpect(jsonPath("$.finishedHint.type").value("PLAYTIME"))
.andExpect(jsonPath("$.finishedHint.achievement").doesNotExist());
}
@Test
void notYet_HidesTheHintAndPlaytimeNeverWeakensEnding() throws Exception {
steamGame(true);
dismiss(ENDING_GAME, "{\"dismissed\":\"ENDING\"}").andExpect(status().isNoContent());
dismiss(ENDING_GAME, "{\"dismissed\":\"PLAYTIME\"}").andExpect(status().isNoContent());
assertEquals(FinishedHintType.ENDING, stored(ENDING_GAME).getFinishedHintDismissed());
mockMvc.perform(get("/api/library/games/" + ENDING_GAME)).andExpect(jsonPath("$.finishedHint").doesNotExist());
}
@Test
void settingTheStatusByHand_MarksItAsChosen() throws Exception {
steamGame(false);
mockMvc.perform(put("/api/library/games/" + ENDING_GAME + "/status").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content("{\"status\":\"DROPPED\"}"))
.andExpect(status().isOk());
assertTrue(stored(ENDING_GAME).isStatusSetByUser());
assertEquals(0, userGameRepository.finishIfUntouched(USER_ID, ENDING_GAME));
assertEquals(GameStatus.DROPPED, stored(ENDING_GAME).getStatus());
}
@Test
void notYet_UnknownGameIs404() throws Exception {
dismiss(620L, "{\"dismissed\":\"ENDING\"}").andExpect(status().isNotFound());
}
@ParameterizedTest
@ValueSource(strings = {"{}", "{\"dismissed\":\"MAYBE\"}"})
void notYet_BadBodyIs400(String body) throws Exception {
steamGame(true);
dismiss(ENDING_GAME, body).andExpect(status().isBadRequest());
assertNull(stored(ENDING_GAME).getFinishedHintDismissed());
}
@Test
void notYet_WithoutClientHeaderIs403() throws Exception {
steamGame(true);
mockMvc.perform(put("/api/library/games/" + ENDING_GAME + "/finished-hint")
.contentType(MediaType.APPLICATION_JSON).content("{\"dismissed\":\"ENDING\"}"))
.andExpect(status().isForbidden());
assertFalse(stored(ENDING_GAME).getFinishedHintDismissed() != null);
}
}
@@ -93,9 +93,9 @@ class LibraryControllerPickerTest extends AbstractIntegrationTest {
game(20L, GameStatus.BACKLOG, null, null);
game(30L, GameStatus.BACKLOG, null, null);
game(40L, GameStatus.FINISHED, null, null);
gameMoodsRepository.upsert(10L, new String[]{"CHILL", "COOP"}, Instant.now());
gameMoodsRepository.upsert(20L, new String[]{"ACTION"}, Instant.now());
gameMoodsRepository.upsert(40L, new String[]{"CHILL"}, Instant.now());
gameMoodsRepository.upsert(10L, new String[]{"CHILL", "COOP"}, false, Instant.now());
gameMoodsRepository.upsert(20L, new String[]{"ACTION"}, false, Instant.now());
gameMoodsRepository.upsert(40L, new String[]{"CHILL"}, false, Instant.now());
mockMvc.perform(get("/api/library/picker").param("mood", "CHILL"))
.andExpect(status().isOk())
@@ -124,8 +124,8 @@ class LibraryControllerPickerTest extends AbstractIntegrationTest {
void picker_MoodAndBudget_BothMustFit() throws Exception {
game(10L, GameStatus.BACKLOG, null, 300);
game(20L, GameStatus.BACKLOG, null, 3000);
gameMoodsRepository.upsert(10L, new String[]{"STORY"}, Instant.now());
gameMoodsRepository.upsert(20L, new String[]{"STORY"}, Instant.now());
gameMoodsRepository.upsert(10L, new String[]{"STORY"}, false, Instant.now());
gameMoodsRepository.upsert(20L, new String[]{"STORY"}, false, Instant.now());
mockMvc.perform(get("/api/library/picker").param("mood", "STORY").param("maxMinutes", "900"))
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10)));
@@ -0,0 +1,127 @@
package core_service.modules.library.domain;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The cases are rows measured on production (ROADMAP item 9), with their Steam global rates.
*/
class EndingAchievementTest {
private static GameAchievementSchema achievement(String apiName, String name, String description, Double percent) {
GameAchievementSchema row = new GameAchievementSchema(1L, apiName, name, description, null, null, false);
row.setGlobalPercent(percent);
return row;
}
@ParameterizedTest
@CsvSource(delimiter = '|', nullValues = "-", textBlock = """
Survivor | Complete the game. | 19.2
To Link the Fire | Reach "To Link the Fire" ending. | 31.2
The End of Fire | - | 21.4
Fate's Conclusion | - | 16.2
A Stroll In The Demonic Park | Complete the game on any difficulty setting | 21.7
A Cozy Completion | Complete the Campaign | 50.0
Passed the Trial | Finish the game on any difficulty. | 22.0
""")
void isCandidate_StoryEnds(String name, String description, double percent) {
assertThat(EndingAchievement.isCandidate(achievement("A", name, description, percent))).isTrue();
}
@ParameterizedTest
@CsvSource(delimiter = '|', nullValues = "-", textBlock = """
The Feast Begins! | Finished tutorial. | 98.6
True Fan I | Finish 5 sessions with a score of at least 1000 | 30.1
Last Breath | Complete the game with 1 HP remaining (and no armor). | 5.9
Honor and Glory | Complete the game after entering and achieving glory. | 14.6
Super Fighting Robot | Complete the game on NORMAL difficulty. | 10.2
Geminiphobia | Beat the game… twice. | 11.6
Do You Want to Super Size | Beat the game on a Large Map. | 13.4
The Alexman | Beat the game on the Warlord difficulty level. | 18.1
In Storybook Endings | Lock up Mad Hatter in GCPD. | 20.0
Neverending | Ascend the world 3 times | 40.0
Victory | Complete the game | 0.5
Victory | Complete the game | 95.0
First Steps | - | 89.1
Prologue Done | Finish the prologue of the game | 70.0
Act One | Complete Act 1 of the campaign | 60.0
First Level | Clear the first level of the game | 75.0
Match Played | Complete a game | 75.0
Chapter 1 | Complete chapter 1 of the story | 70.0
The End of the Beginning | - | 65.0
Dead End | - | 40.0
Beginning of the End | - | 30.0
""")
void isCandidate_NoStoryEnd(String name, String description, double percent) {
assertThat(EndingAchievement.isCandidate(achievement("A", name, description, percent))).isFalse();
}
@Test
void isCandidate_UnknownRateIsNoCandidate() {
assertThat(EndingAchievement.isCandidate(achievement("A", "Survivor", "Complete the game.", null))).isFalse();
}
@Test
void of_TheEasiestVariantWins() {
List<GameAchievementSchema> schema = List.of(
achievement("Game_CompleteApocolyptic", "Apocalyptic", "Complete the Game on Apocalyptic Difficulty", 6.6),
achievement("Game_Complete", "Protector of Humanity", "Complete the Game on Any Difficulty Setting", 32.9),
achievement("GAME_STORY", "Story", "Complete the story", 20.0),
achievement("Other", "First Blood", "Kill an enemy", 90.0));
EndingAchievement ending = EndingAchievement.of(schema).orElseThrow();
assertThat(ending.achievement().getApiName()).isEqualTo("Game_Complete");
// Two candidates: a hint, never FINISHED by Core itself.
assertThat(ending.certain()).isFalse();
}
@Test
void of_MoreThanThreeCandidatesIsAnOpenGame() {
List<GameAchievementSchema> schema = List.of(
achievement("A", "Egypt", "Beat the game on any difficulty setting as Ramesses II.", 10.0),
achievement("B", "Rome", "Beat the game on any difficulty setting as Augustus.", 11.0),
achievement("C", "China", "Beat the game on any difficulty setting as Wu Zetian.", 9.0),
achievement("D", "Greece", "Beat the game on any difficulty setting as Alexander.", 12.0));
assertThat(EndingAchievement.of(schema)).isEmpty();
}
// Three of the four leaders are under the 1 % floor; the family still shows an open game.
@Test
void of_SiblingsUnderTheRateFloorStillMakeAnOpenGame() {
List<GameAchievementSchema> schema = List.of(
achievement("A", "Egypt", "Beat the game on any difficulty setting as Ramesses II.", 5.0),
achievement("B", "Rome", "Beat the game on any difficulty setting as Augustus.", 0.8),
achievement("C", "China", "Beat the game on any difficulty setting as Wu Zetian.", 0.7),
achievement("D", "Greece", "Beat the game on any difficulty setting as Alexander.", 0.5));
assertThat(EndingAchievement.of(schema).filter(EndingAchievement::certain)).isEmpty();
}
@Test
void of_NoCandidateIsEmpty() {
assertThat(EndingAchievement.of(List.of(achievement("A", "First Blood", "Kill an enemy", 90.0)))).isEmpty();
assertThat(EndingAchievement.of(List.of())).isEmpty();
}
@Test
void of_OneTextMatchIsCertain() {
EndingAchievement ending = EndingAchievement.of(List.of(
achievement("END", "Survivor", "Complete the game.", 19.2),
achievement("KILL", "First Blood", "Kill an enemy", 90.0))).orElseThrow();
assertThat(ending.certain()).isTrue();
}
@Test
void of_ANameOnlyMatchIsNeverCertain() {
EndingAchievement ending = EndingAchievement.of(List.of(achievement("ACH02", "The End of Fire", null, 21.4))).orElseThrow();
assertThat(ending.certain()).isFalse();
}
}
@@ -0,0 +1,121 @@
package core_service.modules.library.domain;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class FinishedHintTest {
private static final int NORMAL = 1000;
private static GameAchievementSchema achievement(String apiName, String name, String description, double percent) {
GameAchievementSchema row = new GameAchievementSchema(1L, apiName, name, description, null, null, false);
row.setGlobalPercent(percent);
return row;
}
private static final List<GameAchievementSchema> WITH_ENDING = List.of(
achievement("END", "Survivor", "Complete the game.", 19.2),
achievement("KILL", "First Blood", "Kill an enemy", 90.0));
private static final List<GameAchievementSchema> WITHOUT_ENDING = List.of(
achievement("KILL", "First Blood", "Kill an enemy", 90.0),
achievement("BOSS", "Big One", "Defeat a boss", 50.0));
/**
* A PLAYING Steam game with visible stats, known time to beat and not endless.
*/
private static FinishedHint.Facts facts(List<GameAchievementSchema> schema, Set<String> unlocked, int playtime) {
return new FinishedHint.Facts(GameStatus.PLAYING, GameSource.STEAM, schema, unlocked, true, playtime, NORMAL,
false, null);
}
private static Optional<FinishedHintType> type(FinishedHint.Facts facts) {
return FinishedHint.of(facts).map(FinishedHint::type);
}
@Test
void endingUnlocked_IsTheEndingHintWithItsName() {
assertThat(FinishedHint.of(facts(WITH_ENDING, Set.of("END"), 0)))
.contains(new FinishedHint(FinishedHintType.ENDING, "Survivor"));
}
@Test
void endingLocked_IsNoHintEvenAfterALongPlaytime() {
assertThat(type(facts(WITH_ENDING, Set.of("KILL"), 5 * NORMAL))).isEmpty();
}
@Test
void noEnding_PlaytimeFromNinetyPercentOfTheNormalTime() {
assertThat(type(facts(WITHOUT_ENDING, Set.of(), 900))).contains(FinishedHintType.PLAYTIME);
assertThat(type(facts(WITHOUT_ENDING, Set.of(), 899))).isEmpty();
}
@Test
void statsHiddenBySteam_UsesThePlaytime() {
FinishedHint.Facts hidden = new FinishedHint.Facts(GameStatus.PLAYING, GameSource.STEAM, WITH_ENDING, Set.of(),
false, NORMAL, NORMAL, false, null);
assertThat(type(hidden)).contains(FinishedHintType.PLAYTIME);
}
@Test
void endlessOrUnknownGame_GetsNoPlaytimeHint() {
FinishedHint.Facts endless = new FinishedHint.Facts(GameStatus.PLAYING, GameSource.STEAM, List.of(), Set.of(),
true, 5 * NORMAL, NORMAL, true, null);
FinishedHint.Facts unknown = new FinishedHint.Facts(GameStatus.PLAYING, GameSource.STEAM, List.of(), Set.of(),
true, 5 * NORMAL, NORMAL, null, null);
FinishedHint.Facts noTimeToBeat = new FinishedHint.Facts(GameStatus.PLAYING, GameSource.STEAM, List.of(), Set.of(),
true, 5 * NORMAL, null, false, null);
assertThat(type(endless)).isEmpty();
assertThat(type(unknown)).isEmpty();
assertThat(type(noTimeToBeat)).isEmpty();
}
@Test
void everyAchievementUnlocked_LeavesItToTheCompletedHint() {
assertThat(type(facts(WITH_ENDING, Set.of("END", "KILL"), 5 * NORMAL))).isEmpty();
}
@ParameterizedTest
@EnumSource(value = GameStatus.class, names = {"FINISHED", "COMPLETED", "DROPPED"})
void onlyBacklogAndPlayingGamesAreAsked(GameStatus status) {
FinishedHint.Facts done = new FinishedHint.Facts(status, GameSource.STEAM, WITH_ENDING, Set.of("END"), true,
5 * NORMAL, NORMAL, false, null);
assertThat(type(done)).isEmpty();
}
@Test
void dismissedPlaytime_StillShowsTheEndingLater() {
FinishedHint.Facts playtimeOnly = new FinishedHint.Facts(GameStatus.PLAYING, GameSource.STEAM, WITHOUT_ENDING,
Set.of(), true, 5 * NORMAL, NORMAL, false, FinishedHintType.PLAYTIME);
FinishedHint.Facts endingNow = new FinishedHint.Facts(GameStatus.PLAYING, GameSource.STEAM, WITH_ENDING,
Set.of("END"), true, 5 * NORMAL, NORMAL, false, FinishedHintType.PLAYTIME);
assertThat(type(playtimeOnly)).isEmpty();
assertThat(type(endingNow)).contains(FinishedHintType.ENDING);
}
@Test
void dismissedEnding_ShowsNothing() {
FinishedHint.Facts facts = new FinishedHint.Facts(GameStatus.PLAYING, GameSource.STEAM, WITH_ENDING,
Set.of("END"), true, 5 * NORMAL, NORMAL, false, FinishedHintType.ENDING);
assertThat(type(facts)).isEmpty();
}
@Test
void gameAddedByHand_UsesOnlyTheTypedHours() {
FinishedHint.Facts manual = new FinishedHint.Facts(GameStatus.BACKLOG, GameSource.MANUAL, List.of(), Set.of(),
true, NORMAL, NORMAL, false, null);
assertThat(type(manual)).contains(FinishedHintType.PLAYTIME);
}
}
@@ -2,6 +2,8 @@ package core_service.modules.library.domain;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import java.util.List;
@@ -45,4 +47,25 @@ class MoodTest extends AbstractUnitTest {
void of_NothingThatFits_HasNoMood() {
assertThat(Mood.of(List.of("Racing", "Sport"), List.of("Science fiction"), List.of("Multiplayer"))).isEmpty();
}
@ParameterizedTest
@CsvSource(delimiter = '|', textBlock = """
Simulator | - | -
Strategy | Sandbox | -
Adventure | Survival | -
Strategy | 4X (explore, expand, exploit, and exterminate) | -
RPG | - | Massively Multiplayer Online (MMO)
""", nullValues = "-")
void endless_GamesWithoutAnEnd(String genre, String theme, String mode) {
assertThat(Mood.endless(listOf(genre), listOf(theme), listOf(mode))).isTrue();
}
@Test
void endless_StrategyWithACampaignIsNotEndless() {
assertThat(Mood.endless(List.of("Real Time Strategy (RTS)", "Strategy"), List.of("Warfare"), List.of("Single player"))).isFalse();
}
private static List<String> listOf(String value) {
return value == null ? List.of() : List.of(value);
}
}
@@ -26,12 +26,23 @@ class GameMoodsRepositoryTest extends AbstractPersistenceTest {
gameMoodsRepository.deleteAll();
}
@Test
void upsert_StoresTheEndlessFlag() {
gameMoodsRepository.upsert(427520L, new String[]{"CHILL"}, true, Instant.now());
entityManager.clear();
assertThat(gameMoodsRepository.findById(427520L).orElseThrow().getEndless()).isTrue();
gameMoodsRepository.upsert(427520L, new String[]{"CHILL"}, false, Instant.now());
entityManager.clear();
assertThat(gameMoodsRepository.findById(427520L).orElseThrow().getEndless()).isFalse();
}
@Test
void upsert_StoresTheMoodsAndReplacesThemOnTheNextRefresh() {
gameMoodsRepository.upsert(620L, new String[]{"CHILL", "COOP"}, Instant.now());
gameMoodsRepository.upsert(620L, new String[]{"CHILL", "COOP"}, false, Instant.now());
assertThat(gameMoodsRepository.findById(620L).orElseThrow().getMoods()).containsExactly("CHILL", "COOP");
gameMoodsRepository.upsert(620L, new String[0], Instant.now());
gameMoodsRepository.upsert(620L, new String[0], false, Instant.now());
// The native upsert passes the persistence context; read the row again.
entityManager.clear();
assertThat(gameMoodsRepository.findById(620L).orElseThrow().getMoods()).isEmpty();
@@ -39,8 +50,8 @@ class GameMoodsRepositoryTest extends AbstractPersistenceTest {
@Test
void findFreshAppids_LeavesOutOldAndMissingRows() {
gameMoodsRepository.upsert(620L, new String[]{"STORY"}, Instant.now());
gameMoodsRepository.upsert(220L, new String[]{"ACTION"}, Instant.now().minus(Duration.ofDays(40)));
gameMoodsRepository.upsert(620L, new String[]{"STORY"}, false, Instant.now());
gameMoodsRepository.upsert(220L, new String[]{"ACTION"}, false, Instant.now().minus(Duration.ofDays(40)));
assertThat(gameMoodsRepository.findFreshAppids(List.of(620L, 220L, 700L), Instant.now().minus(Duration.ofDays(30))))
.isEqualTo(Set.of(620L));
@@ -1,7 +1,9 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.GameAchievementSchema;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievement;
import core_service.modules.library.domain.UserGameAchievementMeta;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameAchievementSchemaRepository;
@@ -9,6 +11,7 @@ import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameAchievementRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamSchemaForGameResponse;
import core_service.modules.steam_adapter.service.SteamApiService;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
@@ -23,6 +26,7 @@ import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
@@ -184,6 +188,101 @@ class AchievementSyncServiceTest extends AbstractUnitTest {
verify(steamApiService, never()).getPlayerAchievements(anyString(), anyLong());
}
private static Optional<SteamSchemaForGameResponse.Game> steamSchema(String... apiNames) {
List<SteamSchemaForGameResponse.Achievement> achievements = java.util.Arrays.stream(apiNames)
.map(name -> new SteamSchemaForGameResponse.Achievement(name, name, "Complete the game.", 0, "icon", "gray"))
.toList();
return Optional.of(new SteamSchemaForGameResponse.Game("Game", "1",
new SteamSchemaForGameResponse.AvailableGameStats(achievements)));
}
@SuppressWarnings("unchecked")
private List<GameAchievementSchema> savedSchema() {
ArgumentCaptor<List<GameAchievementSchema>> saved = ArgumentCaptor.forClass(List.class);
verify(schemaRepository).saveAll(saved.capture());
return saved.getValue();
}
@Test
void refreshSchemaNow_StoresTheGlobalRatesPerAchievement() throws Exception {
when(steamApiService.getSchemaForGame(620L)).thenReturn(steamSchema("A", "B"));
when(steamApiService.getGlobalAchievementPercentages(620L)).thenReturn(Map.of("A", 12.5));
service.refreshSchemaNow(620L);
List<GameAchievementSchema> rows = savedSchema();
assertEquals(12.5, rows.get(0).getGlobalPercent());
assertEquals(null, rows.get(1).getGlobalPercent());
}
@Test
void refreshSchemaNow_FailedRatesCall_KeepsTheStoredRates() throws Exception {
GameAchievementSchema old = new GameAchievementSchema(620L, "A", "A", null, null, null, false);
old.setGlobalPercent(30.0);
when(steamApiService.getSchemaForGame(620L)).thenReturn(steamSchema("A"));
when(steamApiService.getGlobalAchievementPercentages(620L)).thenReturn(Map.of());
when(schemaRepository.findByAppid(620L)).thenReturn(List.of(old));
service.refreshSchemaNow(620L);
assertEquals(30.0, savedSchema().getFirst().getGlobalPercent());
}
@Test
void refreshSchemaNow_RateLimiterFull_StillStoresTheSchema() throws Exception {
when(steamApiService.getSchemaForGame(620L)).thenReturn(steamSchema("A"));
when(steamApiService.getGlobalAchievementPercentages(620L)).thenThrow(new IllegalStateException("limiter full"));
service.refreshSchemaNow(620L);
assertEquals("A", savedSchema().getFirst().getApiName());
}
private void endingOf620(boolean unlocked) {
GameAchievementSchema ending = new GameAchievementSchema(620L, "END", "Survivor", "Complete the game.", null, null, false);
ending.setGlobalPercent(19.2);
when(schemaRepository.findByAppid(620L)).thenReturn(List.of(ending));
when(userAchievementRepository.findByUserIdAndAppid(USER_ID, 620L))
.thenReturn(List.of(new UserGameAchievement(USER_ID, 620L, "END", unlocked, null)));
}
@Test
void finishIfEndingUnlocked_UnlockedEnding_FinishesAnUntouchedGame() {
endingOf620(true);
when(userGameRepository.finishIfUntouched(USER_ID, 620L)).thenReturn(1);
assertTrue(service.finishIfEndingUnlocked(USER_ID, 620L));
}
@Test
void finishIfEndingUnlocked_LockedEnding_ChangesNothing() {
endingOf620(false);
assertFalse(service.finishIfEndingUnlocked(USER_ID, 620L));
verify(userGameRepository, never()).finishIfUntouched(anyLong(), anyLong());
}
@Test
void syncLibrary_UntouchedGameWithUnchangedPlaytime_IsCheckedForTheEnding() {
library(List.of(game(620L, 120)), List.of(schema(620L, 1)), List.of(unlocks(620L, 1, 120)));
endingOf620(true);
service.syncLibrary(USER_ID, STEAM_ID);
verify(userGameRepository).finishIfUntouched(USER_ID, 620L);
}
@Test
void syncLibrary_StatusChosenByTheUser_IsNeverFinishedByCore() {
UserGame chosen = game(620L, 120);
chosen.setStatusSetByUser(true);
library(List.of(chosen), List.of(schema(620L, 1)), List.of(unlocks(620L, 1, 120)));
service.syncLibrary(USER_ID, STEAM_ID);
verify(userGameRepository, never()).finishIfUntouched(anyLong(), anyLong());
}
@Test
void refreshSchemaNow_EmptyAnswer_KeepsTheStoredSchema() throws Exception {
when(steamApiService.getSchemaForGame(620L)).thenReturn(Optional.empty());
@@ -18,8 +18,10 @@ import java.util.Set;
import static org.mockito.AdditionalMatchers.aryEq;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
@@ -53,9 +55,21 @@ class MoodServiceTest extends AbstractUnitTest {
service.refresh(List.of(220L, 620L, 700L));
verify(gameMoodsRepository).upsert(eq(620L), aryEq(new String[]{"CHILL", "COOP"}), any(Instant.class));
verify(gameMoodsRepository).upsert(eq(700L), aryEq(new String[0]), any(Instant.class));
verify(gameMoodsRepository, never()).upsert(eq(220L), any(), any());
verify(gameMoodsRepository).upsert(eq(620L), aryEq(new String[]{"CHILL", "COOP"}), eq(false), any(Instant.class));
verify(gameMoodsRepository).upsert(eq(700L), aryEq(new String[0]), isNull(), any(Instant.class));
verify(gameMoodsRepository, never()).upsert(eq(220L), any(), anyBoolean(), any());
}
@Test
void refresh_StoresWhetherTheGameHasNoEnd() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(gameMoodsRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getGameTags(Set.of(427520L))).thenReturn(Optional.of(Map.of(
427520L, new IgdbApiService.GameTags(List.of("Simulator", "Strategy"), List.of("Sandbox"), List.of()))));
service.refresh(List.of(427520L));
verify(gameMoodsRepository).upsert(eq(427520L), any(), eq(true), any(Instant.class));
}
@Test
@@ -67,7 +81,7 @@ class MoodServiceTest extends AbstractUnitTest {
service.refreshBacklogInBackground(7L);
verify(gameMoodsRepository).upsert(eq(620L), aryEq(new String[0]), any(Instant.class));
verify(gameMoodsRepository).upsert(eq(620L), aryEq(new String[0]), isNull(), any(Instant.class));
}
@Test
@@ -79,7 +93,7 @@ class MoodServiceTest extends AbstractUnitTest {
service.refresh(List.of(-119388L));
verify(gameMoodsRepository).upsert(eq(-119388L), aryEq(new String[]{"STORY"}), any(Instant.class));
verify(gameMoodsRepository).upsert(eq(-119388L), aryEq(new String[]{"STORY"}), eq(false), any(Instant.class));
}
@Test
@@ -90,7 +104,7 @@ class MoodServiceTest extends AbstractUnitTest {
service.refresh(List.of(620L));
verify(gameMoodsRepository, never()).upsert(any(), any(), any());
verify(gameMoodsRepository, never()).upsert(any(), any(), anyBoolean(), any());
}
@Test
@@ -383,4 +383,29 @@ class SteamApiServiceTest {
assertThatThrownBy(() -> steamApiService.getPlayerAchievements("76561197960435530", 620))
.isInstanceOf(RestClientException.class);
}
@Test
void getGlobalAchievementPercentages_ReadsStringsAndNumbers() {
server.expect(requestTo(containsString("/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/")))
.andExpect(queryParam("gameid", "620"))
.andRespond(withSuccess("""
{"achievementpercentages":{"achievements":[
{"name":"ACH.WAKE_UP","percent":"64.8"},
{"name":"ACH.LASER","percent":62.7},
{"name":"ACH.BROKEN"}
]}}
""", MediaType.APPLICATION_JSON));
assertThat(steamApiService.getGlobalAchievementPercentages(620))
.containsOnly(Map.entry("ACH.WAKE_UP", 64.8), Map.entry("ACH.LASER", 62.7));
}
@Test
void getGlobalAchievementPercentages_AnyFailureIsEmpty() {
server.expect(requestTo(containsString("/GetGlobalAchievementPercentagesForApp/"))).andRespond(withStatus(HttpStatus.FORBIDDEN));
server.expect(requestTo(containsString("/GetGlobalAchievementPercentagesForApp/"))).andRespond(withServerError());
assertThat(steamApiService.getGlobalAchievementPercentages(620)).isEmpty();
assertThat(steamApiService.getGlobalAchievementPercentages(620)).isEmpty();
}
}