Compare commits

...
6 Commits
Author SHA1 Message Date
lobo 0f56f5bb0f feat(library): add ALL_UNLOCKED sync change for last achievement
Build, Test & Push Core Service / docker (push) Successful in 1m35s
Build, Test & Push Core Service / quality-check (push) Successful in 2m30s
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
2026-10-11 16:22:49 +02:00
lobo 1de8acef2e fix(library): merge manual game into its Steam row on sync
Steam sync added a second row for a game the user added by hand (#9).
The manual row moves to the Steam appid and keeps its data, such as
status, notes, sets and PSN links. Manual rows on other platforms stay
and join the Steam game's set.
2026-10-11 14:55:09 +02:00
lobo 59d6b3394d fix(rewards): check last achievement unlock for manual COMPLETED
Refs #44. Check the last achievement unlock, not the first status change.
Old 100% games no longer pass. New reason COMPLETION_TOO_OLD.
Add earlySince to the games and wallet GET responses.
2026-10-11 13:51:34 +02:00
lobo 1a9a4e912c fix(rewards): let manual finish open COMPLETED for Steam edition
A paid FINISHED_MANUAL on a set edition blocked COMPLETED for the
Steam game forever. It now opens COMPLETED, but only when the Steam
game's first finish is on or after app.rewards.early-since. Refs #35
2026-10-11 13:07:26 +02:00
lobo 6215bb0dec fix(library): count trend never played by set total
Nightly snapshot and live trend point use the set total, as stats do.
Newest point now equals stats "never started". Old rows keep old value.
Refs #43
2026-10-11 12:11:47 +02:00
lobo 8f884040d8 fix(stats): count each set once by its set playtime
Played, never started, under one hour and oldest unplayed used the
counted edition's own minutes, so a set played only on PS5 showed as
never started. They and their drill-down lists now use the set total
(user_game_entry_playtime). Refs #38. Nightly snapshot (#43) unchanged.
2026-10-11 11:52:45 +02:00
34 changed files with 1328 additions and 88 deletions

No files matched your search

+18 -6
View File
@@ -122,8 +122,11 @@ flowchart LR
genres, moods, perfect games and the longest day list one entry per set, with the playtime of all its editions genres, moods, perfect games and the longest day list one entry per set, with the playtime of all its editions
(`user_game_entry_member`, V43; its list shows that sum as `minutes`). Every playtime number (most played, genres, (`user_game_entry_member`, V43; its list shows that sum as `minutes`). Every playtime number (most played, genres,
moods, the total playtime and moon trips, the drill-down lists) uses the set total, the view moods, the total playtime and moon trips, the drill-down lists) uses the set total, the view
`user_game_entry_playtime` (V45), the same as the library card, sort and public profile. Platforms stay per edition: `user_game_entry_playtime` (V45), the same as the library card, sort and public profile. So do "played", "never
a Steam + PlayStation set counts on both platforms. started" and "under one hour": a set counts once, judged by its set total (also the dashboard "never played" and the
oldest unplayed game) and the trend's never played (`backlog_snapshot`); rows written before #43 keep the old value,
they cannot be rebuilt.
Platforms stay per edition: a Steam + PlayStation set counts on both platforms.
- **Drill-down**: every countable number opens the games behind it. A `StatKey` (library), `CommunityKey` (landing page) - **Drill-down**: every countable number opens the games behind it. A `StatKey` (library), `CommunityKey` (landing page)
or a Pile Points track/bonus row names a number; its SQL "game set" is the same text the number counts, so the or a Pile Points track/bonus row names a number; its SQL "game set" is the same text the number counts, so the
list and the number cannot drift. 40 games per page (at most page 250), a fixed order per key. A profile visitor list and the number cannot drift. 40 games per page (at most page 250), a fixed order per key. A profile visitor
@@ -140,8 +143,9 @@ flowchart LR
proof of fresh play (ending achievement or playtime around the finish, or half of the IGDB time played since the finish). Without proof a Steam finish pays 10 early points (`FINISHED_EARLY`), only for finishes on or after proof of fresh play (ending achievement or playtime around the finish, or half of the IGDB time played since the finish). Without proof a Steam finish pays 10 early points (`FINISHED_EARLY`), only for finishes on or after
`app.rewards.early-since` (older unproven finishes pay only with proof); when proof arrives later, `FINISHED_TOPUP` pays the rest up to the full amount, once per game or set. PlayStation days never prove: a game linked to a PlayStation title pays by its own rules, only `app.rewards.early-since` (older unproven finishes pay only with proof); when proof arrives later, `FINISHED_TOPUP` pays the rest up to the full amount, once per game or set. PlayStation days never prove: a game linked to a PlayStation title pays by its own rules, only
PlayStation rows pay nothing. The month bonus needs a finished game in that month. Games added by hand pay 15 for PlayStation rows pay nothing. The month bonus needs a finished game in that month. Games added by hand pay 15 for
a finish. Manual finishes and early finishes share the cap of 3 per ISO week. `COMPLETED` needs a `FINISHED` or `FINISHED_TOPUP` row. a finish. Manual finishes and early finishes share the cap of 3 per ISO week. `COMPLETED` needs a `FINISHED`, `FINISHED_TOPUP` or `FINISHED_MANUAL` row on the game or its set, so a manual edition's finish opens it for the Steam edition, but only when the Steam game's last achievement
`GET /api/rewards/expected?appids=` (read-only, at most 200) answers the full finish value per game for the Questions cards. `GET /api/rewards/games/{appid}` (game page open, after every status save) settles, then answers the paid rows of the game and its set (`bookedNow` = written by this call) and one `NoPointsReason` per missing kind (`STARTED`, `FINISHED`, `COMPLETED`); the codes live in `RewardRules`, the Frontend only maps them to texts. The settle takes a per-user advisory lock. V26 also pays a one-time starter grant unlock (100 % reached) is on or after `app.rewards.early-since` (else `COMPLETION_TOO_OLD`); a manual game itself never gets it.
`GET /api/rewards/expected?appids=` (read-only, at most 200) answers the full finish value per game for the Questions cards. `GET /api/rewards/games/{appid}` (game page open, after every status save) settles, then answers the paid rows of the game and its set (`bookedNow` = written by this call) and one `NoPointsReason` per missing kind (`STARTED`, `FINISHED`, `COMPLETED`); the codes live in `RewardRules`, the Frontend only maps them to texts; `earlySince` (ISO date, `app.rewards.early-since`) fills the date in the texts. The settle takes a per-user advisory lock. V26 also pays a one-time starter grant
for finishes from before the status log. Shop: a whitelist of 57 looks (`ShopItem`; 45 buyable and 12 unlock-only) in eight slots, including animated completed effects, page backgrounds (CSS, SVG and two canvas engines in the Frontend), for finishes from before the status log. Shop: a whitelist of 57 looks (`ShopItem`; 45 buyable and 12 unlock-only) in eight slots, including animated completed effects, page backgrounds (CSS, SVG and two canvas engines in the Frontend),
banner backgrounds (V27 widens `reward_equipped.slot`) and three buyable skins (full themes, slot `SKIN`). The unlock-only banner backgrounds (V27 widens `reward_equipped.slot`) and three buyable skins (full themes, slot `SKIN`). The unlock-only
items (price 0, never sold): the settle books an `UNLOCK` row (ref = item code, 0 points) once a items (price 0, never sold): the settle books an `UNLOCK` row (ref = item code, 0 points) once a
@@ -221,7 +225,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| Method | Path | Auth | | Method | Path | Auth |
|---|---|---| |---|---|---|
| `GET` | `/api/rewards/wallet` (balance, catalog, owned, equipped, last 50 ledger rows with the game name, unlock `progress` per track) | JWT | | `GET` | `/api/rewards/wallet` (balance, catalog, owned, equipped, last 50 ledger rows with the game name, unlock `progress` per track, `earlySince` for the rules text) | JWT |
| `GET` | `/api/rewards/expected?appids=` (`{appid: points}`, the full finish value, 0 when paid; read-only, no settle; `400 TOO_MANY` over 200 appids) | JWT | | `GET` | `/api/rewards/expected?appids=` (`{appid: points}`, the full finish value, 0 when paid; read-only, no settle; `400 TOO_MANY` over 200 appids) | JWT |
| `POST` | `/api/rewards/purchases` (`{item}`; `200` wallet, `409 ALREADY_OWNED` / `NOT_ENOUGH_POINTS` / `NOT_FOR_SALE`) | JWT | | `POST` | `/api/rewards/purchases` (`{item}`; `200` wallet, `409 ALREADY_OWNED` / `NOT_ENOUGH_POINTS` / `NOT_FOR_SALE`) | JWT |
| `GET` | `/api/rewards/progress/{track}/games?page=`, `/api/rewards/history/{kind}/{ref}/games?page=` (the games behind a track or a `MONTH` / `STREAK` bonus; `400 NOT_A_GAME_LIST`) | JWT | | `GET` | `/api/rewards/progress/{track}/games?page=`, `/api/rewards/history/{kind}/{ref}/games?page=` (the games behind a track or a `MONTH` / `STREAK` bonus; `400 NOT_A_GAME_LIST`) | JWT |
@@ -398,6 +402,12 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain`
no old playtime, and one game gets at most 24 hours per day. Steam's `rtime_last_played` (unix seconds, 0 = never) no old playtime, and one game gets at most 24 hours per day. Steam's `rtime_last_played` (unix seconds, 0 = never)
is stored in `user_game.last_played_at`; a missing or zero value keeps the old one. A BACKLOG game with new playtime becomes PLAYING, unless the user chose its status. When Steam returns no is stored in `user_game.last_played_at`; a missing or zero value keeps the old one. A BACKLOG game with new playtime becomes PLAYING, unless the user chose its status. When Steam returns no
games, the library stays as it is. games, the library stays as it is.
A game the user added by hand for PC, Steam, Mac or Linux (or with no platform), whose IGDB game is the IGDB
game of a Steam appid the user owns for the first time (`game_edition`), becomes that Steam row. The row moves
from minus its IGDB id to the appid and keeps status, rating, notes, priority, status history, backlog
position, questions, set rows, PlayStation links and paid game rewards. Playtime and last played come from
Steam. A hand-added game on another platform stays and joins the Steam game's set. A user with such a game
gets the IGDB edition refresh before the save, so the mapping is known in time.
5. `TimeToBeatService` and `MoodService` ask IGDB for missing or old time-to-beat, mood and genre data, for Steam 5. `TimeToBeatService` and `MoodService` ask IGDB for missing or old time-to-beat, mood and genre data, for Steam
games and manual games. Hidden games only get their playtime updated. games and manual games. Hidden games only get their playtime updated.
6. After the commit, `LibrarySyncedEvent` starts `AchievementSyncService` and `SpeedrunService` (backlog games only) 6. After the commit, `LibrarySyncedEvent` starts `AchievementSyncService` and `SpeedrunService` (backlog games only)
@@ -416,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
@@ -428,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)
@@ -24,6 +24,8 @@ public record UserStatsDto(
) { ) {
/** /**
* @param playedGames games with a set total above 0
* @param neverStarted games with a set total of 0
* @param byStatus has every status, also those with 0 games * @param byStatus has every status, also those with 0 games
*/ */
public record Overview(long totalGames, long totalPlaytimeMinutes, long playedGames, long neverStarted, public record Overview(long totalGames, long totalPlaytimeMinutes, long playedGames, long neverStarted,
@@ -56,6 +58,7 @@ public record UserStatsDto(
/** /**
* @param moonTrips the total playtime as flights to the moon and back (Apollo 11, 195.3 hours) * @param moonTrips the total playtime as flights to the moon and back (Apollo 11, 195.3 hours)
* @param longestDay the day with the most playtime; null without history * @param longestDay the day with the most playtime; null without history
* @param underOneHourGames games whose set total is 1 to 59 minutes
* @param underOneHourShare share of the started games with less than one hour; null when none is started * @param underOneHourShare share of the started games with less than one hour; null when none is started
*/ */
public record Habits(double moonTrips, DayMinutes longestDay, long underOneHourGames, Double underOneHourShare) {} public record Habits(double moonTrips, DayMinutes longestDay, long underOneHourGames, Double underOneHourShare) {}
@@ -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
} }
@@ -12,6 +12,7 @@ import java.util.List;
/** /**
* The daily pile size per user, table backlog_snapshot. Counts listed games only, like {@link UserGameRepository#LISTED}, and a linked set once (V28). * The daily pile size per user, table backlog_snapshot. Counts listed games only, like {@link UserGameRepository#LISTED}, and a linked set once (V28).
* Never played is judged by the set total (V45), like {@link UserGameRepository#countListedNeverPlayed}.
*/ */
@Repository @Repository
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -19,11 +20,12 @@ public class BacklogSnapshotRepository {
private static final String PILE_COLUMNS = """ private static final String PILE_COLUMNS = """
count(*) filter (where ug.status = 'BACKLOG' and cg.appid is null) as backlog_games, count(*) filter (where ug.status = 'BACKLOG' and cg.appid is null) as backlog_games,
count(*) filter (where ug.playtime_minutes = 0) as never_played, count(*) filter (where ep.playtime_minutes = 0) as never_played,
coalesce(sum(t.normally_minutes) filter (where ug.status = 'BACKLOG' and cg.appid is null), 0) as backlog_minutes, coalesce(sum(t.normally_minutes) filter (where ug.status = 'BACKLOG' and cg.appid is null), 0) as backlog_minutes,
count(*) as listed_games count(*) as listed_games
from counted_user_game ug left join game_time_to_beat t on t.appid = ug.appid from counted_user_game ug left join game_time_to_beat t on t.appid = ug.appid
left join coming_game cg on cg.appid = ug.appid left join coming_game cg on cg.appid = ug.appid
left join user_game_entry_playtime ep on ep.user_id = ug.user_id and ep.appid = ug.appid
"""; """;
private final NamedParameterJdbcTemplate jdbc; private final NamedParameterJdbcTemplate jdbc;
@@ -20,7 +20,8 @@ import java.util.Map;
import java.util.Optional; import java.util.Optional;
/** /**
* Merges one library key into another (V21). Plain SQL: a merge rewrites the key of rows in many tables at once. * Merges one library key into another (V21), and moves one user's row to another key (Steam takeover).
* Plain SQL: a merge rewrites the key of rows in many tables at once.
*/ */
@Repository @Repository
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -39,6 +40,10 @@ public class GameMergeRepository {
+ " and not exists (select 1 from reward_ledger o where o.user_id = l.user_id and o.kind = l.kind" + " and not exists (select 1 from reward_ledger o where o.user_id = l.user_id and o.kind = l.kind"
+ " and o.ref = cast(:to as text))"; + " and o.ref = cast(:to as text))";
// A row added by hand for PC, Steam, Mac or Linux (or with no platform) is the Steam copy.
private static final String TAKEOVER_PLATFORM =
"lower(btrim(coalesce(ug.platform, ''))) in ('', 'pc', 'steam', 'mac', 'linux')";
// What the snapshot saves besides the catalog tables: the link sets of both keys and the pairs and requests of from. // What the snapshot saves besides the catalog tables: the link sets of both keys and the pairs and requests of from.
private static final String LINK_SET_ROWS = "select * from game_link where set_id in " private static final String LINK_SET_ROWS = "select * from game_link where set_id in "
+ "(select set_id from game_link where appid in (:from, :to))"; + "(select set_id from game_link where appid in (:from, :to))";
@@ -366,4 +371,75 @@ public class GameMergeRepository {
return jdbc.update("delete from game_merge_undo where created_at < :cutoff", return jdbc.update("delete from game_merge_undo where created_at < :cutoff",
new MapSqlParameterSource("cutoff", Timestamp.from(cutoff))); new MapSqlParameterSource("cutoff", Timestamp.from(cutoff)));
} }
public boolean hasSteamTakeoverCandidates(long userId) {
return Boolean.TRUE.equals(jdbc.queryForObject("""
select exists (select 1 from user_game ug
where ug.user_id = :user and ug.appid < 0 and ug.source = 'MANUAL' and %s)
""".formatted(TAKEOVER_PLATFORM), new MapSqlParameterSource("user", userId), Boolean.class));
}
/**
* @return the keys of {@code appids} the user has no row for and whose stored edition has no IGDB game
*/
public List<Long> findUnmappedNewlyOwned(long userId, Collection<Long> appids) {
if (appids.isEmpty()) {
return List.of();
}
return jdbc.queryForList("""
select e.appid from game_edition e
where e.appid in (:appids) and e.igdb_id is null
and not exists (select 1 from user_game t where t.user_id = :user and t.appid = e.appid)
""", new MapSqlParameterSource("user", userId).addValue("appids", appids), Long.class);
}
/**
* @return a manual key of the user, by the Steam appid that takes its row; only a Steam appid the user has no row for
*/
public Map<Long, Long> findSteamTakeovers(long userId, Collection<Long> steamAppids) {
if (steamAppids.isEmpty()) {
return Map.of();
}
Map<Long, Long> takeovers = new LinkedHashMap<>();
jdbc.query("""
select distinct on (ug.appid) ug.appid as from_appid, e.appid as to_appid
from user_game ug
join game_edition e on e.igdb_id = -ug.appid and e.appid in (:appids)
where ug.user_id = :user and ug.appid < 0 and ug.source = 'MANUAL' and %s
and not exists (select 1 from user_game t where t.user_id = ug.user_id and t.appid = e.appid)
order by ug.appid, e.appid
""".formatted(TAKEOVER_PLATFORM),
new MapSqlParameterSource("user", userId).addValue("appids", steamAppids),
rs -> { takeovers.put(rs.getLong("from_appid"), rs.getLong("to_appid")); });
return takeovers;
}
/**
* Moves one user's row to another key. The cascading FKs move status log, backlog order, questions, splits and
* set backups. Needs the steam_game row of {@code to} flushed.
*
* @return false when the user has no row on {@code from}, or already has one on {@code to}
*/
public boolean rekeyUserGame(long userId, long from, long to) {
MapSqlParameterSource params = new MapSqlParameterSource("user", userId).addValue("from", from).addValue("to", to);
if (jdbc.update("""
update user_game set appid = :to where user_id = :user and appid = :from
and not exists (select 1 from user_game t where t.user_id = :user and t.appid = :to)
""", params) == 0) {
return false;
}
// A day both keys have adds up on the target.
jdbc.update("""
insert into user_game_playtime_day (user_id, day, appid, minutes)
select user_id, day, :to, minutes from user_game_playtime_day where user_id = :user and appid = :from
on conflict (user_id, day, appid) do update
set minutes = user_game_playtime_day.minutes + excluded.minutes
""", params);
jdbc.update("delete from user_game_playtime_day where user_id = :user and appid = :from", params);
jdbc.update("update user_psn_title set appid = :to where user_id = :user and appid = :from", params);
jdbc.update("update user_psn_trophy_set set appid = :to where user_id = :user and appid = :from", params);
jdbc.update("update reward_ledger l set ref = cast(:to as text) where l.user_id = :user and " + LEDGER_MOVED,
params);
return true;
}
} }
@@ -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(
@@ -123,7 +123,8 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
nativeQuery = true) nativeQuery = true)
List<RecentEntry> findEntriesPlayedInTwoWeeks(@Param("userId") Long userId, @Param("limit") int limit); List<RecentEntry> findEntriesPlayedInTwoWeeks(@Param("userId") Long userId, @Param("limit") int limit);
@Query("select count(c) from CountedUserGame c where c.userId = :userId and c.playtimeMinutes = 0") /** The entries whose set total (V45) is 0. */
@Query("select count(ep) from UserGameEntryPlaytime ep where ep.userId = :userId and ep.playtimeMinutes = 0")
long countListedNeverPlayed(@Param("userId") Long userId); long countListedNeverPlayed(@Param("userId") Long userId);
@Query("select new core_service.modules.library.domain.StatusCount(c.status, count(c)) from CountedUserGame c " + @Query("select new core_service.modules.library.domain.StatusCount(c.status, count(c)) from CountedUserGame c " +
@@ -114,11 +114,11 @@ public class UserStatsRepository {
private static final Map<StatKey, String> SETS = Map.ofEntries( private static final Map<StatKey, String> SETS = Map.ofEntries(
Map.entry(StatKey.TOTAL, counted("true")), Map.entry(StatKey.TOTAL, counted("true")),
Map.entry(StatKey.STATUS, counted("ug.status = :arg")), Map.entry(StatKey.STATUS, counted("ug.status = :arg")),
Map.entry(StatKey.PLAYED, counted("ug.playtime_minutes > 0")), Map.entry(StatKey.PLAYED, entries("ug.playtime_minutes > 0")),
Map.entry(StatKey.NEVER_STARTED, counted("ug.playtime_minutes = 0")), Map.entry(StatKey.NEVER_STARTED, entries("ug.playtime_minutes = 0")),
Map.entry(StatKey.PILE, counted("ug.status = 'BACKLOG' and " + UserGameRepository.NOT_COMING)), Map.entry(StatKey.PILE, counted("ug.status = 'BACKLOG' and " + UserGameRepository.NOT_COMING)),
Map.entry(StatKey.FINISHED_ALL, counted("ug.status in " + FINISHED)), Map.entry(StatKey.FINISHED_ALL, counted("ug.status in " + FINISHED)),
Map.entry(StatKey.UNDER_ONE_HOUR, counted(UNDER_ONE_HOUR)), Map.entry(StatKey.UNDER_ONE_HOUR, entries(UNDER_ONE_HOUR)),
Map.entry(StatKey.FINISHED_UNTRACKED, counted(UNTRACKED)), Map.entry(StatKey.FINISHED_UNTRACKED, counted(UNTRACKED)),
Map.entry(StatKey.PILE_TIME_TO_BEAT, PILE_TTB_SET), Map.entry(StatKey.PILE_TIME_TO_BEAT, PILE_TTB_SET),
Map.entry(StatKey.MOST_PLAYED, "select ug.appid, ug.playtime_minutes as minutes, " + NO_EVENT + ", " + NO_MOVE Map.entry(StatKey.MOST_PLAYED, "select ug.appid, ug.playtime_minutes as minutes, " + NO_EVENT + ", " + NO_MOVE
@@ -168,9 +168,10 @@ public class UserStatsRepository {
return jdbc.query(""" return jdbc.query("""
select ug.appid, sg.name, sg.image_url, ug.first_seen_at select ug.appid, sg.name, sg.image_url, ug.first_seen_at
from counted_user_game ug join steam_game sg on sg.appid = ug.appid from counted_user_game ug join steam_game sg on sg.appid = ug.appid
where %s and ug.status = 'BACKLOG' and ug.playtime_minutes = 0 and %s join %s ep on ep.appid = ug.appid
where %s and ug.status = 'BACKLOG' and ep.playtime_minutes = 0 and %s
order by ug.first_seen_at, ug.appid limit 1 order by ug.first_seen_at, ug.appid limit 1
""".formatted(OF_USER, UserGameRepository.NOT_COMING), user(userId), """.formatted(ENTRY_PLAYTIME, OF_USER, UserGameRepository.NOT_COMING), user(userId),
(rs, i) -> new UserStats.OldestGame(rs.getLong("appid"), rs.getString("name"), (rs, i) -> new UserStats.OldestGame(rs.getLong("appid"), rs.getString("name"),
rs.getString("image_url"), rs.getTimestamp("first_seen_at").toInstant())) rs.getString("image_url"), rs.getTimestamp("first_seen_at").toInstant()))
.stream().findFirst(); .stream().findFirst();
@@ -251,8 +252,8 @@ public class UserStatsRepository {
return jdbc.queryForObject(""" return jdbc.queryForObject("""
select count(*) filter (where ug.playtime_minutes > 0) as started, select count(*) filter (where ug.playtime_minutes > 0) as started,
count(*) filter (where %s) as under_one_hour count(*) filter (where %s) as under_one_hour
from counted_user_game ug where %s from %s ug
""".formatted(UNDER_ONE_HOUR, OF_USER), user(userId), """.formatted(UNDER_ONE_HOUR, ENTRY_PLAYTIME), user(userId),
(rs, i) -> new UserStats.PlayedCounts(rs.getLong("started"), rs.getLong("under_one_hour"))); (rs, i) -> new UserStats.PlayedCounts(rs.getLong("started"), rs.getLong("under_one_hour")));
} }
@@ -321,6 +322,11 @@ public class UserStatsRepository {
return games != null ? games : 0L; return games != null ? games : 0L;
} }
/** The entries whose set total (V45) matches {@code where}; alias ug. */
private static String entries(String where) {
return "select ug.appid, %s from %s ug where %s".formatted(NO_STAT, ENTRY_PLAYTIME, where);
}
private static String counted(String where) { private static String counted(String where) {
return "select ug.appid, %s from counted_user_game ug where %s and %s".formatted(NO_STAT, OF_USER, where); return "select ug.appid, %s from counted_user_game ug where %s and %s".formatted(NO_STAT, OF_USER, where);
} }
@@ -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 }
} }
@@ -45,10 +45,25 @@ public class GameEditionService {
this.transaction = new TransactionTemplate(transactionManager); this.transaction = new TransactionTemplate(transactionManager);
} }
public void refresh(Collection<Long> appids) { /**
if (!igdbApiService.isEnabled() || appids.isEmpty()) return; * @return the keys stored now; linkSameGames ran for them, but a key without a steam_game row is not linked yet
Set<Long> stale = stale(appids); */
if (stale.isEmpty()) return; public Set<Long> refresh(Collection<Long> appids) {
if (!igdbApiService.isEnabled() || appids.isEmpty()) return Set.of();
return store(stale(appids));
}
/**
* Like {@link #refresh}, but for keys whose stored edition has no IGDB game: IGDB may know them by now, and a
* fresh null would block a takeover until the TTL ends.
*/
public Set<Long> refreshAgain(Collection<Long> appids) {
if (!igdbApiService.isEnabled() || appids.isEmpty()) return Set.of();
return store(Set.copyOf(appids));
}
private Set<Long> store(Set<Long> stale) {
if (stale.isEmpty()) return Set.of();
IgdbLookup.Answer<IgdbApiService.Edition> answer = IgdbLookup.byAppid(stale, igdbApiService::getEditions, IgdbLookup.Answer<IgdbApiService.Edition> answer = IgdbLookup.byAppid(stale, igdbApiService::getEditions,
igdbApiService::getEditionsByGameIds); igdbApiService::getEditionsByGameIds);
@@ -63,6 +78,7 @@ public class GameEditionService {
edition == null ? null : edition.sameGameId(), now); edition == null ? null : edition.sameGameId(), now);
} }
linkSameGames(answer.answered()); linkSameGames(answer.answered());
return answer.answered();
} }
/** /**
@@ -10,6 +10,7 @@ import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserSteamSync; import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.domain.WishlistGame; import core_service.modules.library.domain.WishlistGame;
import core_service.modules.library.repo.GameCorrectionRepository; import core_service.modules.library.repo.GameCorrectionRepository;
import core_service.modules.library.repo.GameMergeRepository;
import core_service.modules.library.repo.PlaytimeDayRepository; import core_service.modules.library.repo.PlaytimeDayRepository;
import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.SyncChangeRepository; import core_service.modules.library.repo.SyncChangeRepository;
@@ -59,6 +60,7 @@ public class LibrarySyncWorker {
private final SteamApiService steamApiService; private final SteamApiService steamApiService;
private final SteamGameRepository steamGameRepository; private final SteamGameRepository steamGameRepository;
private final GameCorrectionRepository gameCorrectionRepository; private final GameCorrectionRepository gameCorrectionRepository;
private final GameMergeRepository gameMergeRepository;
private final UserGameRepository userGameRepository; private final UserGameRepository userGameRepository;
private final UserSteamSyncRepository userSteamSyncRepository; private final UserSteamSyncRepository userSteamSyncRepository;
private final SyncChangeRepository syncChangeRepository; private final SyncChangeRepository syncChangeRepository;
@@ -137,6 +139,16 @@ public class LibrarySyncWorker {
} }
// Before the empty check: an account without games can still have a public wishlist. // Before the empty check: an account without games can still have a public wishlist.
Optional<Wishlist> wishlist = hasSteam ? fetchWishlist(steamId, games) : Optional.empty(); Optional<Wishlist> wishlist = hasSteam ? fetchWishlist(steamId, games) : Optional.empty();
List<Long> ownedAppids = games.stream().map(g -> g.appid().longValue()).toList();
// A game added by hand can be a Steam game this sync owns for the first time. Its mapping (game_edition)
// must be known before the save; the refresh after the save then skips these keys.
Set<Long> learned = new HashSet<>();
if (!games.isEmpty() && gameMergeRepository.hasSteamTakeoverCandidates(userId)) {
// First: a fresh edition without an IGDB game (stored before IGDB knew the app) is asked again.
learned.addAll(gameEditionService.refreshAgain(
gameMergeRepository.findUnmappedNewlyOwned(userId, ownedAppids)));
learned.addAll(gameEditionService.refresh(ownedAppids));
}
PsnSyncStep.Prepared psn = psnSyncStep.run(userId); PsnSyncStep.Prepared psn = psnSyncStep.run(userId);
boolean saved = transaction.execute(tx -> { boolean saved = transaction.execute(tx -> {
List<SyncChange> wishlistAdds = wishlist.map(found -> saveWishlist(userId, found)).orElseGet(() -> { List<SyncChange> wishlistAdds = wishlist.map(found -> saveWishlist(userId, found)).orElseGet(() -> {
@@ -174,8 +186,12 @@ public class LibrarySyncWorker {
if (!saved) { if (!saved) {
return; return;
} }
// Keys stored before their steam_game row existed were not linked by that refresh.
if (!learned.isEmpty()) {
gameEditionService.linkSameGames(learned);
}
// Also the games added by hand: here a failed refresh from the add is asked again. // Also the games added by hand: here a failed refresh from the add is asked again.
List<Long> igdbAppids = new ArrayList<>(games.stream().map(g -> g.appid().longValue()).toList()); List<Long> igdbAppids = new ArrayList<>(ownedAppids);
igdbAppids.addAll(userGameRepository.findActiveManualAppids(userId)); igdbAppids.addAll(userGameRepository.findActiveManualAppids(userId));
igdbAppids.removeAll(new HashSet<>(userGameRepository.findHiddenAppids(userId))); igdbAppids.removeAll(new HashSet<>(userGameRepository.findHiddenAppids(userId)));
timeToBeatService.refresh(igdbAppids); timeToBeatService.refresh(igdbAppids);
@@ -214,6 +230,10 @@ public class LibrarySyncWorker {
Set<Long> correctedNames = gameCorrectionRepository.findCorrectedAppids(CorrectionField.NAME, appids); Set<Long> correctedNames = gameCorrectionRepository.findCorrectedAppids(CorrectionField.NAME, appids);
Map<Long, SteamGame> knownGames = steamGameRepository.findAllById(appids).stream() Map<Long, SteamGame> knownGames = steamGameRepository.findAllById(appids).stream()
.collect(Collectors.toMap(SteamGame::getAppid, game -> game)); .collect(Collectors.toMap(SteamGame::getAppid, game -> game));
for (SteamOwnedGamesResponse.Game g : games) {
upsertGame(g, knownGames.get(g.appid().longValue()), now, correctedNames);
}
takeOverManualGames(userId, appids);
Map<Long, UserGame> userGames = userGameRepository.findByUserId(userId).stream() Map<Long, UserGame> userGames = userGameRepository.findByUserId(userId).stream()
.collect(Collectors.toMap(UserGame::getAppid, game -> game)); .collect(Collectors.toMap(UserGame::getAppid, game -> game));
List<SyncChange> changes = new ArrayList<>(); List<SyncChange> changes = new ArrayList<>();
@@ -226,7 +246,6 @@ public class LibrarySyncWorker {
List<Long> played = new ArrayList<>(); List<Long> played = new ArrayList<>();
for (SteamOwnedGamesResponse.Game g : games) { for (SteamOwnedGamesResponse.Game g : games) {
Long appid = g.appid().longValue(); Long appid = g.appid().longValue();
upsertGame(g, knownGames.get(appid), now, correctedNames);
if (upsertUserGame(userId, g, userGames.get(appid), now, playedOn, changes)) { if (upsertUserGame(userId, g, userGames.get(appid), now, playedOn, changes)) {
played.add(appid); played.add(appid);
} }
@@ -243,6 +262,21 @@ public class LibrarySyncWorker {
return changes; return changes;
} }
/**
* A game the user added by hand for a PC platform becomes the Steam row of its appid: the row moves to the
* Steam key here, and upsertUserGame takes it over. Runs before any UserGame is loaded in this transaction,
* so the persistence context holds no row under the old key.
*/
private void takeOverManualGames(Long userId, List<Long> appids) {
// user_game refers to steam_game; the JDBC update below does not flush JPA.
steamGameRepository.flush();
gameMergeRepository.findSteamTakeovers(userId, appids).forEach((from, to) -> {
if (gameMergeRepository.rekeyUserGame(userId, from, to)) {
log.info("Sync user={} moved the game added by hand appid={} to its Steam appid={}", userId, from, to);
}
});
}
private record Wishlist(List<SteamApiService.WishlistItem> wanted, Map<Long, SteamApiService.StoreItem> store) {} private record Wishlist(List<SteamApiService.WishlistItem> wanted, Map<Long, SteamApiService.StoreItem> store) {}
private Optional<Wishlist> fetchWishlist(String steamId, List<SteamOwnedGamesResponse.Game> ownedGames) { private Optional<Wishlist> fetchWishlist(String steamId, List<SteamOwnedGamesResponse.Game> ownedGames) {
@@ -304,6 +338,7 @@ public class LibrarySyncWorker {
Long appid = g.appid().longValue(); Long appid = g.appid().longValue();
int playtime = g.playtimeForever() != null ? g.playtimeForever() : 0; int playtime = g.playtimeForever() != null ? g.playtimeForever() : 0;
// Hours typed by hand are no Steam playtime, so the first Steam value of a game added by hand is a baseline. // Hours typed by hand are no Steam playtime, so the first Steam value of a game added by hand is a baseline.
// Also a row moved here from minus its IGDB id (takeOverManualGames).
boolean takesOverManualGame = existing != null && existing.getSource() != GameSource.STEAM; boolean takesOverManualGame = existing != null && existing.getSource() != GameSource.STEAM;
int previousPlaytime = existing != null ? existing.getPlaytimeMinutes() : 0; int previousPlaytime = existing != null ? existing.getPlaytimeMinutes() : 0;
int playtime2Weeks = g.playtime2Weeks() != null ? g.playtime2Weeks() : 0; int playtime2Weeks = g.playtime2Weeks() != null ? g.playtime2Weeks() : 0;
@@ -6,14 +6,17 @@ import core_service.modules.rewards.domain.RewardKind;
import core_service.modules.rewards.service.RewardService; import core_service.modules.rewards.service.RewardService;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* @param paid the game rows of the game and the other editions of its set * @param paid the game rows of the game and the other editions of its set
* @param earlySince app.rewards.early-since, for the text of COMPLETION_TOO_OLD
* @param missing why a kind is not paid; STARTED, FINISHED (both finish kinds) and COMPLETED * @param missing why a kind is not paid; STARTED, FINISHED (both finish kinds) and COMPLETED
*/ */
public record GamePointsDto(long balance, List<PaidDto> paid, Map<RewardKind, NoPointsReason> missing) { public record GamePointsDto(long balance, List<PaidDto> paid, Map<RewardKind, NoPointsReason> missing,
LocalDate earlySince) {
/** /**
* @param appid the edition the row was paid for * @param appid the edition the row was paid for
@@ -27,6 +30,7 @@ public record GamePointsDto(long balance, List<PaidDto> paid, Map<RewardKind, No
} }
public static GamePointsDto of(RewardService.GamePoints points) { public static GamePointsDto of(RewardService.GamePoints points) {
return new GamePointsDto(points.balance(), points.paid().stream().map(PaidDto::of).toList(), points.missing()); return new GamePointsDto(points.balance(), points.paid().stream().map(PaidDto::of).toList(), points.missing(),
points.earlySince());
} }
} }
@@ -7,6 +7,7 @@ import core_service.modules.rewards.domain.Track;
import core_service.modules.rewards.service.RewardService; import core_service.modules.rewards.service.RewardService;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -16,9 +17,10 @@ import java.util.Set;
* @param owned the item codes the user may wear * @param owned the item codes the user may wear
* @param equipped the worn item code per slot * @param equipped the worn item code per slot
* @param progress the value of each unlock track * @param progress the value of each unlock track
* @param earlySince app.rewards.early-since, for the rules text
*/ */
public record WalletDto(long balance, List<ItemDto> catalog, Set<String> owned, Map<LookSlot, String> equipped, public record WalletDto(long balance, List<ItemDto> catalog, Set<String> owned, Map<LookSlot, String> equipped,
List<EntryDto> history, Map<Track, Integer> progress) { List<EntryDto> history, Map<Track, Integer> progress, LocalDate earlySince) {
/** /**
* @param track null when the item is buyable * @param track null when the item is buyable
@@ -38,6 +40,6 @@ public record WalletDto(long balance, List<ItemDto> catalog, Set<String> owned,
wallet.owned(), wallet.equipped(), wallet.owned(), wallet.equipped(),
wallet.history().stream() wallet.history().stream()
.map(h -> new EntryDto(h.kind(), h.ref(), h.gameName(), h.points(), h.eventAt())).toList(), .map(h -> new EntryDto(h.kind(), h.ref(), h.gameName(), h.points(), h.eventAt())).toList(),
wallet.progress()); wallet.progress(), wallet.earlySince());
} }
} }
@@ -8,6 +8,8 @@ package core_service.modules.rewards.domain;
* @param startLogged a BACKLOG to PLAYING change is in the status log * @param startLogged a BACKLOG to PLAYING change is in the status log
* @param finishLogged a change to FINISHED or COMPLETED is in the status log * @param finishLogged a change to FINISHED or COMPLETED is in the status log
* @param linkedTitle a PlayStation title is linked to the game, so its playtime days prove nothing * @param linkedTitle a PlayStation title is linked to the game, so its playtime days prove nothing
* @param allAchievements the game has achievements and the user unlocked all of them
*/ */
public record GameRewardFacts(boolean manual, boolean finished, boolean completed, boolean hidden, boolean playstation, public record GameRewardFacts(boolean manual, boolean finished, boolean completed, boolean hidden, boolean playstation,
boolean startLogged, boolean finishLogged, boolean linkedTitle) {} boolean startLogged, boolean finishLogged, boolean linkedTitle,
boolean allAchievements) {}
@@ -30,8 +30,13 @@ public enum NoPointsReason {
WEEKLY_LIMIT, WEEKLY_LIMIT,
/** The weekly cap is used up, so no early points; proof still pays the full finish. */ /** The weekly cap is used up, so no early points; proof still pays the full finish. */
WEEKLY_LIMIT_NO_PROOF, WEEKLY_LIMIT_NO_PROOF,
/** COMPLETED needs a FINISHED or FINISHED_TOPUP row first. */ /**
* COMPLETED needs a FINISHED or FINISHED_TOPUP row on the game or its set first, or a FINISHED_MANUAL row on the
* set when the game reached 100 % on or after early-since.
*/
NEEDS_PROVEN_FINISH, NEEDS_PROVEN_FINISH,
/** A FINISHED_MANUAL row is paid, but the game's last achievement unlock is before early-since. */
COMPLETION_TOO_OLD,
/** COMPLETED needs all achievements. */ /** COMPLETED needs all achievements. */
NOT_ALL_ACHIEVEMENTS NOT_ALL_ACHIEVEMENTS
} }
@@ -30,6 +30,11 @@ public enum RewardKind {
*/ */
public static final List<RewardKind> FINISH_KINDS = List.of(FINISHED, FINISHED_MANUAL, FINISHED_EARLY); public static final List<RewardKind> FINISH_KINDS = List.of(FINISHED, FINISHED_MANUAL, FINISHED_EARLY);
/**
* The finish kinds that pay the full finish: a proven Steam finish, its top-up, or a manual finish. They open COMPLETED.
*/
public static final List<RewardKind> FULL_FINISH_KINDS = List.of(FINISHED, FINISHED_MANUAL, FINISHED_TOPUP);
/** /**
* The finish kinds that share the weekly cap: a manual finish and an unproven Steam finish. * The finish kinds that share the weekly cap: a manual finish and an unproven Steam finish.
*/ */
@@ -191,18 +191,23 @@ public final class RewardRules {
/** /**
* @param c the completed candidate of the game, null when it is none * @param c the completed candidate of the game, null when it is none
* @param provenFinishPaid a FINISHED or FINISHED_TOPUP row is paid for the game or its set * @param finishPaid a FINISHED or FINISHED_TOPUP row is paid for the game or its set, or a FINISHED_MANUAL row opens COMPLETED
* @param manualFinishPaid a FINISHED_MANUAL row is paid for the game or its set
* @return why no COMPLETED row is paid, null when nothing stops it * @return why no COMPLETED row is paid, null when nothing stops it
*/ */
public static NoPointsReason noCompletedReason(GameRewardFacts g, FinishFacts c, boolean provenFinishPaid) { public static NoPointsReason noCompletedReason(GameRewardFacts g, FinishFacts c, boolean finishPaid,
boolean manualFinishPaid) {
if (!g.completed()) { if (!g.completed()) {
return NoPointsReason.NOT_COMPLETED; return NoPointsReason.NOT_COMPLETED;
} }
if (g.manual()) { if (g.manual()) {
return NoPointsReason.MANUAL_GAME; return NoPointsReason.MANUAL_GAME;
} }
if (!provenFinishPaid) { if (!finishPaid) {
return NoPointsReason.NEEDS_PROVEN_FINISH; if (!manualFinishPaid) {
return NoPointsReason.NEEDS_PROVEN_FINISH;
}
return g.allAchievements() ? NoPointsReason.COMPLETION_TOO_OLD : NoPointsReason.NOT_ALL_ACHIEVEMENTS;
} }
return c != null && !c.allAchievements() ? NoPointsReason.NOT_ALL_ACHIEVEMENTS : null; return c != null && !c.allAchievements() ? NoPointsReason.NOT_ALL_ACHIEVEMENTS : null;
} }
@@ -13,6 +13,7 @@ import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.sql.Date; import java.sql.Date;
import java.time.Instant;
import java.time.YearMonth; import java.time.YearMonth;
import java.time.ZoneId; import java.time.ZoneId;
import java.util.Collection; import java.util.Collection;
@@ -165,25 +166,35 @@ public class RewardFactsRepository {
} }
/** /**
* COMPLETED Steam games with a paid FINISHED row (on the key or any key of its set) and no COMPLETED row yet; finishedAt is the FINISHED event. * COMPLETED games with a paid FINISHED, FINISHED_TOPUP or FINISHED_MANUAL row (on the key or any key of its set)
* Every edition of a linked set is a candidate; the service books one per set. * and no COMPLETED row yet; finishedAt is the first of those rows. A FINISHED_MANUAL row counts only with a recent
* last achievement unlock, see {@code earlySince}. A manual game is a candidate with manual set, so
* the rules reject it. Every edition of a linked set is a candidate; the service books one per set.
*/ */
public List<FinishFacts> completedCandidates(long userId) { public List<FinishFacts> completedCandidates(long userId, Instant earlySince) {
return completedCandidates(userId, null); return completedCandidates(userId, null, earlySince);
} }
/** /**
* @param appid only this game, null for all * @param appid only this game, null for all
* @param earlySince a FINISHED_MANUAL row opens COMPLETED only when the game's last achievement unlock is on or
* after this (100 % reached recently). A manual edition can be added already finished, so its
* fresh row says nothing about an old 100 %; no unlock time counts as old.
*/ */
public List<FinishFacts> completedCandidates(long userId, Long appid) { public List<FinishFacts> completedCandidates(long userId, Long appid, Instant earlySince) {
return jdbc.query(""" return jdbc.query("""
select ug.appid, ug.playtime_minutes, f.event_at, select ug.appid, ug.source = 'MANUAL' as manual, ug.playtime_minutes, f.event_at,
coalesce(s.achievements_total > 0 and m.achievements_unlocked >= s.achievements_total, false) coalesce(s.achievements_total > 0 and m.achievements_unlocked >= s.achievements_total, false)
as all_achievements as all_achievements
from user_game ug from user_game ug
left join user_game_set st on st.user_id = ug.user_id and st.appid = ug.appid left join user_game_set st on st.user_id = ug.user_id and st.appid = ug.appid
join lateral (select min(l.event_at) as event_at from reward_ledger l join lateral (select min(l.event_at) as event_at from reward_ledger l
where l.user_id = ug.user_id and l.kind in ('FINISHED', 'FINISHED_TOPUP') where l.user_id = ug.user_id
and (l.kind in ('FINISHED', 'FINISHED_TOPUP')
or (l.kind = 'FINISHED_MANUAL' and (select max(a.unlocked_at) from user_game_achievement a
where a.user_id = ug.user_id and a.appid = ug.appid
and a.unlocked)
>= :earlySince))
and (l.ref = ug.appid::text and (l.ref = ug.appid::text
or l.ref in (select o.appid::text from user_game_set o or l.ref in (select o.appid::text from user_game_set o
where o.user_id = st.user_id and o.set_id = st.set_id))) f where o.user_id = st.user_id and o.set_id = st.set_id))) f
@@ -196,9 +207,10 @@ public class RewardFactsRepository {
and not %1$s and not %1$s
order by f.event_at, ug.appid order by f.event_at, ug.appid
""".formatted(paidInSet("ug", "'COMPLETED'")), """.formatted(paidInSet("ug", "'COMPLETED'")),
new MapSqlParameterSource("userId", userId).addValue("appid", appid), new MapSqlParameterSource("userId", userId).addValue("appid", appid)
(rs, i) -> new FinishFacts(rs.getLong("appid"), false, true, rs.getInt("playtime_minutes"), 0, null, .addValue("earlySince", java.sql.Timestamp.from(earlySince)),
false, false, rs.getBoolean("all_achievements"), rs.getTimestamp("event_at").toInstant(), (rs, i) -> new FinishFacts(rs.getLong("appid"), rs.getBoolean("manual"), true, rs.getInt("playtime_minutes"), 0,
null, false, false, rs.getBoolean("all_achievements"), rs.getTimestamp("event_at").toInstant(),
false)); false));
} }
@@ -216,13 +228,18 @@ public class RewardFactsRepository {
exists (select 1 from user_game_status_change c where c.user_id = ug.user_id exists (select 1 from user_game_status_change c where c.user_id = ug.user_id
and c.appid = ug.appid and c.to_status in ('FINISHED', 'COMPLETED')) as finish_logged, and c.appid = ug.appid and c.to_status in ('FINISHED', 'COMPLETED')) as finish_logged,
exists (select 1 from user_psn_title t where t.user_id = ug.user_id and t.appid = ug.appid exists (select 1 from user_psn_title t where t.user_id = ug.user_id and t.appid = ug.appid
and t.state = 'LINKED') as linked_title and t.state = 'LINKED') as linked_title,
coalesce(s.achievements_total > 0 and m.achievements_unlocked >= s.achievements_total, false)
as all_achievements
from user_game ug from user_game ug
left join user_game_achievement_meta m on m.user_id = ug.user_id and m.appid = ug.appid
left join game_achievement_schema_meta s on s.appid = ug.appid
where ug.user_id = :userId and ug.appid = :appid and ug.removed_at is null where ug.user_id = :userId and ug.appid = :appid and ug.removed_at is null
""", new MapSqlParameterSource("userId", userId).addValue("appid", appid), """, new MapSqlParameterSource("userId", userId).addValue("appid", appid),
(rs, i) -> new GameRewardFacts(rs.getBoolean("manual"), rs.getBoolean("finished"), (rs, i) -> new GameRewardFacts(rs.getBoolean("manual"), rs.getBoolean("finished"),
rs.getBoolean("completed"), rs.getBoolean("hidden"), rs.getBoolean("playstation"), rs.getBoolean("completed"), rs.getBoolean("hidden"), rs.getBoolean("playstation"),
rs.getBoolean("start_logged"), rs.getBoolean("finish_logged"), rs.getBoolean("linked_title"))) rs.getBoolean("start_logged"), rs.getBoolean("finish_logged"), rs.getBoolean("linked_title"),
rs.getBoolean("all_achievements")))
.stream().findFirst(); .stream().findFirst();
} }
@@ -73,10 +73,11 @@ public class RewardService {
/** /**
* @param owned the codes the user may wear: bought or unlocked, or all for a platform admin * @param owned the codes the user may wear: bought or unlocked, or all for a platform admin
* @param progress the value of each track, see {@link Unlocks} * @param progress the value of each track, see {@link Unlocks}
* @param earlySince the first day a finish with a manual opener or no proof counts, see {@code app.rewards.early-since}
* @param equipped the worn looks, only those the user still owns * @param equipped the worn looks, only those the user still owns
*/ */
public record Wallet(long balance, Set<String> owned, Map<LookSlot, String> equipped, List<HistoryEntry> history, public record Wallet(long balance, Set<String> owned, Map<LookSlot, String> equipped, List<HistoryEntry> history,
Map<Track, Integer> progress) {} Map<Track, Integer> progress, LocalDate earlySince) {}
// ponytail: settles on every wallet read (the app reads it once per start); a settled_at column if that hurts. // ponytail: settles on every wallet read (the app reads it once per start); a settled_at column if that hurts.
@Transactional @Transactional
@@ -84,7 +85,7 @@ public class RewardService {
Map<Track, Integer> progress = settleLocked(userId); Map<Track, Integer> progress = settleLocked(userId);
Set<String> owned = Looks.owned(ledger.itemCodes(userId), role); Set<String> owned = Looks.owned(ledger.itemCodes(userId), role);
return new Wallet(ledger.balance(userId), owned, Looks.wearable(ledger.equipped(userId), owned), return new Wallet(ledger.balance(userId), owned, Looks.wearable(ledger.equipped(userId), owned),
ledger.history(userId, HISTORY_SIZE), progress); ledger.history(userId, HISTORY_SIZE), progress, earlySince);
} }
public enum BuyResult { BOUGHT, ALREADY_OWNED, NOT_ENOUGH_POINTS, NOT_FOR_SALE } public enum BuyResult { BOUGHT, ALREADY_OWNED, NOT_ENOUGH_POINTS, NOT_FOR_SALE }
@@ -249,7 +250,8 @@ public class RewardService {
* @param paid the game rows of the game and its set * @param paid the game rows of the game and its set
* @param missing why a kind is not paid; FINISHED stands for both finish kinds * @param missing why a kind is not paid; FINISHED stands for both finish kinds
*/ */
public record GamePoints(long balance, List<PaidRow> paid, Map<RewardKind, NoPointsReason> missing) {} public record GamePoints(long balance, List<PaidRow> paid, Map<RewardKind, NoPointsReason> missing,
LocalDate earlySince) {}
/** /**
* Settles first, like the wallet; {@link PaidRow#bookedNow()} relies on settle and read sharing this one * Settles first, like the wallet; {@link PaidRow#bookedNow()} relies on settle and read sharing this one
@@ -265,7 +267,8 @@ public class RewardService {
} }
settleLocked(userId); settleLocked(userId);
List<PaidRow> paid = facts.paidRows(userId, appid); List<PaidRow> paid = facts.paidRows(userId, appid);
return Optional.of(new GamePoints(ledger.balance(userId), paid, missing(userId, appid, found.get(), paid))); return Optional.of(new GamePoints(ledger.balance(userId), paid, missing(userId, appid, found.get(), paid),
earlySince));
} }
private Map<RewardKind, NoPointsReason> missing(long userId, long appid, GameRewardFacts game, List<PaidRow> paid) { private Map<RewardKind, NoPointsReason> missing(long userId, long appid, GameRewardFacts game, List<PaidRow> paid) {
@@ -301,9 +304,12 @@ public class RewardService {
putReason(missing, RewardKind.FINISHED_TOPUP, RewardRules.noTopUpReason(game, finish)); putReason(missing, RewardKind.FINISHED_TOPUP, RewardRules.noTopUpReason(game, finish));
} }
if (noRow(paid, List.of(RewardKind.COMPLETED))) { if (noRow(paid, List.of(RewardKind.COMPLETED))) {
FinishFacts completed = facts.completedCandidates(userId, appid).stream().findFirst().orElse(null); FinishFacts completed = facts.completedCandidates(userId, appid, earlySince()).stream().findFirst().orElse(null);
putReason(missing, RewardKind.COMPLETED, RewardRules.noCompletedReason(game, completed, // A candidate means an opener passed; a FINISHED_MANUAL row with an old last unlock is none.
!noRow(paid, List.of(RewardKind.FINISHED, RewardKind.FINISHED_TOPUP)))); boolean finishPaid = completed != null || !noRow(paid, List.of(RewardKind.FINISHED, RewardKind.FINISHED_TOPUP));
boolean manualFinishPaid = !noRow(paid, List.of(RewardKind.FINISHED_MANUAL));
putReason(missing, RewardKind.COMPLETED,
RewardRules.noCompletedReason(game, completed, finishPaid, manualFinishPaid));
} }
return missing; return missing;
} }
@@ -363,7 +369,7 @@ public class RewardService {
private int bookOnePerSet(long userId, RewardEntry entry) { private int bookOnePerSet(long userId, RewardEntry entry) {
List<RewardKind> kinds = switch (entry.kind()) { List<RewardKind> kinds = switch (entry.kind()) {
case FINISHED, FINISHED_MANUAL, FINISHED_EARLY -> RewardKind.FINISH_KINDS; case FINISHED, FINISHED_MANUAL, FINISHED_EARLY -> RewardKind.FINISH_KINDS;
case FINISHED_TOPUP -> List.of(RewardKind.FINISHED, RewardKind.FINISHED_MANUAL, RewardKind.FINISHED_TOPUP); case FINISHED_TOPUP -> RewardKind.FULL_FINISH_KINDS;
default -> List.of(entry.kind()); default -> List.of(entry.kind());
}; };
return facts.paidInSet(userId, Long.parseLong(entry.ref()), kinds) ? 0 : book(userId, entry); return facts.paidInSet(userId, Long.parseLong(entry.ref()), kinds) ? 0 : book(userId, entry);
@@ -397,7 +403,7 @@ public class RewardService {
} }
private int settleCompleted(long userId) { private int settleCompleted(long userId) {
return facts.completedCandidates(userId).stream() return facts.completedCandidates(userId, earlySince()).stream()
.flatMap(f -> RewardRules.completed(f).stream()).mapToInt(e -> bookOnePerSet(userId, e)).sum(); .flatMap(f -> RewardRules.completed(f).stream()).mapToInt(e -> bookOnePerSet(userId, e)).sum();
} }
@@ -101,6 +101,7 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
@BeforeEach @BeforeEach
void seed() { void seed() {
jdbc.update("delete from game_link where set_id = 980002"); jdbc.update("delete from game_link where set_id = 980002");
jdbc.update("delete from user_psn_title where user_id = ?", USER_ID);
gameReleaseRepository.deleteAll(); gameReleaseRepository.deleteAll();
userGameRepository.deleteAll(); userGameRepository.deleteAll();
schemaMetaRepository.deleteAll(); schemaMetaRepository.deleteAll();
@@ -237,6 +238,21 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.stats.topGames.mostPlayed[0].minutes").value(1000)); .andExpect(jsonPath("$.stats.topGames.mostPlayed[0].minutes").value(1000));
} }
@Test
void publicProfile_HabitsCountsEqualTheOwnersStats() throws Exception {
jdbc.update("update user_game set playtime_minutes = 0 where user_id = ? and appid = 220", USER_ID);
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes) "
+ "values (?, 'c1', 'STEAM_OWNED', 220, 'PS5', 40)", USER_ID);
var own = userStatsService.getStats(USER_ID, Set.of(StatGroup.HABITS));
assertEquals(1L, own.habits().underOneHourGames());
mockMvc.perform(get("/api/public/profiles/library")
.header("X-Profile-Ticket", ticket(USER_ID, List.of("HABITS"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.stats.habits.underOneHourGames").value(own.habits().underOneHourGames()))
.andExpect(jsonPath("$.stats.habits.underOneHourShare").value(own.habits().underOneHourShare()));
}
@Test @Test
void publicLibrary_OnlyTheSharedStatGroups() throws Exception { void publicLibrary_OnlyTheSharedStatGroups() throws Exception {
mockMvc.perform(get("/api/public/profiles/library") mockMvc.perform(get("/api/public/profiles/library")
@@ -47,6 +47,7 @@ class StatGamesFlowTest extends AbstractMockedLibraryServicesTest {
private static final long MANUAL_SWITCH = 5_000_010L; private static final long MANUAL_SWITCH = 5_000_010L;
private static final long MANUAL_SWITCH_LOWER = 5_000_011L; private static final long MANUAL_SWITCH_LOWER = 5_000_011L;
private static final long BACK_TO_PILE = 5_000_012L; private static final long BACK_TO_PILE = 5_000_012L;
private static final long SET_PSN = 5_000_013L;
private static final int YEAR = LocalDate.now(ZoneId.of("Europe/Berlin")).getYear(); private static final int YEAR = LocalDate.now(ZoneId.of("Europe/Berlin")).getYear();
private static final JsonMapper JSON = JsonMapper.builder().build(); private static final JsonMapper JSON = JsonMapper.builder().build();
@@ -123,6 +124,7 @@ class StatGamesFlowTest extends AbstractMockedLibraryServicesTest {
@BeforeEach @BeforeEach
void seed() { void seed() {
jdbc.update("delete from user_psn_title where user_id in (?, ?)", USER, OTHER);
jdbc.update("delete from user_game where user_id in (?, ?)", USER, OTHER); jdbc.update("delete from user_game where user_id in (?, ?)", USER, OTHER);
jdbc.update("delete from user_game_playtime_day where user_id in (?, ?)", USER, OTHER); jdbc.update("delete from user_game_playtime_day where user_id in (?, ?)", USER, OTHER);
jdbc.update("delete from game_link where appid in (?, ?)", EDITION_MAIN, EDITION_SECOND); jdbc.update("delete from game_link where appid in (?, ?)", EDITION_MAIN, EDITION_SECOND);
@@ -147,6 +149,9 @@ class StatGamesFlowTest extends AbstractMockedLibraryServicesTest {
game(USER, MANUAL_SWITCH_LOWER, 70, "BACKLOG", "MANUAL", "switch"); game(USER, MANUAL_SWITCH_LOWER, 70, "BACKLOG", "MANUAL", "switch");
game(USER, BACK_TO_PILE, 10, "PLAYING", "STEAM", null); game(USER, BACK_TO_PILE, 10, "PLAYING", "STEAM", null);
jdbc.update("update user_game set status = 'BACKLOG' where user_id = ? and appid = ?", USER, BACK_TO_PILE); jdbc.update("update user_game set status = 'BACKLOG' where user_id = ? and appid = ?", USER, BACK_TO_PILE);
game(USER, SET_PSN, 0, "BACKLOG", "MANUAL", "Xbox");
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes) "
+ "values (?, 'set-psn', 'STEAM_OWNED', ?, 'PS5', 120)", USER, SET_PSN);
game(OTHER, FINISHED_TRACKED, 4000, "FINISHED", "STEAM", null); game(OTHER, FINISHED_TRACKED, 4000, "FINISHED", "STEAM", null);
jdbc.update(""" jdbc.update("""
@@ -220,6 +225,18 @@ class StatGamesFlowTest extends AbstractMockedLibraryServicesTest {
assertThat(rows).extracting(r -> r.get("appid").asLong()).doesNotHaveDuplicates(); assertThat(rows).extracting(r -> r.get("appid").asLong()).doesNotHaveDuplicates();
} }
@Test
void setPlayedOnPlayStation_IsPlayedNotNeverStarted() throws Exception {
JsonNode stats = stats();
List<Long> played = allRows("PLAYED", null).stream().map(r -> r.get("appid").asLong()).toList();
List<Long> never = allRows("NEVER_STARTED", null).stream().map(r -> r.get("appid").asLong()).toList();
assertThat(played).contains(SET_PSN);
assertThat(never).doesNotContain(SET_PSN);
assertThat(stats.at("/overview/playedGames").asLong() + stats.at("/overview/neverStarted").asLong())
.isEqualTo(stats.at("/overview/totalGames").asLong());
}
@Test @Test
void mostPlayed_FirstFiveAreTheStatsTopGames() throws Exception { void mostPlayed_FirstFiveAreTheStatsTopGames() throws Exception {
List<Long> top = new ArrayList<>(); List<Long> top = new ArrayList<>();
@@ -0,0 +1,331 @@
package core_service.modules.library.api;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.domain.SyncTrigger;
import core_service.modules.library.service.LibrarySyncWorker;
import core_service.modules.rewards.service.RewardService;
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
import core_service.shared.test.AbstractMockedAdaptersTest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
/**
* A game added by hand for PC becomes the Steam row when the sync sees the user own it on Steam. Real syncs with a
* mocked Steam; the first sync of an account is a baseline, so every test syncs once before the game is owned.
*/
class SteamTakeoverFlowTest extends AbstractMockedAdaptersTest {
private static final long USER = 1L;
private static final long OTHER_USER = 2L;
private static final String STEAM_ID = "76561198000000001";
private static final long STEAM = 920_001L;
private static final long STEAM_2 = 920_002L;
private static final long FILLER = 920_003L;
private static final long IGDB = 920_101L;
private static final long MANUAL = -IGDB;
private static final long OTHER_IGDB = 920_102L;
@Autowired
private LibrarySyncWorker worker;
@Autowired
private RewardService rewardService;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
@AfterEach
void clean() {
for (String table : List.of("user_game_playtime_day", "user_game_split", "user_game_set_backup",
"user_game_question", "backlog_order", "user_game_status_change", "user_game", "reward_ledger",
"sync_result", "user_steam_sync")) {
jdbc.update("delete from " + table + " where user_id in (?, ?)", USER, OTHER_USER);
}
jdbc.update("delete from game_link_pair where appid_a between -920200 and 920200 or appid_b between -920200 and 920200");
jdbc.update("delete from game_link where appid between -920200 and 920200");
jdbc.update("delete from game_edition where appid between -920200 and 920200");
jdbc.update("delete from steam_game where appid between -920200 and 920200");
}
private void manual(long userId, String platform) {
jdbc.update("insert into steam_game (appid, name) values (?, ?) on conflict do nothing", MANUAL, "By hand");
jdbc.update("""
insert into user_game (user_id, appid, source, platform, status, status_set_by_user, rating, notes,
priority, playtime_minutes)
values (?, ?, 'MANUAL', ?, 'FINISHED', true, 4, 'my notes', 'HIGH', 120)
""", userId, MANUAL, platform);
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'FINISHED_MANUAL', ?, 10, now())",
userId, String.valueOf(MANUAL));
}
private void edition(long appid, long igdbId, long sameGameId) {
jdbc.update("insert into game_edition (appid, igdb_id, same_game_id, checked_at) values (?, ?, ?, now()) "
+ "on conflict (appid) do update set igdb_id = excluded.igdb_id, same_game_id = excluded.same_game_id",
appid, igdbId, sameGameId);
}
private void mapped() {
edition(STEAM, IGDB, IGDB);
edition(MANUAL, IGDB, IGDB);
}
private static SteamOwnedGamesResponse.Game owned(long appid, int playtime) {
return new SteamOwnedGamesResponse.Game((int) appid, "Game " + appid, playtime, "icon", true, playtime, 1_700_000_000L);
}
private void sync(SteamOwnedGamesResponse.Game... games) {
when(steamApiService.getWishlist(anyString())).thenReturn(Optional.empty());
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(games));
worker.runSyncNow(USER, STEAM_ID, SyncTrigger.AUTO);
}
/** The baseline sync, then the one that sees the game for the first time. */
private void syncUntilOwned(SteamOwnedGamesResponse.Game... games) {
sync(owned(FILLER, 0));
sync(games);
}
private List<Long> keys(long userId) {
return jdbc.queryForList("select appid from user_game where user_id = ? and appid between -920200 and 920200 and appid <> 920003 "
+ "order by appid", Long.class, userId);
}
private Map<String, Object> row(long userId, long appid) {
return jdbc.queryForMap("select * from user_game where user_id = ? and appid = ?", userId, appid);
}
private int count(String sql, Object... args) {
return jdbc.queryForObject(sql, Integer.class, args);
}
private void assertTookOver() {
assertThat(keys(USER)).containsExactly(STEAM);
Map<String, Object> steam = row(USER, STEAM);
assertThat(steam).containsEntry("source", "STEAM").containsEntry("playtime_minutes", 300)
.containsEntry("status", "FINISHED").containsEntry("rating", 4).containsEntry("notes", "my notes")
.containsEntry("priority", "HIGH");
assertThat(steam.get("platform")).isNull();
assertThat(steam.get("last_played_at")).isNotNull();
assertThat(count("select count(*) from user_game_status_change where user_id = ? and appid = ?", USER, STEAM))
.isPositive();
assertThat(count("select count(*) from user_game_status_change where user_id = ? and appid = ?", USER, MANUAL))
.isZero();
assertThat(count("select count(*) from user_game_playtime_day where user_id = ? and appid = ?", USER, STEAM))
.isZero();
assertThat(jdbc.queryForList("select ref from reward_ledger where user_id = ? and kind = 'FINISHED_MANUAL'",
String.class, USER)).containsExactly(String.valueOf(STEAM));
}
@Test
void runSync_GameAddedByHandForPcIsOwnedOnSteam_BecomesTheSteamRow() {
manual(USER, "PC");
mapped();
jdbc.update("insert into user_game_question (user_id, appid, question, playtime_minutes) values (?, ?, 'RATE', 120)",
USER, MANUAL);
syncUntilOwned(owned(FILLER, 0), owned(STEAM, 300));
assertTookOver();
assertThat(count("select count(*) from user_game_question where user_id = ? and appid = ?", USER, STEAM)).isOne();
}
@Test
void runSync_BacklogGameAddedByHand_KeepsItsBacklogPosition() {
manual(USER, "PC");
jdbc.update("update user_game set status = 'BACKLOG', status_set_by_user = false where user_id = ?", USER);
mapped();
syncUntilOwned(owned(FILLER, 0), owned(STEAM, 300));
assertThat(keys(USER)).containsExactly(STEAM);
assertThat(count("select count(*) from backlog_order where user_id = ? and appid = ?", USER, MANUAL)).isZero();
}
@Test
void runSync_MappingLearnedInThisSync_BecomesTheSteamRowAndLinksTheSwitchRow() {
manual(USER, "PC");
long switchKey = -OTHER_IGDB;
jdbc.update("insert into steam_game (appid, name) values (?, 'Switch copy')", switchKey);
jdbc.update("insert into user_game (user_id, appid, source, platform, status) values (?, ?, 'MANUAL', 'Switch', 'BACKLOG')",
USER, switchKey);
edition(switchKey, OTHER_IGDB, IGDB);
edition(MANUAL, IGDB, IGDB);
when(igdbApiService.isEnabled()).thenReturn(true);
when(igdbApiService.getEditions(anyCollection()))
.thenReturn(Optional.of(Map.of(STEAM, new IgdbApiService.Edition(IGDB, IGDB))));
syncUntilOwned(owned(FILLER, 0), owned(STEAM, 300));
assertThat(keys(USER)).containsExactlyInAnyOrder(STEAM, switchKey);
assertThat(count("select count(distinct set_id) from game_link where appid in (?, ?, ?)", STEAM, MANUAL, switchKey))
.isOne();
assertThat(count("select count(*) from game_link where appid in (?, ?)", STEAM, switchKey)).isEqualTo(2);
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {"Steam", "pc", "Mac", "Linux"})
void runSync_PlatformOfTheSteamCopy_BecomesTheSteamRow(String platform) {
manual(USER, platform);
mapped();
syncUntilOwned(owned(FILLER, 0), owned(STEAM, 300));
assertTookOver();
}
@Test
void runSync_GameAddedByHandForSwitch_AddsTheSteamRowInOneSet() {
manual(USER, "Switch");
edition(MANUAL, IGDB, IGDB);
when(igdbApiService.isEnabled()).thenReturn(true);
when(igdbApiService.getEditions(anyCollection()))
.thenReturn(Optional.of(Map.of(STEAM, new IgdbApiService.Edition(IGDB, IGDB))));
syncUntilOwned(owned(FILLER, 0), owned(STEAM, 300));
assertThat(keys(USER)).containsExactlyInAnyOrder(MANUAL, STEAM);
assertThat(row(USER, MANUAL)).containsEntry("source", "MANUAL");
assertThat(count("select count(distinct set_id) from game_link where appid in (?, ?)", STEAM, MANUAL)).isOne();
assertThat(count("select count(*) from counted_user_game where user_id = ? and appid in (?, ?)", USER, STEAM, MANUAL))
.isOne();
}
@Test
void runSync_NoMapping_AddsASecondRowAsToday() {
manual(USER, "PC");
edition(STEAM, OTHER_IGDB, OTHER_IGDB);
syncUntilOwned(owned(FILLER, 0), owned(STEAM, 300));
assertThat(keys(USER)).containsExactlyInAnyOrder(MANUAL, STEAM);
assertThat(row(USER, MANUAL)).containsEntry("source", "MANUAL").containsEntry("playtime_minutes", 120);
}
@Test
void runSync_TwiceAfterTakeover_ChangesNothingMore() {
manual(USER, "PC");
mapped();
syncUntilOwned(owned(FILLER, 0), owned(STEAM, 300));
int changes = count("select count(*) from user_game_status_change where user_id = ?", USER);
sync(owned(FILLER, 0), owned(STEAM, 300));
assertTookOver();
assertThat(count("select count(*) from user_game_status_change where user_id = ?", USER)).isEqualTo(changes);
assertThat(count("select count(*) from reward_ledger where user_id = ?", USER)).isOne();
}
@Test
void runSync_RemovedManualRow_ComesBackAsTheSteamRow() {
manual(USER, "PC");
jdbc.update("update user_game set removed_at = now() where user_id = ?", USER);
mapped();
syncUntilOwned(owned(FILLER, 0), owned(STEAM, 300));
assertThat(keys(USER)).containsExactly(STEAM);
assertThat(row(USER, STEAM).get("removed_at")).isNull();
assertThat(row(USER, STEAM)).containsEntry("rating", 4);
assertThat(count("select count(*) from sync_change where user_id = ? and kind = 'ADDED' and appid = ?", USER, STEAM))
.isOne();
}
@Test
void runSync_HiddenManualRow_StaysHiddenUnderTheSteamKey() {
manual(USER, "PC");
jdbc.update("update user_game set hidden_at = now() where user_id = ?", USER);
mapped();
syncUntilOwned(owned(FILLER, 0), owned(STEAM, 300));
assertThat(keys(USER)).containsExactly(STEAM);
assertThat(row(USER, STEAM).get("hidden_at")).isNotNull();
}
@Test
void runSync_UserHasARemovedSteamRow_KeepsBothRows() {
manual(USER, "PC");
mapped();
jdbc.update("insert into steam_game (appid, name) values (?, 'Steam copy')", STEAM);
jdbc.update("insert into user_game (user_id, appid, status, removed_at) values (?, ?, 'BACKLOG', now())", USER, STEAM);
syncUntilOwned(owned(FILLER, 0), owned(STEAM, 300));
assertThat(keys(USER)).containsExactlyInAnyOrder(MANUAL, STEAM);
assertThat(row(USER, STEAM).get("removed_at")).isNull();
assertThat(row(USER, MANUAL)).containsEntry("source", "MANUAL");
}
@Test
void runSync_TwoSteamAppsOfOneGame_TheLowestTakesTheRow() {
manual(USER, "PC");
mapped();
edition(STEAM_2, IGDB, IGDB);
syncUntilOwned(owned(FILLER, 0), owned(STEAM_2, 50), owned(STEAM, 300));
assertThat(keys(USER)).containsExactlyInAnyOrder(STEAM, STEAM_2);
assertThat(row(USER, STEAM)).containsEntry("rating", 4);
assertThat(row(USER, STEAM_2).get("rating")).isNull();
}
@Test
void runSync_OtherUserHasTheSameManualGame_IsUntouched() {
manual(USER, "PC");
manual(OTHER_USER, "PC");
mapped();
syncUntilOwned(owned(FILLER, 0), owned(STEAM, 300));
assertThat(keys(OTHER_USER)).containsExactly(MANUAL);
assertThat(row(OTHER_USER, MANUAL)).containsEntry("source", "MANUAL");
assertThat(jdbc.queryForList("select ref from reward_ledger where user_id = ?", String.class, OTHER_USER))
.containsExactly(String.valueOf(MANUAL));
}
@Test
void runSync_FinishedByHandBeforeTakeover_PaysNoSteamFinish() {
manual(USER, "PC");
mapped();
syncUntilOwned(owned(FILLER, 0), owned(STEAM, 300));
rewardService.settleAll();
assertThat(jdbc.queryForList("select kind || ':' || ref from reward_ledger where user_id = ? "
+ "and kind in ('FINISHED', 'FINISHED_MANUAL', 'FINISHED_EARLY')", String.class, USER))
.containsExactly("FINISHED_MANUAL:" + STEAM);
}
@Test
void runSync_FreshEditionWithoutIgdbGame_StillBecomesTheSteamRow() {
manual(USER, "PC");
edition(MANUAL, IGDB, IGDB);
jdbc.update("insert into steam_game (appid, name) values (?, 'Owned by another user') on conflict do nothing", STEAM);
jdbc.update("insert into game_edition (appid, igdb_id, same_game_id, checked_at) values (?, null, null, now())",
STEAM);
when(igdbApiService.isEnabled()).thenReturn(true);
when(igdbApiService.getEditions(anyCollection()))
.thenReturn(Optional.of(Map.of(STEAM, new IgdbApiService.Edition(IGDB, IGDB))));
syncUntilOwned(owned(FILLER, 0), owned(STEAM, 300));
assertThat(keys(USER)).containsExactly(STEAM);
}
}
@@ -86,6 +86,30 @@ class BacklogSnapshotRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.findSince(1L, DAY)).containsExactly(new UserStats.PileDay(DAY, 1, 1, 600)); assertThat(repository.findSince(1L, DAY)).containsExactly(new UserStats.PileDay(DAY, 1, 1, 600));
} }
private void psnPlay(long appid, int minutes) {
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes) "
+ "values (1, ?, 'STEAM_OWNED', ?, 'PS5', ?)", "c" + appid, appid, minutes);
}
@Test
void writeAll_ASetPlayedOnlyOnPlayStationIsNotNeverPlayed() {
userGameRepository.saveAllAndFlush(List.of(game(1L, 220L, 0, GameStatus.BACKLOG), game(1L, 620L, 0, GameStatus.BACKLOG)));
psnPlay(220L, 120);
repository.writeAll(DAY);
assertThat(repository.findSince(1L, DAY)).extracting(UserStats.PileDay::neverPlayed).containsExactly(1L);
assertThat(userGameRepository.countListedNeverPlayed(1L)).isEqualTo(1);
}
@Test
void live_UsesTheSetTotal() {
userGameRepository.saveAllAndFlush(List.of(game(1L, 220L, 0, GameStatus.BACKLOG), game(1L, 620L, 0, GameStatus.BACKLOG)));
psnPlay(220L, 120);
assertThat(repository.live(1L, DAY).neverPlayed()).isEqualTo(1).isEqualTo(userGameRepository.countListedNeverPlayed(1L));
}
@Test @Test
void writeAll_SecondRunOnTheSameDay_ReplacesTheRow() { void writeAll_SecondRunOnTheSameDay_ReplacesTheRow() {
UserGame game = game(1L, 220L, 0, GameStatus.BACKLOG); UserGame game = game(1L, 220L, 0, GameStatus.BACKLOG);
@@ -0,0 +1,201 @@
package core_service.modules.library.repo;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@Import(GameMergeRepository.class)
class GameMergeRepositoryTakeoverTest extends AbstractPersistenceTest {
private static final long USER = 1L;
private static final long OTHER_USER = 2L;
private static final long STEAM = 100L;
private static final long STEAM_2 = 200L;
private static final long MANUAL = -7L;
@Autowired
private GameMergeRepository repository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
for (String table : List.of("reward_ledger", "user_psn_trophy_set", "user_psn_title", "user_game_playtime_day", "user_game_split",
"user_game_set_backup", "user_game_question", "backlog_order", "user_game_status_change", "game_link",
"game_edition", "user_game")) {
jdbc.update("delete from " + table);
}
for (long appid : List.of(STEAM, STEAM_2, MANUAL, -8L, -9L, 300L)) {
jdbc.update("insert into steam_game (appid, name) values (?, ?) on conflict do nothing", appid, "Game " + appid);
}
edition(STEAM, 7);
edition(STEAM_2, 7);
edition(MANUAL, 7);
}
private void edition(long appid, long igdbId) {
jdbc.update("insert into game_edition (appid, igdb_id, same_game_id, checked_at) values (?, ?, ?, now())",
appid, appid < 0 ? -appid : igdbId, igdbId);
}
private void game(long userId, long appid, String source, String platform) {
jdbc.update("insert into user_game (user_id, appid, status, source, platform) values (?, ?, 'BACKLOG', ?, ?)",
userId, appid, source, platform);
}
private Map<Long, Long> takeovers() {
return repository.findSteamTakeovers(USER, List.of(STEAM, STEAM_2));
}
private int count(String sql, Object... args) {
return jdbc.queryForObject(sql, Integer.class, args);
}
@Test
void findSteamTakeovers_PlatformRule() {
for (String platform : new String[]{"PC", "pc", "Steam", " ", null, "Mac", "Linux"}) {
jdbc.update("delete from user_game");
game(USER, MANUAL, "MANUAL", platform);
assertThat(takeovers()).as("platform %s", platform).containsEntry(MANUAL, STEAM);
assertThat(repository.hasSteamTakeoverCandidates(USER)).isTrue();
}
for (String platform : new String[]{"Switch", "PS5"}) {
jdbc.update("delete from user_game");
game(USER, MANUAL, "MANUAL", platform);
assertThat(takeovers()).as("platform %s", platform).isEmpty();
assertThat(repository.hasSteamTakeoverCandidates(USER)).isFalse();
}
}
@Test
void findSteamTakeovers_SkipsNonManualPositiveKeysAndKeysTheUserHas() {
game(USER, MANUAL, "PSN", "PC");
game(USER, 300, "MANUAL", "PC");
assertThat(takeovers()).isEmpty();
jdbc.update("delete from user_game");
game(USER, MANUAL, "MANUAL", "PC");
game(USER, STEAM, "STEAM", null);
game(USER, STEAM_2, "STEAM", null);
for (String state : new String[]{"removed_at = now()", "hidden_at = now()", "status = status"}) {
jdbc.update("update user_game set " + state + " where user_id = ? and appid in (?, ?)", USER, STEAM, STEAM_2);
assertThat(takeovers()).as(state).isEmpty();
}
}
@Test
void findSteamTakeovers_TwoSteamAppsOfOneGame_TakesTheLowest() {
game(USER, MANUAL, "MANUAL", "PC");
assertThat(takeovers()).containsExactly(Map.entry(MANUAL, STEAM));
}
@Test
void findSteamTakeovers_EmptyAppids_ReturnsEmpty() {
game(USER, MANUAL, "MANUAL", "PC");
assertThat(repository.findSteamTakeovers(USER, List.of())).isEmpty();
}
@Test
void rekeyUserGame_MovesRowAndChildren() {
game(USER, MANUAL, "MANUAL", "PC");
jdbc.update("update user_game set status = 'PLAYING' where user_id = ?", USER);
jdbc.update("insert into user_game_question (user_id, appid, question, playtime_minutes) values (?, ?, 'RATE', 1)",
USER, MANUAL);
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, current_date, ?, 5)",
USER, MANUAL);
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'FINISHED_MANUAL', ?, 10, now())",
USER, String.valueOf(MANUAL));
assertThat(repository.rekeyUserGame(USER, MANUAL, STEAM)).isTrue();
assertThat(count("select count(*) from user_game where user_id = ? and appid = ?", USER, MANUAL)).isZero();
assertThat(count("select count(*) from user_game where user_id = ? and appid = ?", USER, STEAM)).isOne();
assertThat(count("select count(*) from user_game_status_change where user_id = ? and appid = ?", USER, STEAM))
.isEqualTo(2);
assertThat(count("select count(*) from user_game_question where appid = ?", STEAM)).isOne();
assertThat(count("select count(*) from user_game_playtime_day where appid = ?", STEAM)).isOne();
assertThat(jdbc.queryForObject("select ref from reward_ledger where user_id = ?", String.class, USER))
.isEqualTo("100");
}
@Test
void rekeyUserGame_DayBothKeysHave_AddsUpOnTheTarget() {
game(USER, MANUAL, "MANUAL", "PC");
for (long appid : new long[]{MANUAL, STEAM}) {
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, current_date, ?, 5)",
USER, appid);
}
repository.rekeyUserGame(USER, MANUAL, STEAM);
assertThat(jdbc.queryForList("select appid || ':' || minutes from user_game_playtime_day", String.class))
.containsExactly("100:10");
}
@Test
void rekeyUserGame_KindHeldOnTarget_StaysOnOldRef() {
game(USER, MANUAL, "MANUAL", "PC");
for (long ref : new long[]{MANUAL, STEAM}) {
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'FINISHED_MANUAL', ?, 10, now())",
USER, String.valueOf(ref));
}
assertThat(repository.rekeyUserGame(USER, MANUAL, STEAM)).isTrue();
assertThat(jdbc.queryForList("select ref from reward_ledger where user_id = ?", String.class, USER))
.containsExactlyInAnyOrder("-7", "100");
}
@Test
void rekeyUserGame_TargetRowExists_ReturnsFalseAndChangesNothing() {
game(USER, MANUAL, "MANUAL", "PC");
game(USER, STEAM, "STEAM", null);
assertThat(repository.rekeyUserGame(USER, MANUAL, STEAM)).isFalse();
assertThat(count("select count(*) from user_game where user_id = ? and appid = ?", USER, MANUAL)).isOne();
}
@Test
void rekeyUserGame_OtherUser_IsUntouched() {
game(USER, MANUAL, "MANUAL", "PC");
game(OTHER_USER, MANUAL, "MANUAL", "PC");
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'FINISHED_MANUAL', ?, 10, now())",
OTHER_USER, String.valueOf(MANUAL));
repository.rekeyUserGame(USER, MANUAL, STEAM);
assertThat(count("select count(*) from user_game where user_id = ? and appid = ?", OTHER_USER, MANUAL)).isOne();
assertThat(jdbc.queryForObject("select ref from reward_ledger where user_id = ?", String.class, OTHER_USER))
.isEqualTo("-7");
}
@Test
void rekeyUserGame_MovesTrophySets() {
game(USER, MANUAL, "MANUAL", "PC");
jdbc.update("delete from psn_trophy_set where np_communication_id = 'NPWR00001_00'");
jdbc.update("""
insert into psn_trophy_set (np_communication_id, service_name, platform, name, defined_bronze,
defined_silver, defined_gold, defined_platinum)
values ('NPWR00001_00', 'trophy2', 'PS5', 'Set', 1, 0, 0, 0)""");
jdbc.update("""
insert into user_psn_trophy_set (user_id, np_communication_id, appid, earned_total, platinum_earned)
values (?, 'NPWR00001_00', ?, 1, false)""", USER, MANUAL);
assertThat(repository.rekeyUserGame(USER, MANUAL, STEAM)).isTrue();
assertThat(jdbc.queryForObject("select appid from user_psn_trophy_set where user_id = ?", Long.class, USER))
.isEqualTo(STEAM);
}
}
@@ -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);
@@ -631,6 +631,14 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
assertEquals(1, userGameRepository.countListedNeverPlayed(USER_ID)); assertEquals(1, userGameRepository.countListedNeverPlayed(USER_ID));
} }
@Test
void countListedNeverPlayed_UsesTheSetTotal() {
userGameRepository.flush();
titlePlay("c9", 380L, "STEAM_OWNED", "PS5", 120);
assertEquals(1, userGameRepository.countListedNeverPlayed(USER_ID));
}
@Test @Test
void findRandomBacklogGame_PicksOnlyOwnActiveBacklogGames() { void findRandomBacklogGame_PicksOnlyOwnActiveBacklogGames() {
UserGame removed = userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow(); UserGame removed = userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow();
@@ -179,12 +179,16 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
.containsExactly(620L, 220L); .containsExactly(620L, 220L);
} }
private void psnPlay(long appid, int minutes) {
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes) "
+ "values (?, ?, 'STEAM_OWNED', ?, 'PS5', ?)", USER_ID, "c" + appid, appid, minutes);
}
private void seedEntryTotal() { private void seedEntryTotal() {
userGameRepository.saveAllAndFlush(List.of( userGameRepository.saveAllAndFlush(List.of(
game(220L, 100, GameStatus.PLAYING, Instant.now()), game(220L, 100, GameStatus.PLAYING, Instant.now()),
game(620L, 300, GameStatus.PLAYING, Instant.now()))); game(620L, 300, GameStatus.PLAYING, Instant.now())));
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes) " psnPlay(220L, 500);
+ "values (?, 'c1', 'STEAM_OWNED', 220, 'PS5', 500)", USER_ID);
} }
@Test @Test
@@ -282,6 +286,79 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.playedCounts(USER_ID)).isEqualTo(new UserStats.PlayedCounts(2, 1)); assertThat(repository.playedCounts(USER_ID)).isEqualTo(new UserStats.PlayedCounts(2, 1));
} }
private List<Long> appids(StatKey key) {
return repository.games(USER_ID, key, "", BERLIN, 0, 10).stream().map(UserStats.StatGame::appid).toList();
}
@Test
void playedCounts_ASetPlayedOnlyOnPlayStationIsPlayed() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 0, GameStatus.BACKLOG, Instant.now()),
game(620L, 0, GameStatus.BACKLOG, Instant.now())));
psnPlay(220L, 120);
assertThat(repository.playedCounts(USER_ID)).isEqualTo(new UserStats.PlayedCounts(1, 0));
assertThat(repository.countGames(USER_ID, StatKey.PLAYED, "", BERLIN)).isEqualTo(1);
assertThat(appids(StatKey.PLAYED)).containsExactly(220L);
assertThat(repository.countGames(USER_ID, StatKey.NEVER_STARTED, "", BERLIN)).isEqualTo(1);
assertThat(appids(StatKey.NEVER_STARTED)).containsExactly(620L);
assertThat(repository.countGames(USER_ID, StatKey.UNDER_ONE_HOUR, "", BERLIN)).isZero();
}
@Test
void playedCounts_UnderOneHourIsJudgedByTheSetTotal() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 40, GameStatus.PLAYING, Instant.now()),
game(620L, 30, GameStatus.PLAYING, Instant.now()),
game(700L, 20, GameStatus.PLAYING, Instant.now())));
link(220L, 620L);
assertThat(repository.playedCounts(USER_ID)).isEqualTo(new UserStats.PlayedCounts(2, 1));
assertThat(appids(StatKey.UNDER_ONE_HOUR)).containsExactly(700L);
}
@Test
void neverStarted_ASetWithEveryEdition0IsCountedOnce() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 0, GameStatus.BACKLOG, Instant.now()),
game(620L, 0, GameStatus.BACKLOG, Instant.now())));
link(220L, 620L);
assertThat(repository.countGames(USER_ID, StatKey.NEVER_STARTED, "", BERLIN)).isEqualTo(1);
assertThat(appids(StatKey.NEVER_STARTED)).hasSize(1);
assertThat(repository.playedCounts(USER_ID)).isEqualTo(new UserStats.PlayedCounts(0, 0));
}
@Test
void playedCounts_ManualAndSteamSet() {
steamGameRepository.saveAndFlush(new SteamGame(8001L, "Manual", null, Instant.now()));
userGameRepository.saveAllAndFlush(List.of(
game(220L, 0, GameStatus.BACKLOG, Instant.now()),
UserGame.manual(USER_ID, 8001L, GameSource.MANUAL, "PS5", GameStatus.BACKLOG, 50, Instant.now())));
link(220L, 8001L);
assertThat(repository.playedCounts(USER_ID)).isEqualTo(new UserStats.PlayedCounts(1, 1));
assertThat(repository.countGames(USER_ID, StatKey.NEVER_STARTED, "", BERLIN)).isZero();
}
@Test
void playedCounts_NoGames() {
assertThat(repository.playedCounts(USER_ID)).isEqualTo(new UserStats.PlayedCounts(0, 0));
assertThat(repository.countGames(USER_ID, StatKey.PLAYED, "", BERLIN)).isZero();
assertThat(repository.countGames(USER_ID, StatKey.NEVER_STARTED, "", BERLIN)).isZero();
assertThat(repository.countGames(USER_ID, StatKey.UNDER_ONE_HOUR, "", BERLIN)).isZero();
}
@Test
void oldestUnplayed_LeavesOutAPileSetPlayedOnPlayStation() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 0, GameStatus.BACKLOG, Instant.EPOCH),
game(620L, 0, GameStatus.BACKLOG, Instant.parse("2026-01-01T00:00:00Z"))));
psnPlay(220L, 120);
assertThat(repository.oldestUnplayed(USER_ID).orElseThrow().appid()).isEqualTo(620L);
}
@Test @Test
void finishedPerYear_CountsAGameOnceInTheYearItFirstGotFinished() { void finishedPerYear_CountsAGameOnceInTheYearItFirstGotFinished() {
userGameRepository.saveAllAndFlush(List.of( userGameRepository.saveAllAndFlush(List.of(
@@ -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());
@@ -20,6 +20,7 @@ import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.anyCollection; import static org.mockito.ArgumentMatchers.anyCollection;
@@ -63,8 +64,9 @@ class GameEditionServiceTest extends AbstractUnitTest {
when(igdbApiService.getEditions(Set.of(620L, 700L))).thenReturn(Optional.of(Map.of( when(igdbApiService.getEditions(Set.of(620L, 700L))).thenReturn(Optional.of(Map.of(
620L, new IgdbApiService.Edition(72, 10)))); 620L, new IgdbApiService.Edition(72, 10))));
service.refresh(List.of(220L, 620L, 700L)); Set<Long> stored = service.refresh(List.of(220L, 620L, 700L));
assertThat(stored).containsExactlyInAnyOrder(620L, 700L);
verify(gameEditionRepository).upsert(eq(620L), eq(72L), eq(10L), any(Instant.class)); verify(gameEditionRepository).upsert(eq(620L), eq(72L), eq(10L), any(Instant.class));
verify(gameEditionRepository).upsert(eq(700L), isNull(), isNull(), any(Instant.class)); verify(gameEditionRepository).upsert(eq(700L), isNull(), isNull(), any(Instant.class));
verify(gameEditionRepository, never()).upsert(eq(220L), any(), any(), any()); verify(gameEditionRepository, never()).upsert(eq(220L), any(), any(), any());
@@ -97,8 +99,9 @@ class GameEditionServiceTest extends AbstractUnitTest {
void refresh_IgdbOff_DoesNothing() { void refresh_IgdbOff_DoesNothing() {
when(igdbApiService.isEnabled()).thenReturn(false); when(igdbApiService.isEnabled()).thenReturn(false);
service.refresh(List.of(620L)); Set<Long> stored = service.refresh(List.of(620L));
assertThat(stored).isEmpty();
verifyNoInteractions(gameEditionRepository, gameLinkRepository); verifyNoInteractions(gameEditionRepository, gameLinkRepository);
} }
@@ -10,6 +10,7 @@ import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserSteamSync; import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.domain.WishlistGame; import core_service.modules.library.domain.WishlistGame;
import core_service.modules.library.repo.GameCorrectionRepository; import core_service.modules.library.repo.GameCorrectionRepository;
import core_service.modules.library.repo.GameMergeRepository;
import core_service.modules.library.repo.PlaytimeDayRepository; import core_service.modules.library.repo.PlaytimeDayRepository;
import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.SyncChangeRepository; import core_service.modules.library.repo.SyncChangeRepository;
@@ -107,6 +108,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@Mock @Mock
private GameCorrectionRepository gameCorrectionRepository; private GameCorrectionRepository gameCorrectionRepository;
@Mock
private GameMergeRepository gameMergeRepository;
@Mock @Mock
private UserGameRepository userGameRepository; private UserGameRepository userGameRepository;
@@ -297,6 +301,45 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 120); verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 120);
} }
@Test
void runSync_ManualCandidate_RefreshesEditionsBeforeTheSaveAndLinksAfter() {
steamReturns(game(100, 0), STEAM_ID);
when(gameMergeRepository.hasSteamTakeoverCandidates(USER_ID)).thenReturn(true);
when(gameEditionService.refresh(List.of(220L))).thenReturn(Set.of(220L));
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
InOrder order = inOrder(gameEditionService, gameMergeRepository);
order.verify(gameEditionService).refresh(List.of(220L));
order.verify(gameMergeRepository).findSteamTakeovers(eq(USER_ID), any());
order.verify(gameEditionService).linkSameGames(Set.of(220L));
}
@Test
void runSync_NoManualCandidate_DoesNotRefreshBeforeTheSave() {
steamReturns(game(100, 0), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(gameEditionService).refresh(anyList());
verify(gameEditionService, never()).linkSameGames(any());
}
@Test
void runSync_Takeover_MovesTheRowBeforeLoadingTheUserGames() {
steamReturns(game(100, 0), STEAM_ID);
when(gameMergeRepository.findSteamTakeovers(eq(USER_ID), any())).thenReturn(Map.of(-119388L, 220L));
when(gameMergeRepository.rekeyUserGame(USER_ID, -119388L, 220L)).thenReturn(true);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
InOrder order = inOrder(steamGameRepository, gameMergeRepository, userGameRepository);
order.verify(steamGameRepository).flush();
order.verify(gameMergeRepository).findSteamTakeovers(eq(USER_ID), any());
order.verify(gameMergeRepository).rekeyUserGame(USER_ID, -119388L, 220L);
order.verify(userGameRepository).findByUserId(USER_ID);
}
private static SteamOwnedGamesResponse.Game gameLastPlayed(Long rtimeLastPlayed) { private static SteamOwnedGamesResponse.Game gameLastPlayed(Long rtimeLastPlayed) {
return new SteamOwnedGamesResponse.Game(220, "Half-Life 2", 100, "icon", true, 0, rtimeLastPlayed); return new SteamOwnedGamesResponse.Game(220, "Half-Life 2", 100, "icon", true, 0, rtimeLastPlayed);
} }
@@ -16,6 +16,7 @@ import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import java.time.Instant;
import java.util.List; import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -55,6 +56,7 @@ class RewardControllerTest extends AbstractIntegrationTest {
jdbc.update("delete from user_psn_title where user_id = ?", USER); jdbc.update("delete from user_psn_title where user_id = ?", USER);
jdbc.update("delete from game_link where appid in (?, ?)", GAME, EDITION); jdbc.update("delete from game_link where appid in (?, ?)", GAME, EDITION);
jdbc.update("delete from game_achievement_schema_meta where appid = ?", GAME); jdbc.update("delete from game_achievement_schema_meta where appid = ?", GAME);
jdbc.update("delete from user_game_achievement where user_id = ?", USER);
jdbc.update("delete from reward_ledger where user_id = ?", OTHER_USER); jdbc.update("delete from reward_ledger where user_id = ?", OTHER_USER);
} }
@@ -74,6 +76,11 @@ class RewardControllerTest extends AbstractIntegrationTest {
return count != null ? count : 0; return count != null ? count : 0;
} }
@Test
void wallet_AnswersTheEarlySinceDay() throws Exception {
mockMvc.perform(get("/api/rewards/wallet")).andExpect(jsonPath("$.earlySince").value("2026-09-10"));
}
private void wallet() throws Exception { private void wallet() throws Exception {
mockMvc.perform(get("/api/rewards/wallet")).andExpect(status().isOk()); mockMvc.perform(get("/api/rewards/wallet")).andExpect(status().isOk());
} }
@@ -481,6 +488,124 @@ class RewardControllerTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.balance").value(70)); .andExpect(jsonPath("$.balance").value(70));
} }
// An old first unlock and the given last one, so the test shows that the latest counts.
private void lastUnlock(long appid, String timestamp) {
jdbc.update("insert into user_game_achievement (user_id, appid, api_name, unlocked, unlocked_at) "
+ "values (?, ?, 'ACH_FIRST', true, '2025-01-01T00:00:00Z'::timestamptz)", USER, appid);
jdbc.update("insert into user_game_achievement (user_id, appid, api_name, unlocked, unlocked_at) "
+ "values (?, ?, 'ACH_LAST', true, ?::timestamptz)", USER, appid, timestamp);
}
private void paidRow(String kind, long appid) {
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, ?, ?, 15, now())",
USER, kind, String.valueOf(appid));
}
private void completedSetWithManualFinish() {
game(EDITION, "MANUAL", "COMPLETED", 0);
game(GAME, "STEAM", "BACKLOG", 120);
setStatus(GAME, "COMPLETED");
allAchievements();
jdbc.update("insert into game_link (appid, set_id) values (?, 4100), (?, 4100)", GAME, EDITION);
lastUnlock(GAME, Instant.now().toString());
paidRow("FINISHED_MANUAL", EDITION);
}
@Test
void gamePoints_SetManualFinishPaid_SteamEditionCompletedPaysOnce() throws Exception {
completedSetWithManualFinish();
gamePoints(GAME)
.andExpect(jsonPath("$.paid[?(@.kind == 'COMPLETED')].points").value(40))
.andExpect(jsonPath("$.missing.COMPLETED").doesNotExist());
gamePoints(GAME);
gamePoints(EDITION);
assertThat(rows("COMPLETED")).isEqualTo(1);
assertThat(rows("FINISHED")).isZero();
assertThat(rows("FINISHED_MANUAL")).isEqualTo(1);
}
@Test
void gamePoints_SetManualFinishPaid_FirstFinishOldHundredPercentRecent_PaysCompletedOnce() throws Exception {
completedSetWithManualFinish();
jdbc.update("update user_game_status_change set changed_at = '2026-01-01T00:00:00Z' where user_id = ? and appid = ?",
USER, GAME);
gamePoints(GAME)
.andExpect(jsonPath("$.paid[?(@.kind == 'COMPLETED')].points").value(40))
.andExpect(jsonPath("$.missing.COMPLETED").doesNotExist());
assertThat(rows("COMPLETED")).isEqualTo(1);
}
@Test
void gamePoints_OldHundredPercentMarkedCompletedNow_ManualEditionPaid_NoCompleted() throws Exception {
completedSetWithManualFinish();
jdbc.update("delete from user_game_achievement where user_id = ?", USER);
lastUnlock(GAME, "2026-01-01T00:00:00Z");
gamePoints(GAME).andExpect(jsonPath("$.missing.COMPLETED").value("COMPLETION_TOO_OLD"))
.andExpect(jsonPath("$.earlySince").value("2026-09-10"));
gamePoints(EDITION);
assertThat(rows("COMPLETED")).isZero();
}
@Test
void gamePoints_ManualEditionAddedCompletedForOldSteamFinish_NoCompleted() throws Exception {
game(GAME, "STEAM", "COMPLETED", 120);
jdbc.update("update user_game_status_change set changed_at = '2025-01-01T00:00:00Z' where user_id = ? and appid = ?",
USER, GAME);
allAchievements();
lastUnlock(GAME, "2025-01-01T00:00:00Z");
game(EDITION, "MANUAL", "COMPLETED", 0);
jdbc.update("insert into game_link (appid, set_id) values (?, 4100), (?, 4100)", GAME, EDITION);
gamePoints(GAME).andExpect(jsonPath("$.missing.COMPLETED").value("COMPLETION_TOO_OLD"));
assertThat(rows("COMPLETED")).isZero();
}
@Test
void gamePoints_SetManualAndSteamFinish_CompletedOnce() throws Exception {
completedSetWithManualFinish();
paidRow("FINISHED", GAME);
gamePoints(GAME);
gamePoints(EDITION);
assertThat(rows("COMPLETED")).isEqualTo(1);
}
@Test
void gamePoints_ManualGameCompletedWithManualFinish_NoCompleted() throws Exception {
game(GAME, "MANUAL", "COMPLETED", 0);
allAchievements();
paidRow("FINISHED_MANUAL", GAME);
gamePoints(GAME).andExpect(jsonPath("$.missing.COMPLETED").value("MANUAL_GAME"));
assertThat(rows("COMPLETED")).isZero();
}
@Test
void gamePoints_ManualFinishPaid_IncompleteAchievementsWithOldUnlocks_NotAllAchievements() throws Exception {
completedSetWithManualFinish();
jdbc.update("delete from user_game_achievement where user_id = ?", USER);
jdbc.update("update user_game_achievement_meta set achievements_unlocked = 5 where user_id = ? and appid = ?",
USER, GAME);
lastUnlock(GAME, "2026-01-01T00:00:00Z");
gamePoints(GAME).andExpect(jsonPath("$.missing.COMPLETED").value("NOT_ALL_ACHIEVEMENTS"));
}
@Test
void gamePoints_ManualFinishPaid_GameWithoutAchievements_NotAllAchievements() throws Exception {
completedSetWithManualFinish();
jdbc.update("delete from user_game_achievement where user_id = ?", USER);
jdbc.update("delete from user_game_achievement_meta where user_id = ? and appid = ?", USER, GAME);
jdbc.update("delete from game_achievement_schema_meta where appid = ?", GAME);
gamePoints(GAME).andExpect(jsonPath("$.missing.COMPLETED").value("NOT_ALL_ACHIEVEMENTS"));
}
@Test @Test
void gamePoints_FinishedPlayingFinishedAfterEarly_PaysNoSecondEarly() throws Exception { void gamePoints_FinishedPlayingFinishedAfterEarly_PaysNoSecondEarly() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120); game(GAME, "STEAM", "BACKLOG", 120);
@@ -26,17 +26,17 @@ class RewardRulesTest extends AbstractUnitTest {
} }
private static GameRewardFacts game(boolean manual, boolean finished, boolean completed, boolean finishLogged) { private static GameRewardFacts game(boolean manual, boolean finished, boolean completed, boolean finishLogged) {
return new GameRewardFacts(manual, finished, completed, false, false, true, finishLogged, false); return new GameRewardFacts(manual, finished, completed, false, false, true, finishLogged, false, true);
} }
private static final GameRewardFacts STEAM_FINISHED = game(false, true, false, true); private static final GameRewardFacts STEAM_FINISHED = game(false, true, false, true);
@Test @Test
void blocked_HiddenBeforePlaystation() { void blocked_HiddenBeforePlaystation() {
GameRewardFacts all = new GameRewardFacts(false, true, false, true, true, true, true, false); GameRewardFacts all = new GameRewardFacts(false, true, false, true, true, true, true, false, false);
assertThat(RewardRules.blocked(all)).isEqualTo(NoPointsReason.HIDDEN); assertThat(RewardRules.blocked(all)).isEqualTo(NoPointsReason.HIDDEN);
assertThat(RewardRules.blocked(new GameRewardFacts(false, true, false, false, true, true, true, false))) assertThat(RewardRules.blocked(new GameRewardFacts(false, true, false, false, true, true, true, false, false)))
.isEqualTo(NoPointsReason.PLAYSTATION); .isEqualTo(NoPointsReason.PLAYSTATION);
assertThat(RewardRules.blocked(STEAM_FINISHED)).isNull(); assertThat(RewardRules.blocked(STEAM_FINISHED)).isNull();
} }
@@ -65,7 +65,7 @@ class RewardRulesTest extends AbstractUnitTest {
@Test @Test
void noTopUpReason_LinkedTitleWithoutProof_IsPlayNotCounted() { void noTopUpReason_LinkedTitleWithoutProof_IsPlayNotCounted() {
GameRewardFacts linked = new GameRewardFacts(false, true, false, false, false, true, true, true); GameRewardFacts linked = new GameRewardFacts(false, true, false, false, false, true, true, true, false);
assertThat(RewardRules.noTopUpReason(linked, steam(60, 300, false, false))) assertThat(RewardRules.noTopUpReason(linked, steam(60, 300, false, false)))
.isEqualTo(NoPointsReason.PLAY_NOT_COUNTED); .isEqualTo(NoPointsReason.PLAY_NOT_COUNTED);
@@ -117,7 +117,7 @@ class RewardRulesTest extends AbstractUnitTest {
assertThat(RewardRules.noStartReason(game(false, false, false, false), idle)) assertThat(RewardRules.noStartReason(game(false, false, false, false), idle))
.isEqualTo(NoPointsReason.NO_PLAY_AFTER_START); .isEqualTo(NoPointsReason.NO_PLAY_AFTER_START);
assertThat(RewardRules.noStartReason(new GameRewardFacts(false, false, false, false, false, false, false, false), assertThat(RewardRules.noStartReason(new GameRewardFacts(false, false, false, false, false, false, false, false, false),
null)).isEqualTo(NoPointsReason.NOT_STARTED); null)).isEqualTo(NoPointsReason.NOT_STARTED);
} }
@@ -125,9 +125,12 @@ class RewardRulesTest extends AbstractUnitTest {
void noCompletedReason_WithoutProvenFinish_IsNeedsProvenFinish() { void noCompletedReason_WithoutProvenFinish_IsNeedsProvenFinish() {
GameRewardFacts g = game(false, true, true, true); GameRewardFacts g = game(false, true, true, true);
assertThat(RewardRules.noCompletedReason(g, null, false)).isEqualTo(NoPointsReason.NEEDS_PROVEN_FINISH); assertThat(RewardRules.noCompletedReason(g, null, false, false)).isEqualTo(NoPointsReason.NEEDS_PROVEN_FINISH);
assertThat(RewardRules.noCompletedReason(STEAM_FINISHED, null, true)).isEqualTo(NoPointsReason.NOT_COMPLETED); assertThat(RewardRules.noCompletedReason(g, null, false, true)).isEqualTo(NoPointsReason.COMPLETION_TOO_OLD);
assertThat(RewardRules.noCompletedReason(game(true, true, true, true), null, true)) assertThat(RewardRules.noCompletedReason(new GameRewardFacts(false, true, true, false, false, true, true, false, false),
null, false, true)).isEqualTo(NoPointsReason.NOT_ALL_ACHIEVEMENTS);
assertThat(RewardRules.noCompletedReason(STEAM_FINISHED, null, true, false)).isEqualTo(NoPointsReason.NOT_COMPLETED);
assertThat(RewardRules.noCompletedReason(game(true, true, true, true), null, true, false))
.isEqualTo(NoPointsReason.MANUAL_GAME); .isEqualTo(NoPointsReason.MANUAL_GAME);
} }
@@ -135,7 +138,7 @@ class RewardRulesTest extends AbstractUnitTest {
void noCompletedReason_MissingAchievements() { void noCompletedReason_MissingAchievements() {
FinishFacts open = new FinishFacts(220L, false, true, 60, 0, null, false, false, false, AT, false); FinishFacts open = new FinishFacts(220L, false, true, 60, 0, null, false, false, false, AT, false);
assertThat(RewardRules.noCompletedReason(game(false, true, true, true), open, true)) assertThat(RewardRules.noCompletedReason(game(false, true, true, true), open, true, false))
.isEqualTo(NoPointsReason.NOT_ALL_ACHIEVEMENTS); .isEqualTo(NoPointsReason.NOT_ALL_ACHIEVEMENTS);
} }
@@ -25,6 +25,7 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
private static final long USER = 7L; private static final long USER = 7L;
private static final long GAME = 220L; private static final long GAME = 220L;
private static final long EDITION = 221L; private static final long EDITION = 221L;
private static final java.time.Instant EARLY = java.time.Instant.parse("2026-09-10T00:00:00Z");
private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin"); private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin");
@Autowired @Autowired
@@ -74,7 +75,7 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', ?)", USER, GAME); jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', ?)", USER, GAME);
assertThat(repository.game(USER, GAME)).hasValue( assertThat(repository.game(USER, GAME)).hasValue(
new GameRewardFacts(false, true, false, true, false, true, true, true)); new GameRewardFacts(false, true, false, true, false, true, true, true, false));
} }
@Test @Test
@@ -105,8 +106,8 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(GAME), 30, Instant.now())); ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(GAME), 30, Instant.now()));
ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(EDITION), 30, Instant.now())); ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(EDITION), 30, Instant.now()));
assertThat(repository.completedCandidates(USER)).hasSize(2); assertThat(repository.completedCandidates(USER, EARLY)).hasSize(2);
assertThat(repository.completedCandidates(USER, GAME)).extracting(FinishFacts::appid).containsExactly(GAME); assertThat(repository.completedCandidates(USER, GAME, EARLY)).extracting(FinishFacts::appid).containsExactly(GAME);
} }
@Test @Test
@@ -217,12 +218,12 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.startCandidates(USER, BERLIN)).hasSize(1); assertThat(repository.startCandidates(USER, BERLIN)).hasSize(1);
setStatus("COMPLETED"); setStatus("COMPLETED");
ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(GAME), 30, Instant.now())); ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(GAME), 30, Instant.now()));
assertThat(repository.completedCandidates(USER)).hasSize(1); assertThat(repository.completedCandidates(USER, EARLY)).hasSize(1);
jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = ?", USER, GAME); jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = ?", USER, GAME);
assertThat(repository.startCandidates(USER, BERLIN)).isEmpty(); assertThat(repository.startCandidates(USER, BERLIN)).isEmpty();
assertThat(repository.completedCandidates(USER)).isEmpty(); assertThat(repository.completedCandidates(USER, EARLY)).isEmpty();
} }
@Test @Test
@@ -275,15 +276,15 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
void completedCandidates_NeedACompletedGameWithAPaidFinish() { void completedCandidates_NeedACompletedGameWithAPaidFinish() {
game("BACKLOG", 120); game("BACKLOG", 120);
setStatus("COMPLETED"); setStatus("COMPLETED");
assertThat(repository.completedCandidates(USER)).isEmpty(); assertThat(repository.completedCandidates(USER, EARLY)).isEmpty();
Instant finishedAt = Instant.parse("2026-09-15T10:00:00Z"); Instant finishedAt = Instant.parse("2026-09-15T10:00:00Z");
ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(GAME), 30, finishedAt)); ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(GAME), 30, finishedAt));
assertThat(repository.completedCandidates(USER)).singleElement() assertThat(repository.completedCandidates(USER, EARLY)).singleElement()
.extracting(FinishFacts::finishedAt).isEqualTo(finishedAt); .extracting(FinishFacts::finishedAt).isEqualTo(finishedAt);
ledger.add(USER, new RewardEntry(RewardKind.COMPLETED, String.valueOf(GAME), 40, finishedAt)); ledger.add(USER, new RewardEntry(RewardKind.COMPLETED, String.valueOf(GAME), 40, finishedAt));
assertThat(repository.completedCandidates(USER)).isEmpty(); assertThat(repository.completedCandidates(USER, EARLY)).isEmpty();
} }
// The game and a manual edition of it, one set, both with the given status. // The game and a manual edition of it, one set, both with the given status.
@@ -329,11 +330,11 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
setOfTwo("COMPLETED"); setOfTwo("COMPLETED");
paid(RewardKind.FINISHED, GAME); paid(RewardKind.FINISHED, GAME);
paid(RewardKind.FINISHED, EDITION); paid(RewardKind.FINISHED, EDITION);
assertThat(repository.completedCandidates(USER)).hasSize(2); assertThat(repository.completedCandidates(USER, EARLY)).hasSize(2);
paid(RewardKind.COMPLETED, EDITION); paid(RewardKind.COMPLETED, EDITION);
assertThat(repository.completedCandidates(USER)).isEmpty(); assertThat(repository.completedCandidates(USER, EARLY)).isEmpty();
} }
@Test @Test
@@ -411,7 +412,7 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.startCandidates(USER, BERLIN)).isEmpty(); assertThat(repository.startCandidates(USER, BERLIN)).isEmpty();
assertThat(repository.finishCandidates(USER, BERLIN)).isEmpty(); assertThat(repository.finishCandidates(USER, BERLIN)).isEmpty();
assertThat(repository.completedCandidates(USER)).isEmpty(); assertThat(repository.completedCandidates(USER, EARLY)).isEmpty();
assertThat(repository.minutesPerMonth(USER)).isEmpty(); assertThat(repository.minutesPerMonth(USER)).isEmpty();
} }
@@ -483,7 +484,116 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
jdbc.update("insert into user_game_achievement_meta (user_id, appid, achievements_unlocked) values (?, ?, 10)", USER, GAME); jdbc.update("insert into user_game_achievement_meta (user_id, appid, achievements_unlocked) values (?, ?, 10)", USER, GAME);
paid(RewardKind.FINISHED, other); paid(RewardKind.FINISHED, other);
assertThat(repository.completedCandidates(USER)).extracting(FinishFacts::appid).contains(GAME); assertThat(repository.completedCandidates(USER, EARLY)).extracting(FinishFacts::appid).contains(GAME);
}
private void allAchievements() {
jdbc.update("insert into game_achievement_schema_meta (appid, achievements_total, has_schema) values (?, 10, true) "
+ "on conflict do nothing", GAME);
jdbc.update("insert into user_game_achievement_meta (user_id, appid, achievements_unlocked) values (?, ?, 10)", USER, GAME);
}
// An old first unlock and the given last one, so the test shows that the latest counts.
private void lastUnlock(String timestamp) {
jdbc.update("insert into user_game_achievement (user_id, appid, api_name, unlocked, unlocked_at) "
+ "values (?, ?, 'ACH_FIRST', true, '2025-01-01T00:00:00Z'::timestamptz)", USER, GAME);
jdbc.update("insert into user_game_achievement (user_id, appid, api_name, unlocked, unlocked_at) "
+ "values (?, ?, 'ACH_LAST', true, ?::timestamptz)", USER, GAME, timestamp);
}
@Test
void completedCandidates_ManualFinishPaidOnAnotherEditionOfTheSet() {
setOfTwo("COMPLETED");
jdbc.update("update user_game set status = 'COMPLETED' where user_id = ? and appid = ?", USER, EDITION);
allAchievements();
lastUnlock("2026-09-15T00:00:00Z");
paid(RewardKind.FINISHED_MANUAL, EDITION);
assertThat(repository.completedCandidates(USER, EARLY)).filteredOn(f -> f.appid() == GAME).singleElement()
.satisfies(f -> assertThat(f.manual()).isFalse());
assertThat(repository.completedCandidates(USER, EARLY)).filteredOn(f -> f.appid() == EDITION)
.allSatisfy(f -> assertThat(f.manual()).isTrue());
}
@Test
void completedCandidates_ManualFinishOpensNothingWhenTheLastUnlockIsBeforeEarlySince() {
setOfTwo("COMPLETED");
allAchievements();
paid(RewardKind.FINISHED_MANUAL, EDITION);
lastUnlock("2026-09-09T23:59:59Z");
assertThat(repository.completedCandidates(USER, GAME, EARLY)).isEmpty();
}
@Test
void completedCandidates_ManualFinishOpensWhenTheLastUnlockIsExactlyEarlySince() {
setOfTwo("COMPLETED");
allAchievements();
paid(RewardKind.FINISHED_MANUAL, EDITION);
lastUnlock("2026-09-10T00:00:00Z");
assertThat(repository.completedCandidates(USER, GAME, EARLY)).extracting(FinishFacts::appid)
.containsExactly(GAME);
}
@Test
void completedCandidates_ManualFinishOpensNothingWithoutUnlockTimes() {
setOfTwo("COMPLETED");
allAchievements();
paid(RewardKind.FINISHED_MANUAL, EDITION);
assertThat(repository.completedCandidates(USER, GAME, EARLY)).isEmpty();
jdbc.update("insert into user_game_achievement (user_id, appid, api_name, unlocked, unlocked_at) "
+ "values (?, ?, 'ACH_X', true, null)", USER, GAME);
assertThat(repository.completedCandidates(USER, GAME, EARLY)).isEmpty();
}
@Test
void completedCandidates_ManualFinishOpensWhenTheFirstFinishIsOldButTheLastUnlockRecent() {
setOfTwo("COMPLETED");
allAchievements();
paid(RewardKind.FINISHED_MANUAL, EDITION);
changedAt("COMPLETED", "2026-01-01T00:00:00Z");
lastUnlock("2026-09-15T00:00:00Z");
assertThat(repository.completedCandidates(USER, GAME, EARLY)).extracting(FinishFacts::appid)
.containsExactly(GAME);
}
@Test
void completedCandidates_ManualFinishOpensWithoutStatusChangeWhenTheLastUnlockIsRecent() {
setOfTwo("COMPLETED");
allAchievements();
paid(RewardKind.FINISHED_MANUAL, EDITION);
jdbc.update("delete from user_game_status_change where user_id = ? and appid = ?", USER, GAME);
lastUnlock("2026-09-15T00:00:00Z");
assertThat(repository.completedCandidates(USER, GAME, EARLY)).extracting(FinishFacts::appid)
.containsExactly(GAME);
}
@Test
void completedCandidates_ManualFinishOnOwnSteamKey() {
game("BACKLOG", 120);
setStatus("COMPLETED");
allAchievements();
lastUnlock("2026-09-15T00:00:00Z");
paid(RewardKind.FINISHED_MANUAL, GAME);
assertThat(repository.completedCandidates(USER, EARLY)).extracting(FinishFacts::appid).containsExactly(GAME);
}
@Test
void completedCandidates_ManualFinishOnOwnSteamKeyWithOldLastUnlock_OpensNothing() {
game("BACKLOG", 120);
setStatus("COMPLETED");
allAchievements();
lastUnlock("2026-01-01T00:00:00Z");
paid(RewardKind.FINISHED_MANUAL, GAME);
assertThat(repository.completedCandidates(USER, EARLY)).isEmpty();
} }
private void linkedTitle() { private void linkedTitle() {
@@ -554,11 +664,11 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
game("BACKLOG", 120); game("BACKLOG", 120);
setStatus("COMPLETED"); setStatus("COMPLETED");
paid(RewardKind.FINISHED_EARLY, GAME); paid(RewardKind.FINISHED_EARLY, GAME);
assertThat(repository.completedCandidates(USER)).isEmpty(); assertThat(repository.completedCandidates(USER, EARLY)).isEmpty();
paid(RewardKind.FINISHED_TOPUP, GAME); paid(RewardKind.FINISHED_TOPUP, GAME);
assertThat(repository.completedCandidates(USER)).extracting(FinishFacts::appid).containsExactly(GAME); assertThat(repository.completedCandidates(USER, EARLY)).extracting(FinishFacts::appid).containsExactly(GAME);
} }
@Test @Test