feat(library): add ALL_UNLOCKED sync change for last achievement
Build, Test & Push Core Service / quality-check (push) Successful in 2m30s
Build, Test & Push Core Service / docker (push) Successful in 1m35s

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
This commit is contained in:
lobo committed 2026-10-11 16:22:49 +02:00
1 parent 1de8acef2e
commit 0f56f5bb0f
7 files changed
+96 -9

No files matched your search

@@ -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);
}
@@ -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
}
@@ -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<SyncChanges> 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(
@@ -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<Unlocks> 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<Unlocks> refreshUser(Long userId, String steamId, Long appid, int playtimeMinutes) {
Optional<SteamPlayerAchievementsResponse.PlayerStats> response = steamApiService.getPlayerAchievements(steamId, appid);
boolean visible = response.isPresent() && (response.get().success() == null || response.get().success());
List<UserGameAchievement> 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 }
}
@@ -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);
@@ -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<SteamPlayerAchievementsResponse.Achievement> 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());