From 0f56f5bb0ff84d6eb145e26258e1d43c15fdedbd Mon Sep 17 00:00:00 2001 From: lobo Date: Sun, 11 Oct 2026 16:22:49 +0200 Subject: [PATCH] feat(library): add ALL_UNLOCKED sync change for last achievement A sync that unlocks the last achievement of a game not COMPLETED adds an ALL_UNLOCKED change. The overview hides it once the game is COMPLETED, hidden or removed. Refs #21 --- readme.md | 4 +- .../modules/library/domain/SyncChange.java | 4 ++ .../library/domain/SyncChangeKind.java | 2 + .../library/repo/SyncChangeRepository.java | 2 + .../service/AchievementSyncService.java | 23 +++++--- .../repo/SyncChangeRepositoryTest.java | 18 +++++++ .../service/AchievementSyncServiceTest.java | 52 +++++++++++++++++++ 7 files changed, 96 insertions(+), 9 deletions(-) diff --git a/readme.md b/readme.md index 4eb3f95..6a7f868 100644 --- a/readme.md +++ b/readme.md @@ -426,6 +426,8 @@ and `WISHLIST` rows of `sync_change`) and the hand changes since the cursor. Kin are left out, and the cursor moves anyway. A job every 10 minutes reports the hand changes alone once a user was quiet for 10 minutes; it never takes a sync result, which may still wait for its achievements. +`ALL_UNLOCKED`: the sync unlocked the last Steam achievement of a game that is not COMPLETED; Core never sets COMPLETED itself, the user confirms it in the sync details. + ```mermaid sequenceDiagram participant W as LibrarySyncWorker @@ -438,7 +440,7 @@ sequenceDiagram P-->>W: ADDED, PLAYTIME, ACHIEVEMENTS (trophy gains) W->>D: sync_result, sync_change (ADDED, STATUS, WISHLIST, PSN changes) W-->>A: LibrarySyncedEvent - A->>D: sync_change (ACHIEVEMENTS) + A->>D: sync_change (ACHIEVEMENTS, ALL_UNLOCKED) A-->>F: LibraryActivityEvent F->>D: lock follow_digest, count sync + hand changes F->>D: inbox_note per follower (public, not blocked, not deleted) diff --git a/src/main/java/core_service/modules/library/domain/SyncChange.java b/src/main/java/core_service/modules/library/domain/SyncChange.java index 0f2bcd6..b27b8fc 100644 --- a/src/main/java/core_service/modules/library/domain/SyncChange.java +++ b/src/main/java/core_service/modules/library/domain/SyncChange.java @@ -27,6 +27,10 @@ public record SyncChange(SyncChangeKind kind, long appid, Integer amount, GameSt return new SyncChange(SyncChangeKind.STATUS, appid, null, from, to); } + public static SyncChange allUnlocked(long appid) { + return new SyncChange(SyncChangeKind.ALL_UNLOCKED, appid, null, null, null); + } + public static SyncChange wishlist(long appid) { return new SyncChange(SyncChangeKind.WISHLIST, appid, null, null, null); } diff --git a/src/main/java/core_service/modules/library/domain/SyncChangeKind.java b/src/main/java/core_service/modules/library/domain/SyncChangeKind.java index a02cc54..a096f9e 100644 --- a/src/main/java/core_service/modules/library/domain/SyncChangeKind.java +++ b/src/main/java/core_service/modules/library/domain/SyncChangeKind.java @@ -6,6 +6,8 @@ public enum SyncChangeKind { PLAYTIME, ACHIEVEMENTS, STATUS, + // The sync unlocked the last achievement of a game that is not COMPLETED. + ALL_UNLOCKED, // Only for followers' digests: never shown in the sync banner. WISHLIST } diff --git a/src/main/java/core_service/modules/library/repo/SyncChangeRepository.java b/src/main/java/core_service/modules/library/repo/SyncChangeRepository.java index de0d4ce..f326693 100644 --- a/src/main/java/core_service/modules/library/repo/SyncChangeRepository.java +++ b/src/main/java/core_service/modules/library/repo/SyncChangeRepository.java @@ -105,6 +105,7 @@ public class SyncChangeRepository { /** * Hidden games are left out when read, so hiding a game after the sync hides it here too. A removed game stays only for its own REMOVED entry. + * An ALL_UNLOCKED row goes once the game is COMPLETED. */ public Optional find(long userId) { MapSqlParameterSource params = new MapSqlParameterSource("userId", userId); @@ -132,6 +133,7 @@ public class SyncChangeRepository { join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid left join steam_game sg on sg.appid = c.appid where c.user_id = :userId and ug.hidden_at is null and (ug.removed_at is null or c.kind = 'REMOVED') and c.kind <> 'WISHLIST' + and (c.kind <> 'ALL_UNLOCKED' or ug.status <> 'COMPLETED') group by c.kind, c.appid, c.from_status, c.to_status, sg.name, sg.image_url order by c.kind, amount desc nulls last, sg.name """, params, (rs, i) -> new SyncChanges.Entry( 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 f97a756..97bbebd 100644 --- a/src/main/java/core_service/modules/library/service/AchievementSyncService.java +++ b/src/main/java/core_service/modules/library/service/AchievementSyncService.java @@ -36,7 +36,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.OptionalInt; import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -237,12 +236,17 @@ public class AchievementSyncService { return Outcome.UNCHANGED; } pause(); - OptionalInt unlocked = refreshUser(userId, steamId, appid, playtime); - if (unlocked.isEmpty()) return Outcome.HIDDEN; + Optional refreshed = refreshUser(userId, steamId, appid, playtime); + if (refreshed.isEmpty()) return Outcome.HIDDEN; + Unlocks unlocks = refreshed.get(); // Without an earlier visible refresh there is no baseline: the first refresh would show every unlock. - if (meta != null && meta.isStatsVisible() && unlocked.getAsInt() > meta.getAchievementsUnlocked()) { + if (meta != null && meta.isStatsVisible() && unlocks.unlocked() > meta.getAchievementsUnlocked()) { syncChangeRepository.add(userId, - SyncChange.achievements(appid, unlocked.getAsInt() - meta.getAchievementsUnlocked())); + SyncChange.achievements(appid, unlocks.unlocked() - meta.getAchievementsUnlocked())); + // Only the step to all: a game at all achievements before this sync gets no line. + if (unlocks.unlocked() == unlocks.total() && game.getStatus() != GameStatus.COMPLETED) { + syncChangeRepository.add(userId, SyncChange.allUnlocked(appid)); + } } return Outcome.VISIBLE; } @@ -300,9 +304,10 @@ public class AchievementSyncService { } /** - * @return the unlocked count, empty when Steam hides the achievements of this user + * @return the unlocked count and the number of achievements Steam listed, empty when Steam hides the achievements + * of this user */ - private OptionalInt refreshUser(Long userId, String steamId, Long appid, int playtimeMinutes) { + private Optional refreshUser(Long userId, String steamId, Long appid, int playtimeMinutes) { Optional response = steamApiService.getPlayerAchievements(steamId, appid); boolean visible = response.isPresent() && (response.get().success() == null || response.get().success()); List rows = visible && response.get().achievements() != null @@ -326,7 +331,7 @@ public class AchievementSyncService { userAchievementMetaRepository.save( new UserGameAchievementMeta(userId, appid, Instant.now(), visible, unlockedCount, playtimeAtRefresh)); }); - return visible ? OptionalInt.of(unlockedCount) : OptionalInt.empty(); + return visible ? Optional.of(new Unlocks(unlockedCount, rows.size())) : Optional.empty(); } /** @@ -364,5 +369,7 @@ public class AchievementSyncService { } } + private record Unlocks(int unlocked, int total) {} + private enum Outcome { UNCHANGED, VISIBLE, HIDDEN } } diff --git a/src/test/java/core_service/modules/library/repo/SyncChangeRepositoryTest.java b/src/test/java/core_service/modules/library/repo/SyncChangeRepositoryTest.java index 5a54c5c..308f42b 100644 --- a/src/test/java/core_service/modules/library/repo/SyncChangeRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/SyncChangeRepositoryTest.java @@ -269,6 +269,24 @@ class SyncChangeRepositoryTest extends AbstractPersistenceTest { assertThat(stored().seen()).isFalse(); } + @Test + void find_AllUnlockedOfACompletedGame_IsLeftOut() { + claim(USER_ID, FIRST_RUN); + repository.replace(USER_ID, FIRST_RUN, SyncTrigger.AUTO, 0, + List.of(SyncChange.allUnlocked(HALF_LIFE), SyncChange.allUnlocked(PORTAL))); + jdbc.update("update user_game set status = 'COMPLETED' where user_id = ? and appid = ?", USER_ID, HALF_LIFE); + + assertThat(stored().changes()).extracting(e -> e.change()).containsExactly(SyncChange.allUnlocked(PORTAL)); + } + + @Test + void find_AllUnlockedOfAHiddenGame_IsLeftOut() { + claim(USER_ID, FIRST_RUN); + repository.replace(USER_ID, FIRST_RUN, SyncTrigger.AUTO, 0, List.of(SyncChange.allUnlocked(HIDDEN))); + + assertThat(stored().changes()).isEmpty(); + } + @Test void find_LeavesWishlistRowsOut() { claim(USER_ID, FIRST_RUN); 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 9617b24..7024ed0 100644 --- a/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java +++ b/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java @@ -469,6 +469,58 @@ class AchievementSyncServiceTest extends AbstractUnitTest { verify(syncChangeRepository).add(USER_ID, SyncChange.achievements(620L, 2)); } + @Test + void syncLibrary_LastAchievementUnlocked_AddsAllUnlocked() { + library(List.of(game(620L, 150)), List.of(schema(620L, 5)), List.of(unlocks(620L, 3, 120))); + when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(visible("A", "B", "C", "D", "E")); + + service.syncLibrary(USER_ID, STEAM_ID); + + verify(syncChangeRepository).add(USER_ID, SyncChange.achievements(620L, 2)); + verify(syncChangeRepository).add(USER_ID, SyncChange.allUnlocked(620L)); + } + + @Test + void syncLibrary_LastAchievementOfACompletedGame_AddsNoAllUnlocked() { + UserGame completed = game(620L, 150); + completed.setStatus(GameStatus.COMPLETED); + library(List.of(completed), List.of(schema(620L, 5)), List.of(unlocks(620L, 3, 120))); + when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(visible("A", "B", "C", "D", "E")); + + service.syncLibrary(USER_ID, STEAM_ID); + + verify(syncChangeRepository).add(USER_ID, SyncChange.achievements(620L, 2)); + verify(syncChangeRepository, never()).add(USER_ID, SyncChange.allUnlocked(620L)); + } + + @Test + void syncLibrary_NewUnlocksWithAchievementsLeft_AddsNoAllUnlocked() { + library(List.of(game(620L, 150)), List.of(schema(620L, 5)), List.of(unlocks(620L, 3, 120))); + List rows = List.of( + new SteamPlayerAchievementsResponse.Achievement("A", 1, 1_700_000_000L), + new SteamPlayerAchievementsResponse.Achievement("B", 1, 1_700_000_000L), + new SteamPlayerAchievementsResponse.Achievement("C", 1, 1_700_000_000L), + new SteamPlayerAchievementsResponse.Achievement("D", 1, 1_700_000_000L), + new SteamPlayerAchievementsResponse.Achievement("E", 0, 0L)); + when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn( + Optional.of(new SteamPlayerAchievementsResponse.PlayerStats(STEAM_ID, "Game", rows, true, null))); + + service.syncLibrary(USER_ID, STEAM_ID); + + verify(syncChangeRepository).add(USER_ID, SyncChange.achievements(620L, 1)); + verify(syncChangeRepository, never()).add(USER_ID, SyncChange.allUnlocked(620L)); + } + + @Test + void syncLibrary_AllUnlockedBeforeTheSync_AddsNoAllUnlocked() { + library(List.of(game(620L, 150)), List.of(schema(620L, 5)), List.of(unlocks(620L, 5, 120))); + when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(visible("A", "B", "C", "D", "E")); + + service.syncLibrary(USER_ID, STEAM_ID); + + verify(syncChangeRepository, never()).add(anyLong(), any()); + } + @Test void syncLibrary_FirstRefreshOfAGame_AddsNoUnlockRow() { library(List.of(game(620L, 150)), List.of(schema(620L, 10)), List.of());