Public Access
Compare commits
6
Commits
0550f3fe9c
...
0f56f5bb0f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f56f5bb0f | ||
|
|
1de8acef2e | ||
|
|
59d6b3394d | ||
|
|
1a9a4e912c | ||
|
|
6215bb0dec | ||
|
|
8f884040d8 |
No files matched your search
@@ -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
|
||||
(`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
|
||||
`user_game_entry_playtime` (V45), the same as the library card, sort and public profile. Platforms stay per edition:
|
||||
a Steam + PlayStation set counts on both platforms.
|
||||
`user_game_entry_playtime` (V45), the same as the library card, sort and public profile. So do "played", "never
|
||||
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)
|
||||
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
|
||||
@@ -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
|
||||
`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
|
||||
a finish. Manual finishes and early finishes share the cap of 3 per ISO week. `COMPLETED` needs a `FINISHED` or `FINISHED_TOPUP` row.
|
||||
`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
|
||||
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
|
||||
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),
|
||||
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
|
||||
@@ -221,7 +225,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
|
||||
|
||||
| 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 |
|
||||
| `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 |
|
||||
@@ -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)
|
||||
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.
|
||||
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
|
||||
games and manual games. Hidden games only get their playtime updated.
|
||||
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
|
||||
minutes; it never takes a sync result, which may still wait for its achievements.
|
||||
|
||||
`ALL_UNLOCKED`: the sync unlocked the last Steam achievement of a game that is not COMPLETED; Core never sets COMPLETED itself, the user confirms it in the sync details.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant W as LibrarySyncWorker
|
||||
@@ -428,7 +440,7 @@ sequenceDiagram
|
||||
P-->>W: ADDED, PLAYTIME, ACHIEVEMENTS (trophy gains)
|
||||
W->>D: sync_result, sync_change (ADDED, STATUS, WISHLIST, PSN changes)
|
||||
W-->>A: LibrarySyncedEvent
|
||||
A->>D: sync_change (ACHIEVEMENTS)
|
||||
A->>D: sync_change (ACHIEVEMENTS, ALL_UNLOCKED)
|
||||
A-->>F: LibraryActivityEvent
|
||||
F->>D: lock follow_digest, count sync + hand changes
|
||||
F->>D: inbox_note per follower (public, not blocked, not deleted)
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
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 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
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
public static SyncChange allUnlocked(long appid) {
|
||||
return new SyncChange(SyncChangeKind.ALL_UNLOCKED, appid, null, null, null);
|
||||
}
|
||||
|
||||
public static SyncChange wishlist(long appid) {
|
||||
return new SyncChange(SyncChangeKind.WISHLIST, appid, null, null, null);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ public enum SyncChangeKind {
|
||||
PLAYTIME,
|
||||
ACHIEVEMENTS,
|
||||
STATUS,
|
||||
// The sync unlocked the last achievement of a game that is not COMPLETED.
|
||||
ALL_UNLOCKED,
|
||||
// Only for followers' digests: never shown in the sync banner.
|
||||
WISHLIST
|
||||
}
|
||||
@@ -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).
|
||||
* Never played is judged by the set total (V45), like {@link UserGameRepository#countListedNeverPlayed}.
|
||||
*/
|
||||
@Repository
|
||||
@RequiredArgsConstructor
|
||||
@@ -19,11 +20,12 @@ public class BacklogSnapshotRepository {
|
||||
|
||||
private static final String PILE_COLUMNS = """
|
||||
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,
|
||||
count(*) as listed_games
|
||||
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 user_game_entry_playtime ep on ep.user_id = ug.user_id and ep.appid = ug.appid
|
||||
""";
|
||||
|
||||
private final NamedParameterJdbcTemplate jdbc;
|
||||
|
||||
@@ -20,7 +20,8 @@ import java.util.Map;
|
||||
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
|
||||
@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 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.
|
||||
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))";
|
||||
@@ -366,4 +371,75 @@ public class GameMergeRepository {
|
||||
return jdbc.update("delete from game_merge_undo where created_at < :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.
|
||||
* An ALL_UNLOCKED row goes once the game is COMPLETED.
|
||||
*/
|
||||
public Optional<SyncChanges> find(long userId) {
|
||||
MapSqlParameterSource params = new MapSqlParameterSource("userId", userId);
|
||||
@@ -132,6 +133,7 @@ public class SyncChangeRepository {
|
||||
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
|
||||
left join steam_game sg on sg.appid = c.appid
|
||||
where c.user_id = :userId and ug.hidden_at is null and (ug.removed_at is null or c.kind = 'REMOVED') and c.kind <> 'WISHLIST'
|
||||
and (c.kind <> 'ALL_UNLOCKED' or ug.status <> 'COMPLETED')
|
||||
group by c.kind, c.appid, c.from_status, c.to_status, sg.name, sg.image_url
|
||||
order by c.kind, amount desc nulls last, sg.name
|
||||
""", params, (rs, i) -> new SyncChanges.Entry(
|
||||
|
||||
@@ -123,7 +123,8 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
||||
nativeQuery = true)
|
||||
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);
|
||||
|
||||
@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(
|
||||
Map.entry(StatKey.TOTAL, counted("true")),
|
||||
Map.entry(StatKey.STATUS, counted("ug.status = :arg")),
|
||||
Map.entry(StatKey.PLAYED, counted("ug.playtime_minutes > 0")),
|
||||
Map.entry(StatKey.NEVER_STARTED, counted("ug.playtime_minutes = 0")),
|
||||
Map.entry(StatKey.PLAYED, entries("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.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.PILE_TIME_TO_BEAT, PILE_TTB_SET),
|
||||
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("""
|
||||
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
|
||||
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
|
||||
""".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.getString("image_url"), rs.getTimestamp("first_seen_at").toInstant()))
|
||||
.stream().findFirst();
|
||||
@@ -251,8 +252,8 @@ public class UserStatsRepository {
|
||||
return jdbc.queryForObject("""
|
||||
select count(*) filter (where ug.playtime_minutes > 0) as started,
|
||||
count(*) filter (where %s) as under_one_hour
|
||||
from counted_user_game ug where %s
|
||||
""".formatted(UNDER_ONE_HOUR, OF_USER), user(userId),
|
||||
from %s ug
|
||||
""".formatted(UNDER_ONE_HOUR, ENTRY_PLAYTIME), user(userId),
|
||||
(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;
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
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.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.OptionalInt;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
@@ -237,12 +236,17 @@ public class AchievementSyncService {
|
||||
return Outcome.UNCHANGED;
|
||||
}
|
||||
pause();
|
||||
OptionalInt unlocked = refreshUser(userId, steamId, appid, playtime);
|
||||
if (unlocked.isEmpty()) return Outcome.HIDDEN;
|
||||
Optional<Unlocks> refreshed = refreshUser(userId, steamId, appid, playtime);
|
||||
if (refreshed.isEmpty()) return Outcome.HIDDEN;
|
||||
Unlocks unlocks = refreshed.get();
|
||||
// Without an earlier visible refresh there is no baseline: the first refresh would show every unlock.
|
||||
if (meta != null && meta.isStatsVisible() && unlocked.getAsInt() > meta.getAchievementsUnlocked()) {
|
||||
if (meta != null && meta.isStatsVisible() && unlocks.unlocked() > meta.getAchievementsUnlocked()) {
|
||||
syncChangeRepository.add(userId,
|
||||
SyncChange.achievements(appid, unlocked.getAsInt() - meta.getAchievementsUnlocked()));
|
||||
SyncChange.achievements(appid, unlocks.unlocked() - meta.getAchievementsUnlocked()));
|
||||
// Only the step to all: a game at all achievements before this sync gets no line.
|
||||
if (unlocks.unlocked() == unlocks.total() && game.getStatus() != GameStatus.COMPLETED) {
|
||||
syncChangeRepository.add(userId, SyncChange.allUnlocked(appid));
|
||||
}
|
||||
}
|
||||
return Outcome.VISIBLE;
|
||||
}
|
||||
@@ -300,9 +304,10 @@ public class AchievementSyncService {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the unlocked count, empty when Steam hides the achievements of this user
|
||||
* @return the unlocked count and the number of achievements Steam listed, empty when Steam hides the achievements
|
||||
* of this user
|
||||
*/
|
||||
private OptionalInt refreshUser(Long userId, String steamId, Long appid, int playtimeMinutes) {
|
||||
private Optional<Unlocks> refreshUser(Long userId, String steamId, Long appid, int playtimeMinutes) {
|
||||
Optional<SteamPlayerAchievementsResponse.PlayerStats> response = steamApiService.getPlayerAchievements(steamId, appid);
|
||||
boolean visible = response.isPresent() && (response.get().success() == null || response.get().success());
|
||||
List<UserGameAchievement> rows = visible && response.get().achievements() != null
|
||||
@@ -326,7 +331,7 @@ public class AchievementSyncService {
|
||||
userAchievementMetaRepository.save(
|
||||
new UserGameAchievementMeta(userId, appid, Instant.now(), visible, unlockedCount, playtimeAtRefresh));
|
||||
});
|
||||
return visible ? OptionalInt.of(unlockedCount) : OptionalInt.empty();
|
||||
return visible ? Optional.of(new Unlocks(unlockedCount, rows.size())) : Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -364,5 +369,7 @@ public class AchievementSyncService {
|
||||
}
|
||||
}
|
||||
|
||||
private record Unlocks(int unlocked, int total) {}
|
||||
|
||||
private enum Outcome { UNCHANGED, VISIBLE, HIDDEN }
|
||||
}
|
||||
@@ -45,10 +45,25 @@ public class GameEditionService {
|
||||
this.transaction = new TransactionTemplate(transactionManager);
|
||||
}
|
||||
|
||||
public void refresh(Collection<Long> appids) {
|
||||
if (!igdbApiService.isEnabled() || appids.isEmpty()) return;
|
||||
Set<Long> stale = stale(appids);
|
||||
if (stale.isEmpty()) return;
|
||||
/**
|
||||
* @return the keys stored now; linkSameGames ran for them, but a key without a steam_game row is not linked yet
|
||||
*/
|
||||
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,
|
||||
igdbApiService::getEditionsByGameIds);
|
||||
@@ -63,6 +78,7 @@ public class GameEditionService {
|
||||
edition == null ? null : edition.sameGameId(), now);
|
||||
}
|
||||
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.WishlistGame;
|
||||
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.SteamGameRepository;
|
||||
import core_service.modules.library.repo.SyncChangeRepository;
|
||||
@@ -59,6 +60,7 @@ public class LibrarySyncWorker {
|
||||
private final SteamApiService steamApiService;
|
||||
private final SteamGameRepository steamGameRepository;
|
||||
private final GameCorrectionRepository gameCorrectionRepository;
|
||||
private final GameMergeRepository gameMergeRepository;
|
||||
private final UserGameRepository userGameRepository;
|
||||
private final UserSteamSyncRepository userSteamSyncRepository;
|
||||
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.
|
||||
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);
|
||||
boolean saved = transaction.execute(tx -> {
|
||||
List<SyncChange> wishlistAdds = wishlist.map(found -> saveWishlist(userId, found)).orElseGet(() -> {
|
||||
@@ -174,8 +186,12 @@ public class LibrarySyncWorker {
|
||||
if (!saved) {
|
||||
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.
|
||||
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.removeAll(new HashSet<>(userGameRepository.findHiddenAppids(userId)));
|
||||
timeToBeatService.refresh(igdbAppids);
|
||||
@@ -214,6 +230,10 @@ public class LibrarySyncWorker {
|
||||
Set<Long> correctedNames = gameCorrectionRepository.findCorrectedAppids(CorrectionField.NAME, appids);
|
||||
Map<Long, SteamGame> knownGames = steamGameRepository.findAllById(appids).stream()
|
||||
.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()
|
||||
.collect(Collectors.toMap(UserGame::getAppid, game -> game));
|
||||
List<SyncChange> changes = new ArrayList<>();
|
||||
@@ -226,7 +246,6 @@ public class LibrarySyncWorker {
|
||||
List<Long> played = new ArrayList<>();
|
||||
for (SteamOwnedGamesResponse.Game g : games) {
|
||||
Long appid = g.appid().longValue();
|
||||
upsertGame(g, knownGames.get(appid), now, correctedNames);
|
||||
if (upsertUserGame(userId, g, userGames.get(appid), now, playedOn, changes)) {
|
||||
played.add(appid);
|
||||
}
|
||||
@@ -243,6 +262,21 @@ public class LibrarySyncWorker {
|
||||
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 Optional<Wishlist> fetchWishlist(String steamId, List<SteamOwnedGamesResponse.Game> ownedGames) {
|
||||
@@ -304,6 +338,7 @@ public class LibrarySyncWorker {
|
||||
Long appid = g.appid().longValue();
|
||||
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.
|
||||
// Also a row moved here from minus its IGDB id (takeOverManualGames).
|
||||
boolean takesOverManualGame = existing != null && existing.getSource() != GameSource.STEAM;
|
||||
int previousPlaytime = existing != null ? existing.getPlaytimeMinutes() : 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 java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @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
|
||||
*/
|
||||
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
|
||||
@@ -27,6 +30,7 @@ public record GamePointsDto(long balance, List<PaidDto> paid, Map<RewardKind, No
|
||||
}
|
||||
|
||||
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 java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -16,9 +17,10 @@ import java.util.Set;
|
||||
* @param owned the item codes the user may wear
|
||||
* @param equipped the worn item code per slot
|
||||
* @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,
|
||||
List<EntryDto> history, Map<Track, Integer> progress) {
|
||||
List<EntryDto> history, Map<Track, Integer> progress, LocalDate earlySince) {
|
||||
|
||||
/**
|
||||
* @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.history().stream()
|
||||
.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 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 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,
|
||||
boolean startLogged, boolean finishLogged, boolean linkedTitle) {}
|
||||
boolean startLogged, boolean finishLogged, boolean linkedTitle,
|
||||
boolean allAchievements) {}
|
||||
@@ -30,8 +30,13 @@ public enum NoPointsReason {
|
||||
WEEKLY_LIMIT,
|
||||
/** The weekly cap is used up, so no early points; proof still pays the full finish. */
|
||||
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,
|
||||
/** A FINISHED_MANUAL row is paid, but the game's last achievement unlock is before early-since. */
|
||||
COMPLETION_TOO_OLD,
|
||||
/** COMPLETED needs 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);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@@ -191,19 +191,24 @@ public final class RewardRules {
|
||||
|
||||
/**
|
||||
* @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
|
||||
*/
|
||||
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()) {
|
||||
return NoPointsReason.NOT_COMPLETED;
|
||||
}
|
||||
if (g.manual()) {
|
||||
return NoPointsReason.MANUAL_GAME;
|
||||
}
|
||||
if (!provenFinishPaid) {
|
||||
if (!finishPaid) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.sql.Date;
|
||||
import java.time.Instant;
|
||||
import java.time.YearMonth;
|
||||
import java.time.ZoneId;
|
||||
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.
|
||||
* Every edition of a linked set is a candidate; the service books one per set.
|
||||
* COMPLETED games with a paid FINISHED, FINISHED_TOPUP or FINISHED_MANUAL row (on the key or any key of its 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) {
|
||||
return completedCandidates(userId, null);
|
||||
public List<FinishFacts> completedCandidates(long userId, Instant earlySince) {
|
||||
return completedCandidates(userId, null, earlySince);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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("""
|
||||
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)
|
||||
as all_achievements
|
||||
from user_game ug
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -196,9 +207,10 @@ public class RewardFactsRepository {
|
||||
and not %1$s
|
||||
order by f.event_at, ug.appid
|
||||
""".formatted(paidInSet("ug", "'COMPLETED'")),
|
||||
new MapSqlParameterSource("userId", userId).addValue("appid", appid),
|
||||
(rs, i) -> new FinishFacts(rs.getLong("appid"), false, true, rs.getInt("playtime_minutes"), 0, null,
|
||||
false, false, rs.getBoolean("all_achievements"), rs.getTimestamp("event_at").toInstant(),
|
||||
new MapSqlParameterSource("userId", userId).addValue("appid", appid)
|
||||
.addValue("earlySince", java.sql.Timestamp.from(earlySince)),
|
||||
(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));
|
||||
}
|
||||
|
||||
@@ -216,13 +228,18 @@ public class RewardFactsRepository {
|
||||
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,
|
||||
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
|
||||
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
|
||||
""", new MapSqlParameterSource("userId", userId).addValue("appid", appid),
|
||||
(rs, i) -> new GameRewardFacts(rs.getBoolean("manual"), rs.getBoolean("finished"),
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 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
|
||||
*/
|
||||
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.
|
||||
@Transactional
|
||||
@@ -84,7 +85,7 @@ public class RewardService {
|
||||
Map<Track, Integer> progress = settleLocked(userId);
|
||||
Set<String> owned = Looks.owned(ledger.itemCodes(userId), role);
|
||||
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 }
|
||||
@@ -249,7 +250,8 @@ public class RewardService {
|
||||
* @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
|
||||
*/
|
||||
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
|
||||
@@ -265,7 +267,8 @@ public class RewardService {
|
||||
}
|
||||
settleLocked(userId);
|
||||
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) {
|
||||
@@ -301,9 +304,12 @@ public class RewardService {
|
||||
putReason(missing, RewardKind.FINISHED_TOPUP, RewardRules.noTopUpReason(game, finish));
|
||||
}
|
||||
if (noRow(paid, List.of(RewardKind.COMPLETED))) {
|
||||
FinishFacts completed = facts.completedCandidates(userId, appid).stream().findFirst().orElse(null);
|
||||
putReason(missing, RewardKind.COMPLETED, RewardRules.noCompletedReason(game, completed,
|
||||
!noRow(paid, List.of(RewardKind.FINISHED, RewardKind.FINISHED_TOPUP))));
|
||||
FinishFacts completed = facts.completedCandidates(userId, appid, earlySince()).stream().findFirst().orElse(null);
|
||||
// A candidate means an opener passed; a FINISHED_MANUAL row with an old last unlock is none.
|
||||
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;
|
||||
}
|
||||
@@ -363,7 +369,7 @@ public class RewardService {
|
||||
private int bookOnePerSet(long userId, RewardEntry entry) {
|
||||
List<RewardKind> kinds = switch (entry.kind()) {
|
||||
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());
|
||||
};
|
||||
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) {
|
||||
return facts.completedCandidates(userId).stream()
|
||||
return facts.completedCandidates(userId, earlySince()).stream()
|
||||
.flatMap(f -> RewardRules.completed(f).stream()).mapToInt(e -> bookOnePerSet(userId, e)).sum();
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
jdbc.update("delete from game_link where set_id = 980002");
|
||||
jdbc.update("delete from user_psn_title where user_id = ?", USER_ID);
|
||||
gameReleaseRepository.deleteAll();
|
||||
userGameRepository.deleteAll();
|
||||
schemaMetaRepository.deleteAll();
|
||||
@@ -237,6 +238,21 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
|
||||
.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
|
||||
void publicLibrary_OnlyTheSharedStatGroups() throws Exception {
|
||||
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_LOWER = 5_000_011L;
|
||||
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 JsonMapper JSON = JsonMapper.builder().build();
|
||||
|
||||
@@ -123,6 +124,7 @@ class StatGamesFlowTest extends AbstractMockedLibraryServicesTest {
|
||||
|
||||
@BeforeEach
|
||||
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_playtime_day where user_id in (?, ?)", USER, OTHER);
|
||||
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, BACK_TO_PILE, 10, "PLAYING", "STEAM", null);
|
||||
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);
|
||||
|
||||
jdbc.update("""
|
||||
@@ -220,6 +225,18 @@ class StatGamesFlowTest extends AbstractMockedLibraryServicesTest {
|
||||
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
|
||||
void mostPlayed_FirstFiveAreTheStatsTopGames() throws Exception {
|
||||
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));
|
||||
}
|
||||
|
||||
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
|
||||
void writeAll_SecondRunOnTheSameDay_ReplacesTheRow() {
|
||||
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();
|
||||
}
|
||||
|
||||
@Test
|
||||
void find_AllUnlockedOfACompletedGame_IsLeftOut() {
|
||||
claim(USER_ID, FIRST_RUN);
|
||||
repository.replace(USER_ID, FIRST_RUN, SyncTrigger.AUTO, 0,
|
||||
List.of(SyncChange.allUnlocked(HALF_LIFE), SyncChange.allUnlocked(PORTAL)));
|
||||
jdbc.update("update user_game set status = 'COMPLETED' where user_id = ? and appid = ?", USER_ID, HALF_LIFE);
|
||||
|
||||
assertThat(stored().changes()).extracting(e -> e.change()).containsExactly(SyncChange.allUnlocked(PORTAL));
|
||||
}
|
||||
|
||||
@Test
|
||||
void find_AllUnlockedOfAHiddenGame_IsLeftOut() {
|
||||
claim(USER_ID, FIRST_RUN);
|
||||
repository.replace(USER_ID, FIRST_RUN, SyncTrigger.AUTO, 0, List.of(SyncChange.allUnlocked(HIDDEN)));
|
||||
|
||||
assertThat(stored().changes()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void find_LeavesWishlistRowsOut() {
|
||||
claim(USER_ID, FIRST_RUN);
|
||||
|
||||
@@ -631,6 +631,14 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
||||
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
|
||||
void findRandomBacklogGame_PicksOnlyOwnActiveBacklogGames() {
|
||||
UserGame removed = userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow();
|
||||
|
||||
@@ -179,12 +179,16 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
|
||||
.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() {
|
||||
userGameRepository.saveAllAndFlush(List.of(
|
||||
game(220L, 100, 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) "
|
||||
+ "values (?, 'c1', 'STEAM_OWNED', 220, 'PS5', 500)", USER_ID);
|
||||
psnPlay(220L, 500);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -282,6 +286,79 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
|
||||
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
|
||||
void finishedPerYear_CountsAGameOnceInTheYearItFirstGotFinished() {
|
||||
userGameRepository.saveAllAndFlush(List.of(
|
||||
|
||||
@@ -469,6 +469,58 @@ class AchievementSyncServiceTest extends AbstractUnitTest {
|
||||
verify(syncChangeRepository).add(USER_ID, SyncChange.achievements(620L, 2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncLibrary_LastAchievementUnlocked_AddsAllUnlocked() {
|
||||
library(List.of(game(620L, 150)), List.of(schema(620L, 5)), List.of(unlocks(620L, 3, 120)));
|
||||
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(visible("A", "B", "C", "D", "E"));
|
||||
|
||||
service.syncLibrary(USER_ID, STEAM_ID);
|
||||
|
||||
verify(syncChangeRepository).add(USER_ID, SyncChange.achievements(620L, 2));
|
||||
verify(syncChangeRepository).add(USER_ID, SyncChange.allUnlocked(620L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncLibrary_LastAchievementOfACompletedGame_AddsNoAllUnlocked() {
|
||||
UserGame completed = game(620L, 150);
|
||||
completed.setStatus(GameStatus.COMPLETED);
|
||||
library(List.of(completed), List.of(schema(620L, 5)), List.of(unlocks(620L, 3, 120)));
|
||||
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(visible("A", "B", "C", "D", "E"));
|
||||
|
||||
service.syncLibrary(USER_ID, STEAM_ID);
|
||||
|
||||
verify(syncChangeRepository).add(USER_ID, SyncChange.achievements(620L, 2));
|
||||
verify(syncChangeRepository, never()).add(USER_ID, SyncChange.allUnlocked(620L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncLibrary_NewUnlocksWithAchievementsLeft_AddsNoAllUnlocked() {
|
||||
library(List.of(game(620L, 150)), List.of(schema(620L, 5)), List.of(unlocks(620L, 3, 120)));
|
||||
List<SteamPlayerAchievementsResponse.Achievement> rows = List.of(
|
||||
new SteamPlayerAchievementsResponse.Achievement("A", 1, 1_700_000_000L),
|
||||
new SteamPlayerAchievementsResponse.Achievement("B", 1, 1_700_000_000L),
|
||||
new SteamPlayerAchievementsResponse.Achievement("C", 1, 1_700_000_000L),
|
||||
new SteamPlayerAchievementsResponse.Achievement("D", 1, 1_700_000_000L),
|
||||
new SteamPlayerAchievementsResponse.Achievement("E", 0, 0L));
|
||||
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(
|
||||
Optional.of(new SteamPlayerAchievementsResponse.PlayerStats(STEAM_ID, "Game", rows, true, null)));
|
||||
|
||||
service.syncLibrary(USER_ID, STEAM_ID);
|
||||
|
||||
verify(syncChangeRepository).add(USER_ID, SyncChange.achievements(620L, 1));
|
||||
verify(syncChangeRepository, never()).add(USER_ID, SyncChange.allUnlocked(620L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncLibrary_AllUnlockedBeforeTheSync_AddsNoAllUnlocked() {
|
||||
library(List.of(game(620L, 150)), List.of(schema(620L, 5)), List.of(unlocks(620L, 5, 120)));
|
||||
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(visible("A", "B", "C", "D", "E"));
|
||||
|
||||
service.syncLibrary(USER_ID, STEAM_ID);
|
||||
|
||||
verify(syncChangeRepository, never()).add(anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncLibrary_FirstRefreshOfAGame_AddsNoUnlockRow() {
|
||||
library(List.of(game(620L, 150)), List.of(schema(620L, 10)), List.of());
|
||||
|
||||
@@ -20,6 +20,7 @@ import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
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(
|
||||
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(700L), isNull(), isNull(), any(Instant.class));
|
||||
verify(gameEditionRepository, never()).upsert(eq(220L), any(), any(), any());
|
||||
@@ -97,8 +99,9 @@ class GameEditionServiceTest extends AbstractUnitTest {
|
||||
void refresh_IgdbOff_DoesNothing() {
|
||||
when(igdbApiService.isEnabled()).thenReturn(false);
|
||||
|
||||
service.refresh(List.of(620L));
|
||||
Set<Long> stored = service.refresh(List.of(620L));
|
||||
|
||||
assertThat(stored).isEmpty();
|
||||
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.WishlistGame;
|
||||
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.SteamGameRepository;
|
||||
import core_service.modules.library.repo.SyncChangeRepository;
|
||||
@@ -107,6 +108,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
@Mock
|
||||
private GameCorrectionRepository gameCorrectionRepository;
|
||||
|
||||
@Mock
|
||||
private GameMergeRepository gameMergeRepository;
|
||||
|
||||
@Mock
|
||||
private UserGameRepository userGameRepository;
|
||||
|
||||
@@ -297,6 +301,45 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
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) {
|
||||
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.request.MockHttpServletRequestBuilder;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
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 game_link where appid in (?, ?)", GAME, EDITION);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -74,6 +76,11 @@ class RewardControllerTest extends AbstractIntegrationTest {
|
||||
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 {
|
||||
mockMvc.perform(get("/api/rewards/wallet")).andExpect(status().isOk());
|
||||
}
|
||||
@@ -481,6 +488,124 @@ class RewardControllerTest extends AbstractIntegrationTest {
|
||||
.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
|
||||
void gamePoints_FinishedPlayingFinishedAfterEarly_PaysNoSecondEarly() throws Exception {
|
||||
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) {
|
||||
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);
|
||||
|
||||
@Test
|
||||
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(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);
|
||||
assertThat(RewardRules.blocked(STEAM_FINISHED)).isNull();
|
||||
}
|
||||
@@ -65,7 +65,7 @@ class RewardRulesTest extends AbstractUnitTest {
|
||||
|
||||
@Test
|
||||
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)))
|
||||
.isEqualTo(NoPointsReason.PLAY_NOT_COUNTED);
|
||||
@@ -117,7 +117,7 @@ class RewardRulesTest extends AbstractUnitTest {
|
||||
|
||||
assertThat(RewardRules.noStartReason(game(false, false, false, false), idle))
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -125,9 +125,12 @@ class RewardRulesTest extends AbstractUnitTest {
|
||||
void noCompletedReason_WithoutProvenFinish_IsNeedsProvenFinish() {
|
||||
GameRewardFacts g = game(false, true, true, true);
|
||||
|
||||
assertThat(RewardRules.noCompletedReason(g, null, false)).isEqualTo(NoPointsReason.NEEDS_PROVEN_FINISH);
|
||||
assertThat(RewardRules.noCompletedReason(STEAM_FINISHED, null, true)).isEqualTo(NoPointsReason.NOT_COMPLETED);
|
||||
assertThat(RewardRules.noCompletedReason(game(true, true, true, true), null, true))
|
||||
assertThat(RewardRules.noCompletedReason(g, null, false, false)).isEqualTo(NoPointsReason.NEEDS_PROVEN_FINISH);
|
||||
assertThat(RewardRules.noCompletedReason(g, null, false, true)).isEqualTo(NoPointsReason.COMPLETION_TOO_OLD);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -135,7 +138,7 @@ class RewardRulesTest extends AbstractUnitTest {
|
||||
void noCompletedReason_MissingAchievements() {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
|
||||
private static final long USER = 7L;
|
||||
private static final long GAME = 220L;
|
||||
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");
|
||||
|
||||
@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);
|
||||
|
||||
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
|
||||
@@ -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(EDITION), 30, Instant.now()));
|
||||
|
||||
assertThat(repository.completedCandidates(USER)).hasSize(2);
|
||||
assertThat(repository.completedCandidates(USER, GAME)).extracting(FinishFacts::appid).containsExactly(GAME);
|
||||
assertThat(repository.completedCandidates(USER, EARLY)).hasSize(2);
|
||||
assertThat(repository.completedCandidates(USER, GAME, EARLY)).extracting(FinishFacts::appid).containsExactly(GAME);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -217,12 +218,12 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
|
||||
assertThat(repository.startCandidates(USER, BERLIN)).hasSize(1);
|
||||
setStatus("COMPLETED");
|
||||
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);
|
||||
|
||||
assertThat(repository.startCandidates(USER, BERLIN)).isEmpty();
|
||||
assertThat(repository.completedCandidates(USER)).isEmpty();
|
||||
assertThat(repository.completedCandidates(USER, EARLY)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -275,15 +276,15 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
|
||||
void completedCandidates_NeedACompletedGameWithAPaidFinish() {
|
||||
game("BACKLOG", 120);
|
||||
setStatus("COMPLETED");
|
||||
assertThat(repository.completedCandidates(USER)).isEmpty();
|
||||
assertThat(repository.completedCandidates(USER, EARLY)).isEmpty();
|
||||
|
||||
Instant finishedAt = Instant.parse("2026-09-15T10:00:00Z");
|
||||
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);
|
||||
|
||||
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.
|
||||
@@ -329,11 +330,11 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
|
||||
setOfTwo("COMPLETED");
|
||||
paid(RewardKind.FINISHED, GAME);
|
||||
paid(RewardKind.FINISHED, EDITION);
|
||||
assertThat(repository.completedCandidates(USER)).hasSize(2);
|
||||
assertThat(repository.completedCandidates(USER, EARLY)).hasSize(2);
|
||||
|
||||
paid(RewardKind.COMPLETED, EDITION);
|
||||
|
||||
assertThat(repository.completedCandidates(USER)).isEmpty();
|
||||
assertThat(repository.completedCandidates(USER, EARLY)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -411,7 +412,7 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
|
||||
|
||||
assertThat(repository.startCandidates(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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
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() {
|
||||
@@ -554,11 +664,11 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
|
||||
game("BACKLOG", 120);
|
||||
setStatus("COMPLETED");
|
||||
paid(RewardKind.FINISHED_EARLY, GAME);
|
||||
assertThat(repository.completedCandidates(USER)).isEmpty();
|
||||
assertThat(repository.completedCandidates(USER, EARLY)).isEmpty();
|
||||
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user