diff --git a/readme.md b/readme.md index aab8898..2299518 100644 --- a/readme.md +++ b/readme.md @@ -31,6 +31,14 @@ To Be Played is split into three repositories: games closest to 100 %. - **Playtime history**: one row per game and day, for the activity view and the "most played in the last two weeks" dashboard banner. +- **Finished from achievements or playtime**: `EndingAchievement` finds the one achievement of a game that means + "story end" (English text rule, Steam's global unlock rate between 1 and 80 %, the easiest variant wins, more than + 3 candidates = a game without one end). When the user unlocks it, never chose the status and never answered + "Not yet", the achievement sync sets FINISHED, but only for a certain ending: its text says so and no other + achievement of the game could be the end. A status the user chose is never changed; such games get a hint on the game page instead. A second, + weaker hint compares the playtime with IGDB's normal time to beat (at least 90 %), only for games IGDB does not + mark as endless (sandbox, survival, 4X, simulator, MMO). "Not yet" hides a hint per game; a dismissed playtime hint + still lets the ending hint through. - **Hide games**: a user can hide a Steam game (e.g. a playtest or a tool). It leaves every list and number: library, picker, Pile of Shame, activity, achievement summary, public profile and landing page stats. The sync still updates its playtime, but no achievements, time to beat, moods or speedrun data are fetched for it. The library @@ -65,6 +73,7 @@ accepted only by `GET /api/public/profiles/library` in the `X-Profile-Ticket` he | `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT | | `PUT` | `/api/library/games/{appid}/backlog` | JWT | | `PUT` | `/api/library/games/{appid}/status` (status only, keeps rating, notes, priority) | JWT | +| `PUT` | `/api/library/games/{appid}/finished-hint` (`{dismissed: ENDING\|PLAYTIME}`, "Not yet"; `204`) | JWT | | `PUT` | `/api/library/games/{appid}/hidden` (`{hidden}`; `409 MANUAL_GAME` for a game added by hand) | JWT | | `POST` | `/api/library/sync` | JWT | | `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | JWT | @@ -150,7 +159,8 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain` 5. `TimeToBeatService` and `MoodService` ask IGDB for missing or old time-to-beat and mood data, for Steam games and manual games. Hidden games only get their playtime updated. 6. After the commit, `LibrarySyncedEvent` starts `AchievementSyncService` and `SpeedrunService` (backlog games only) - in the background. + in the background. The achievement sync also fetches Steam's global unlock rates with each schema, and sets + FINISHED for a game whose ending achievement is unlocked while the user never chose its status. The frontend polls `GET /api/library/sync-status` until the run ends. diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java index b65f397..6e31daf 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -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, diff --git a/src/main/java/core_service/modules/library/api/dto/FinishedHintDismissal.java b/src/main/java/core_service/modules/library/api/dto/FinishedHintDismissal.java new file mode 100644 index 0000000..725a40d --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/FinishedHintDismissal.java @@ -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) {} diff --git a/src/main/java/core_service/modules/library/api/dto/FinishedHintDto.java b/src/main/java/core_service/modules/library/api/dto/FinishedHintDto.java new file mode 100644 index 0000000..9d632df --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/FinishedHintDto.java @@ -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) {} diff --git a/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java b/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java index 83cc4c6..1596aef 100644 --- a/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java +++ b/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java @@ -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 ) { /** diff --git a/src/main/java/core_service/modules/library/domain/EndingAchievement.java b/src/main/java/core_service/modules/library/domain/EndingAchievement.java new file mode 100644 index 0000000..bb2e867 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/EndingAchievement.java @@ -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 " 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 of(List 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; + } +} diff --git a/src/main/java/core_service/modules/library/domain/FinishedHint.java b/src/main/java/core_service/modules/library/domain/FinishedHint.java new file mode 100644 index 0000000..c254df2 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/FinishedHint.java @@ -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 schema, Set unlocked, + boolean statsVisible, int playtimeMinutes, Integer normallyMinutes, Boolean endless, + FinishedHintType dismissed) {} + + public static Optional 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 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(); + } +} diff --git a/src/main/java/core_service/modules/library/domain/FinishedHintType.java b/src/main/java/core_service/modules/library/domain/FinishedHintType.java new file mode 100644 index 0000000..c845188 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/FinishedHintType.java @@ -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 +} diff --git a/src/main/java/core_service/modules/library/domain/GameAchievementSchema.java b/src/main/java/core_service/modules/library/domain/GameAchievementSchema.java index 44fb70d..33a6c98 100644 --- a/src/main/java/core_service/modules/library/domain/GameAchievementSchema.java +++ b/src/main/java/core_service/modules/library/domain/GameAchievementSchema.java @@ -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; diff --git a/src/main/java/core_service/modules/library/domain/GameMoods.java b/src/main/java/core_service/modules/library/domain/GameMoods.java index a0f6f92..76bec58 100644 --- a/src/main/java/core_service/modules/library/domain/GameMoods.java +++ b/src/main/java/core_service/modules/library/domain/GameMoods.java @@ -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; } diff --git a/src/main/java/core_service/modules/library/domain/Mood.java b/src/main/java/core_service/modules/library/domain/Mood.java index 3922761..ca83160 100644 --- a/src/main/java/core_service/modules/library/domain/Mood.java +++ b/src/main/java/core_service/modules/library/domain/Mood.java @@ -25,6 +25,9 @@ public enum Mood { private static final Set ACTION_GENRES = Set.of("shooter", "fighting", "hack and slash/beat 'em up", "platform"); private static final Set ACTION_THEMES = Set.of("action"); private static final Set SCARY_THEMES = Set.of("horror"); + private static final Set ENDLESS_GENRES = Set.of("simulator"); + private static final Set ENDLESS_THEMES = Set.of("sandbox", "survival", "4x (explore, expand, exploit, and exterminate)"); + private static final Set ENDLESS_MODES = Set.of("massively multiplayer online (mmo)"); public static Set of(Collection genres, Collection themes, Collection modes) { Set 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 genres, Collection themes, Collection modes) { + return anyIn(lowerCase(genres), ENDLESS_GENRES) || anyIn(lowerCase(themes), ENDLESS_THEMES) + || anyIn(lowerCase(modes), ENDLESS_MODES); + } + private static Set lowerCase(Collection names) { return names.stream().map(name -> name.trim().toLowerCase(Locale.ROOT)).collect(Collectors.toSet()); } diff --git a/src/main/java/core_service/modules/library/domain/UserGame.java b/src/main/java/core_service/modules/library/domain/UserGame.java index 8e4dd23..6a9ef4d 100644 --- a/src/main/java/core_service/modules/library/domain/UserGame.java +++ b/src/main/java/core_service/modules/library/domain/UserGame.java @@ -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; } } diff --git a/src/main/java/core_service/modules/library/repo/GameMoodsRepository.java b/src/main/java/core_service/modules/library/repo/GameMoodsRepository.java index 13f3a78..1251353 100644 --- a/src/main/java/core_service/modules/library/repo/GameMoodsRepository.java +++ b/src/main/java/core_service/modules/library/repo/GameMoodsRepository.java @@ -22,8 +22,11 @@ public interface GameMoodsRepository extends JpaRepository { // 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); } diff --git a/src/main/java/core_service/modules/library/repo/UserGameRepository.java b/src/main/java/core_service/modules/library/repo/UserGameRepository.java index d1d02f9..157fbc2 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java @@ -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 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. */ diff --git a/src/main/java/core_service/modules/library/service/AchievementSyncService.java b/src/main/java/core_service/modules/library/service/AchievementSyncService.java index abb1c56..4547088 100644 --- a/src/main/java/core_service/modules/library/service/AchievementSyncService.java +++ b/src/main/java/core_service/modules/library/service/AchievementSyncService.java @@ -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 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 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 schema = steamApiService.getSchemaForGame(appid); List achievements = schema .map(SteamSchemaForGameResponse.Game::availableGameStats) .map(SteamSchemaForGameResponse.AvailableGameStats::achievements) .orElse(List.of()); if (keepOnEmpty && achievements.isEmpty()) return; + Map 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 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 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 storedPercents(Long appid) { + Map 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 */ diff --git a/src/main/java/core_service/modules/library/service/BacklogService.java b/src/main/java/core_service/modules/library/service/BacklogService.java index c92e892..77171ed 100644 --- a/src/main/java/core_service/modules/library/service/BacklogService.java +++ b/src/main/java/core_service/modules/library/service/BacklogService.java @@ -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 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()); } diff --git a/src/main/java/core_service/modules/library/service/GameDetailService.java b/src/main/java/core_service/modules/library/service/GameDetailService.java index c43ab3f..659eb41 100644 --- a/src/main/java/core_service/modules/library/service/GameDetailService.java +++ b/src/main/java/core_service/modules/library/service/GameDetailService.java @@ -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 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 achievements, List schema) { + Optional timeToBeat = gameTimeToBeatRepository.findById(userGame.getAppid()); + Set 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 ); } } diff --git a/src/main/java/core_service/modules/library/service/ManualGameService.java b/src/main/java/core_service/modules/library/service/ManualGameService.java index 031e89c..210285e 100644 --- a/src/main/java/core_service/modules/library/service/ManualGameService.java +++ b/src/main/java/core_service/modules/library/service/ManualGameService.java @@ -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); diff --git a/src/main/java/core_service/modules/library/service/MoodService.java b/src/main/java/core_service/modules/library/service/MoodService.java index 42a53fc..5ff9425 100644 --- a/src/main/java/core_service/modules/library/service/MoodService.java +++ b/src/main/java/core_service/modules/library/service/MoodService.java @@ -72,7 +72,8 @@ public class MoodService { for (Long appid : answered) { IgdbApiService.GameTags gameTags = tags.get(appid); Set 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); } } diff --git a/src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamGlobalAchievementPercentagesResponse.java b/src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamGlobalAchievementPercentagesResponse.java new file mode 100644 index 0000000..6b30f63 --- /dev/null +++ b/src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamGlobalAchievementPercentagesResponse.java @@ -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 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 + ) {} +} diff --git a/src/main/java/core_service/modules/steam_adapter/service/SteamApiService.java b/src/main/java/core_service/modules/steam_adapter/service/SteamApiService.java index a869646..f6c75fb 100644 --- a/src/main/java/core_service/modules/steam_adapter/service/SteamApiService.java +++ b/src/main/java/core_service/modules/steam_adapter/service/SteamApiService.java @@ -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 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 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 diff --git a/src/main/resources/db/migration/V13__finished_hint.sql b/src/main/resources/db/migration/V13__finished_hint.sql new file mode 100644 index 0000000..20287ad --- /dev/null +++ b/src/main/resources/db/migration/V13__finished_hint.sql @@ -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)); diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerFinishedHintTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerFinishedHintTest.java new file mode 100644 index 0000000..91d2fe3 --- /dev/null +++ b/src/test/java/core_service/modules/library/api/LibraryControllerFinishedHintTest.java @@ -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); + } +} diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java index 9773402..efb947d 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java @@ -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))); diff --git a/src/test/java/core_service/modules/library/domain/EndingAchievementTest.java b/src/test/java/core_service/modules/library/domain/EndingAchievementTest.java new file mode 100644 index 0000000..33f143e --- /dev/null +++ b/src/test/java/core_service/modules/library/domain/EndingAchievementTest.java @@ -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 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 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 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(); + } +} diff --git a/src/test/java/core_service/modules/library/domain/FinishedHintTest.java b/src/test/java/core_service/modules/library/domain/FinishedHintTest.java new file mode 100644 index 0000000..ff8b334 --- /dev/null +++ b/src/test/java/core_service/modules/library/domain/FinishedHintTest.java @@ -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 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 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 schema, Set unlocked, int playtime) { + return new FinishedHint.Facts(GameStatus.PLAYING, GameSource.STEAM, schema, unlocked, true, playtime, NORMAL, + false, null); + } + + private static Optional 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); + } +} diff --git a/src/test/java/core_service/modules/library/domain/MoodTest.java b/src/test/java/core_service/modules/library/domain/MoodTest.java index 251fc6d..653858c 100644 --- a/src/test/java/core_service/modules/library/domain/MoodTest.java +++ b/src/test/java/core_service/modules/library/domain/MoodTest.java @@ -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 listOf(String value) { + return value == null ? List.of() : List.of(value); + } } diff --git a/src/test/java/core_service/modules/library/repo/GameMoodsRepositoryTest.java b/src/test/java/core_service/modules/library/repo/GameMoodsRepositoryTest.java index c50b8e5..4f3712c 100644 --- a/src/test/java/core_service/modules/library/repo/GameMoodsRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/GameMoodsRepositoryTest.java @@ -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)); diff --git a/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java b/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java index 7ad2ef2..de5f001 100644 --- a/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java +++ b/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java @@ -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 steamSchema(String... apiNames) { + List 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 savedSchema() { + ArgumentCaptor> 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 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()); diff --git a/src/test/java/core_service/modules/library/service/MoodServiceTest.java b/src/test/java/core_service/modules/library/service/MoodServiceTest.java index 62823e8..b4607b7 100644 --- a/src/test/java/core_service/modules/library/service/MoodServiceTest.java +++ b/src/test/java/core_service/modules/library/service/MoodServiceTest.java @@ -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 diff --git a/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceTest.java b/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceTest.java index 41c9f04..ce7dec1 100644 --- a/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceTest.java +++ b/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceTest.java @@ -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(); + } }