Public Access
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
This commit is contained in:
1 parent
1de8acef2e
commit
0f56f5bb0f
7 files changed
+96
-9
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user