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
|
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.
|
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
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant W as LibrarySyncWorker
|
participant W as LibrarySyncWorker
|
||||||
@@ -438,7 +440,7 @@ sequenceDiagram
|
|||||||
P-->>W: ADDED, PLAYTIME, ACHIEVEMENTS (trophy gains)
|
P-->>W: ADDED, PLAYTIME, ACHIEVEMENTS (trophy gains)
|
||||||
W->>D: sync_result, sync_change (ADDED, STATUS, WISHLIST, PSN changes)
|
W->>D: sync_result, sync_change (ADDED, STATUS, WISHLIST, PSN changes)
|
||||||
W-->>A: LibrarySyncedEvent
|
W-->>A: LibrarySyncedEvent
|
||||||
A->>D: sync_change (ACHIEVEMENTS)
|
A->>D: sync_change (ACHIEVEMENTS, ALL_UNLOCKED)
|
||||||
A-->>F: LibraryActivityEvent
|
A-->>F: LibraryActivityEvent
|
||||||
F->>D: lock follow_digest, count sync + hand changes
|
F->>D: lock follow_digest, count sync + hand changes
|
||||||
F->>D: inbox_note per follower (public, not blocked, not deleted)
|
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);
|
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) {
|
public static SyncChange wishlist(long appid) {
|
||||||
return new SyncChange(SyncChangeKind.WISHLIST, appid, null, null, null);
|
return new SyncChange(SyncChangeKind.WISHLIST, appid, null, null, null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ public enum SyncChangeKind {
|
|||||||
PLAYTIME,
|
PLAYTIME,
|
||||||
ACHIEVEMENTS,
|
ACHIEVEMENTS,
|
||||||
STATUS,
|
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.
|
// Only for followers' digests: never shown in the sync banner.
|
||||||
WISHLIST
|
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.
|
* 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) {
|
public Optional<SyncChanges> find(long userId) {
|
||||||
MapSqlParameterSource params = new MapSqlParameterSource("userId", 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
|
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
|
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'
|
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
|
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
|
order by c.kind, amount desc nulls last, sg.name
|
||||||
""", params, (rs, i) -> new SyncChanges.Entry(
|
""", params, (rs, i) -> new SyncChanges.Entry(
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ import java.util.HashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.OptionalInt;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
@@ -237,12 +236,17 @@ public class AchievementSyncService {
|
|||||||
return Outcome.UNCHANGED;
|
return Outcome.UNCHANGED;
|
||||||
}
|
}
|
||||||
pause();
|
pause();
|
||||||
OptionalInt unlocked = refreshUser(userId, steamId, appid, playtime);
|
Optional<Unlocks> refreshed = refreshUser(userId, steamId, appid, playtime);
|
||||||
if (unlocked.isEmpty()) return Outcome.HIDDEN;
|
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.
|
// 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,
|
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;
|
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);
|
Optional<SteamPlayerAchievementsResponse.PlayerStats> response = steamApiService.getPlayerAchievements(steamId, appid);
|
||||||
boolean visible = response.isPresent() && (response.get().success() == null || response.get().success());
|
boolean visible = response.isPresent() && (response.get().success() == null || response.get().success());
|
||||||
List<UserGameAchievement> rows = visible && response.get().achievements() != null
|
List<UserGameAchievement> rows = visible && response.get().achievements() != null
|
||||||
@@ -326,7 +331,7 @@ public class AchievementSyncService {
|
|||||||
userAchievementMetaRepository.save(
|
userAchievementMetaRepository.save(
|
||||||
new UserGameAchievementMeta(userId, appid, Instant.now(), visible, unlockedCount, playtimeAtRefresh));
|
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 }
|
private enum Outcome { UNCHANGED, VISIBLE, HIDDEN }
|
||||||
}
|
}
|
||||||
@@ -269,6 +269,24 @@ class SyncChangeRepositoryTest extends AbstractPersistenceTest {
|
|||||||
assertThat(stored().seen()).isFalse();
|
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
|
@Test
|
||||||
void find_LeavesWishlistRowsOut() {
|
void find_LeavesWishlistRowsOut() {
|
||||||
claim(USER_ID, FIRST_RUN);
|
claim(USER_ID, FIRST_RUN);
|
||||||
|
|||||||
@@ -469,6 +469,58 @@ class AchievementSyncServiceTest extends AbstractUnitTest {
|
|||||||
verify(syncChangeRepository).add(USER_ID, SyncChange.achievements(620L, 2));
|
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
|
@Test
|
||||||
void syncLibrary_FirstRefreshOfAGame_AddsNoUnlockRow() {
|
void syncLibrary_FirstRefreshOfAGame_AddsNoUnlockRow() {
|
||||||
library(List.of(game(620L, 150)), List.of(schema(620L, 10)), List.of());
|
library(List.of(game(620L, 150)), List.of(schema(620L, 10)), List.of());
|
||||||
|
|||||||
Reference in new issue
Block a user