Public Access
feat(library): finish games from the ending achievement or suggest it
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+26
@@ -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));
|
||||
+205
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user