From 8eab98df74ed493a41bbce42ac56416f79470b24 Mon Sep 17 00:00:00 2001 From: lobo Date: Fri, 2 Oct 2026 15:40:46 +0200 Subject: [PATCH] feat(rewards): unlock items by progress and skins Five progress tracks: games finished, completed, weekly finish streaks, pile-down months and play-more months. Settle books UNLOCK rows once, also retroactively, when a track reaches an item's target. 12 unlock-only items (incl. the Steam skin) and 3 skins to buy. The wallet returns progress; buying an unlock item answers NOT_FOR_SALE. --- readme.md | 13 +- .../modules/rewards/api/RewardController.java | 2 + .../modules/rewards/api/dto/WalletDto.java | 16 +- .../modules/rewards/domain/LookSlot.java | 2 +- .../modules/rewards/domain/RewardKind.java | 7 +- .../modules/rewards/domain/RewardRules.java | 4 + .../modules/rewards/domain/ShopItem.java | 46 +++++- .../modules/rewards/domain/Track.java | 8 + .../modules/rewards/domain/Unlocks.java | 68 +++++++++ .../rewards/repo/RewardFactsRepository.java | 19 +++ .../rewards/repo/RewardLedgerRepository.java | 27 +++- .../rewards/service/RewardService.java | 60 ++++++-- .../library/api/GameMergeUndoFlowTest.java | 22 +++ .../rewards/api/RewardControllerTest.java | 106 +++++++++++++ .../modules/rewards/domain/LooksTest.java | 6 + .../modules/rewards/domain/UnlocksTest.java | 69 +++++++++ .../repo/RewardFactsRepositoryTest.java | 14 ++ .../repo/RewardLedgerRepositoryTest.java | 32 +++- .../service/RewardServiceUnlockTest.java | 143 ++++++++++++++++++ 19 files changed, 633 insertions(+), 31 deletions(-) create mode 100644 src/main/java/core_service/modules/rewards/domain/Track.java create mode 100644 src/main/java/core_service/modules/rewards/domain/Unlocks.java create mode 100644 src/test/java/core_service/modules/rewards/domain/UnlocksTest.java create mode 100644 src/test/java/core_service/modules/rewards/service/RewardServiceUnlockTest.java diff --git a/readme.md b/readme.md index 20af94a..a964a2e 100644 --- a/readme.md +++ b/readme.md @@ -65,8 +65,13 @@ To Be Played is split into three repositories: snapshots, once per user, kind and game (`ref`). Rules and numbers: `RewardRules`; a Steam finish needs playtime and one proof of fresh play (ending achievement or playtime around the finish, or half of the IGDB time played since the finish; old history pays nothing). The month bonus needs a finished game in that month. Games added by hand pay 15 for a finish, at most 3 per ISO week. 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 28 looks (`ShopItem`) in seven slots, including animated completed effects, page backgrounds and banner - backgrounds (V27 widens `reward_equipped.slot`); a `BUY` row proves ownership, + for finishes from before the status log. Shop: a whitelist of 43 looks (`ShopItem`; 31 buyable and 12 unlock-only) in eight slots, including animated completed effects, page backgrounds, + 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 + track reaches the item's target, retroactively and never lost. The five tracks (`Unlocks`): games finished + (`FINISHED` + `FINISHED_MANUAL` rows + starter grant / 20), games completed, weeks in a row with a finish, months in + a row with a `MONTH` bonus, months in a row with more Steam playtime than the month before (hidden games count too). + A `BUY` or `UNLOCK` row proves ownership, `reward_equipped` holds what is worn. A platform admin owns every item (the role of the login token, or the `role` claim of the profile ticket for visitors). - **Editions and links**: editions, remasters and ports of one game stay separate library rows, but an AUDITOR can @@ -131,8 +136,8 @@ 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) | JWT | -| `POST` | `/api/rewards/purchases` (`{item}`; `200` wallet, `409 ALREADY_OWNED` / `NOT_ENOUGH_POINTS`) | JWT | +| `GET` | `/api/rewards/wallet` (balance, catalog, owned, equipped, last 50 ledger rows with the game name, unlock `progress` per track) | JWT | +| `POST` | `/api/rewards/purchases` (`{item}`; `200` wallet, `409 ALREADY_OWNED` / `NOT_ENOUGH_POINTS` / `NOT_FOR_SALE`) | JWT | | `PUT` | `/api/rewards/equipped/{slot}` (`{item}`, null = take off; `200` wallet, `409 NOT_OWNED`, `400 WRONG_SLOT`) | JWT | | `GET` | `/api/library/games` (`hideDuplicates=true` leaves out the lesser editions of a linked set), `/api/library/games/{appid}` | JWT | | `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT | diff --git a/src/main/java/core_service/modules/rewards/api/RewardController.java b/src/main/java/core_service/modules/rewards/api/RewardController.java index 7371beb..4b29ba6 100644 --- a/src/main/java/core_service/modules/rewards/api/RewardController.java +++ b/src/main/java/core_service/modules/rewards/api/RewardController.java @@ -44,6 +44,8 @@ public class RewardController { .body(new ErrorResponse("ALREADY_OWNED", "The item is already owned")); case NOT_ENOUGH_POINTS -> ResponseEntity.status(HttpStatus.CONFLICT) .body(new ErrorResponse("NOT_ENOUGH_POINTS", "Not enough Pile Points")); + case NOT_FOR_SALE -> ResponseEntity.status(HttpStatus.CONFLICT) + .body(new ErrorResponse("NOT_FOR_SALE", "The item can only be unlocked")); }; } diff --git a/src/main/java/core_service/modules/rewards/api/dto/WalletDto.java b/src/main/java/core_service/modules/rewards/api/dto/WalletDto.java index 5b4c712..7587bd8 100644 --- a/src/main/java/core_service/modules/rewards/api/dto/WalletDto.java +++ b/src/main/java/core_service/modules/rewards/api/dto/WalletDto.java @@ -3,6 +3,7 @@ package core_service.modules.rewards.api.dto; import core_service.modules.rewards.domain.LookSlot; import core_service.modules.rewards.domain.RewardKind; import core_service.modules.rewards.domain.ShopItem; +import core_service.modules.rewards.domain.Track; import core_service.modules.rewards.service.RewardService; import java.time.Instant; @@ -14,11 +15,16 @@ 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 */ public record WalletDto(long balance, List catalog, Set owned, Map equipped, - List history) { + List history, Map progress) { - public record ItemDto(String code, LookSlot slot, int price) {} + /** + * @param track null when the item is buyable + * @param target null when the item is buyable + */ + public record ItemDto(String code, LookSlot slot, int price, Track track, Integer target) {} /** * @param gameName null when the ref is no game @@ -27,9 +33,11 @@ public record WalletDto(long balance, List catalog, Set owned, public static WalletDto of(RewardService.Wallet wallet) { return new WalletDto(wallet.balance(), - Arrays.stream(ShopItem.values()).map(item -> new ItemDto(item.name(), item.slot(), item.price())).toList(), + Arrays.stream(ShopItem.values()).map(item -> new ItemDto(item.name(), item.slot(), item.price(), item.track(), + item.unlockOnly() ? item.target() : null)).toList(), wallet.owned(), wallet.equipped(), wallet.history().stream() - .map(h -> new EntryDto(h.kind(), h.ref(), h.gameName(), h.points(), h.eventAt())).toList()); + .map(h -> new EntryDto(h.kind(), h.ref(), h.gameName(), h.points(), h.eventAt())).toList(), + wallet.progress()); } } diff --git a/src/main/java/core_service/modules/rewards/domain/LookSlot.java b/src/main/java/core_service/modules/rewards/domain/LookSlot.java index 5e8e71d..250b973 100644 --- a/src/main/java/core_service/modules/rewards/domain/LookSlot.java +++ b/src/main/java/core_service/modules/rewards/domain/LookSlot.java @@ -1,5 +1,5 @@ package core_service.modules.rewards.domain; public enum LookSlot { - AVATAR_BORDER, BADGE, ACCENT, BANNER_FRAME, COMPLETED_EFFECT, PAGE_BACKGROUND, BANNER_BACKGROUND + AVATAR_BORDER, BADGE, ACCENT, BANNER_FRAME, COMPLETED_EFFECT, PAGE_BACKGROUND, BANNER_BACKGROUND, SKIN } diff --git a/src/main/java/core_service/modules/rewards/domain/RewardKind.java b/src/main/java/core_service/modules/rewards/domain/RewardKind.java index 57766d4..a937450 100644 --- a/src/main/java/core_service/modules/rewards/domain/RewardKind.java +++ b/src/main/java/core_service/modules/rewards/domain/RewardKind.java @@ -3,7 +3,12 @@ package core_service.modules.rewards.domain; import java.util.List; public enum RewardKind { - STARTED, FINISHED, FINISHED_MANUAL, COMPLETED, MONTH, STREAK, STARTER, BUY; + STARTED, FINISHED, FINISHED_MANUAL, COMPLETED, MONTH, STREAK, STARTER, BUY, + /** + * An unlock-only item reached by progress: ref = item code, points 0. Not in {@link #GAME_KINDS}, so a merge never + * moves it. + */ + UNLOCK; /** * The kinds whose ref is a game (an appid). diff --git a/src/main/java/core_service/modules/rewards/domain/RewardRules.java b/src/main/java/core_service/modules/rewards/domain/RewardRules.java index eb85ecd..be3fc5d 100644 --- a/src/main/java/core_service/modules/rewards/domain/RewardRules.java +++ b/src/main/java/core_service/modules/rewards/domain/RewardRules.java @@ -26,6 +26,10 @@ public final class RewardRules { public static final int PLAYED_AROUND_DAYS = 30; public static final int MONTHS_BACK = 2; public static final int STREAK_WEEKS = 12; + /** + * The starter grant per pre-ledger finished game; same number as V26, which stays the source. + */ + public static final int STARTER_PER_GAME = 20; private RewardRules() { } diff --git a/src/main/java/core_service/modules/rewards/domain/ShopItem.java b/src/main/java/core_service/modules/rewards/domain/ShopItem.java index 74cfac0..4f5c82d 100644 --- a/src/main/java/core_service/modules/rewards/domain/ShopItem.java +++ b/src/main/java/core_service/modules/rewards/domain/ShopItem.java @@ -7,7 +7,8 @@ import java.util.stream.Collectors; /** * The whitelist of looks. The frontend maps a code to its visuals; nothing here is ever uploaded CSS. * {@code FX_} effects show on the cover of a COMPLETED game and on its details page, {@code BG_} on the page - * background, {@code BN_} on the dashboard and profile banner. + * background, {@code BN_} on the dashboard and profile banner, {@code SKIN_} is a full theme. An item with a track is + * unlock-only: never sold, owned once its target is reached. */ public enum ShopItem { BORDER_GOLD(LookSlot.AVATAR_BORDER, 150), @@ -37,14 +38,55 @@ public enum ShopItem { BN_MESH(LookSlot.BANNER_BACKGROUND, 300), BN_GRID(LookSlot.BANNER_BACKGROUND, 300), BN_WAVES(LookSlot.BANNER_BACKGROUND, 300), - BN_BOKEH(LookSlot.BANNER_BACKGROUND, 300); + BN_BOKEH(LookSlot.BANNER_BACKGROUND, 300), + SKIN_PASTEL(LookSlot.SKIN, 600), + SKIN_RETRO_CRT(LookSlot.SKIN, 700), + SKIN_CYBERPUNK(LookSlot.SKIN, 800), + BADGE_FINISH_10(LookSlot.BADGE, Track.GAMES_FINISHED, 10), + BORDER_FINISH_25(LookSlot.AVATAR_BORDER, Track.GAMES_FINISHED, 25), + FRAME_FINISH_50(LookSlot.BANNER_FRAME, Track.GAMES_FINISHED, 50), + SKIN_STEAM(LookSlot.SKIN, Track.GAMES_FINISHED, 100), + BADGE_PERFECT_5(LookSlot.BADGE, Track.GAMES_COMPLETED, 5), + FX_PLATINUM(LookSlot.COMPLETED_EFFECT, Track.GAMES_COMPLETED, 20), + BADGE_STREAK_4(LookSlot.BADGE, Track.FINISH_WEEKS, 4), + BORDER_STREAK_12(LookSlot.AVATAR_BORDER, Track.FINISH_WEEKS, 12), + FRAME_PILE_DOWN_3(LookSlot.BANNER_FRAME, Track.PILE_DOWN_MONTHS, 3), + BADGE_PLAY_MORE_1(LookSlot.BADGE, Track.PLAY_MORE_MONTHS, 1), + BORDER_PLAY_MORE_3(LookSlot.AVATAR_BORDER, Track.PLAY_MORE_MONTHS, 3), + BN_EQUALIZER(LookSlot.BANNER_BACKGROUND, Track.PLAY_MORE_MONTHS, 6); private final LookSlot slot; private final int price; + private final Track track; + private final int target; ShopItem(LookSlot slot, int price) { this.slot = slot; this.price = price; + this.track = null; + this.target = 0; + } + + ShopItem(LookSlot slot, Track track, int target) { + this.slot = slot; + this.price = 0; + this.track = track; + this.target = target; + } + + /** + * @return null when the item is buyable + */ + public Track track() { + return track; + } + + public int target() { + return target; + } + + public boolean unlockOnly() { + return track != null; } public LookSlot slot() { diff --git a/src/main/java/core_service/modules/rewards/domain/Track.java b/src/main/java/core_service/modules/rewards/domain/Track.java new file mode 100644 index 0000000..8a4960a --- /dev/null +++ b/src/main/java/core_service/modules/rewards/domain/Track.java @@ -0,0 +1,8 @@ +package core_service.modules.rewards.domain; + +/** + * What an unlock-only item measures, see {@link Unlocks}. + */ +public enum Track { + GAMES_FINISHED, GAMES_COMPLETED, FINISH_WEEKS, PILE_DOWN_MONTHS, PLAY_MORE_MONTHS +} diff --git a/src/main/java/core_service/modules/rewards/domain/Unlocks.java b/src/main/java/core_service/modules/rewards/domain/Unlocks.java new file mode 100644 index 0000000..f064bda --- /dev/null +++ b/src/main/java/core_service/modules/rewards/domain/Unlocks.java @@ -0,0 +1,68 @@ +package core_service.modules.rewards.domain; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.UnaryOperator; +import java.util.stream.Collectors; + +/** + * The progress rules of the unlock-only items. Pure, no database. + */ +public final class Unlocks { + + private Unlocks() { + } + + /** + * @param mondays the Mondays of the weeks that count + * @return the longest run of consecutive weeks + */ + public static int longestWeekRun(Collection mondays) { + return longestRun(mondays, day -> day.plusWeeks(1)); + } + + public static int longestMonthRun(Collection months) { + return longestRun(months, month -> month.plusMonths(1)); + } + + private static > int longestRun(Collection values, UnaryOperator next) { + int best = 0; + int run = 0; + T previous = null; + for (T value : new TreeSet<>(values)) { + run = previous != null && next.apply(previous).equals(value) ? run + 1 : 1; + best = Math.max(best, run); + previous = value; + } + return best; + } + + /** + * The months M up to lastFull with {@code minutes(M) > minutes(M-1) > 0}; a missing month is 0. + */ + public static Set playMoreMonths(Map minutes, YearMonth lastFull) { + return minutes.keySet().stream() + .filter(month -> !month.isAfter(lastFull)) + .filter(month -> { + long before = minutes.getOrDefault(month.minusMonths(1), 0L); + return before > 0 && minutes.get(month) > before; + }) + .collect(Collectors.toSet()); + } + + /** + * The unlock-only items whose target the progress reaches. + */ + public static List reached(Map progress) { + return Arrays.stream(ShopItem.values()) + .filter(ShopItem::unlockOnly) + .filter(item -> progress.getOrDefault(item.track(), 0) >= item.target()) + .toList(); + } +} diff --git a/src/main/java/core_service/modules/rewards/repo/RewardFactsRepository.java b/src/main/java/core_service/modules/rewards/repo/RewardFactsRepository.java index c418ec7..59ab75c 100644 --- a/src/main/java/core_service/modules/rewards/repo/RewardFactsRepository.java +++ b/src/main/java/core_service/modules/rewards/repo/RewardFactsRepository.java @@ -4,6 +4,7 @@ import core_service.modules.rewards.domain.FinishFacts; import core_service.modules.rewards.domain.RewardRules; import core_service.modules.rewards.domain.StartFacts; import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.RowCallbackHandler; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Repository; @@ -11,7 +12,9 @@ import org.springframework.stereotype.Repository; import java.sql.Date; import java.time.YearMonth; import java.time.ZoneId; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -121,6 +124,22 @@ public class RewardFactsRepository { false, false, rs.getBoolean("all_achievements"), rs.getTimestamp("event_at").toInstant())); } + /** + * Playtime minutes per month of the Steam playtime days (day is a Berlin date). Hidden games count too: hiding + * is free and reversible, so excluding them would let a user shape the curve for one settle. + */ + public Map minutesPerMonth(long userId) { + Map result = new HashMap<>(); + jdbc.query(""" + select to_char(p.day, 'YYYY-MM') as month, sum(p.minutes) as minutes + from user_game_playtime_day p + where p.user_id = :userId + group by 1 + """, new MapSqlParameterSource("userId", userId), + (RowCallbackHandler) rs -> result.put(YearMonth.parse(rs.getString("month")), rs.getLong("minutes"))); + return result; + } + /** * The pile (backlog_games) on the first and on the last day of the month, {first, last}. Empty unless both days * have a row. diff --git a/src/main/java/core_service/modules/rewards/repo/RewardLedgerRepository.java b/src/main/java/core_service/modules/rewards/repo/RewardLedgerRepository.java index bce6ce4..6e586d6 100644 --- a/src/main/java/core_service/modules/rewards/repo/RewardLedgerRepository.java +++ b/src/main/java/core_service/modules/rewards/repo/RewardLedgerRepository.java @@ -78,11 +78,30 @@ public class RewardLedgerRepository { } /** - * The codes of the items the user bought. + * The codes of the items the user bought or unlocked. */ - public Set bought(long userId) { - return new HashSet<>(jdbc.queryForList("select ref from reward_ledger where user_id = :userId and kind = 'BUY'", - new MapSqlParameterSource("userId", userId), String.class)); + public Set itemCodes(long userId) { + return new HashSet<>(jdbc.queryForList(""" + select ref from reward_ledger where user_id = :userId and kind in ('BUY', 'UNLOCK') + """, new MapSqlParameterSource("userId", userId), String.class)); + } + + public int countKinds(long userId, Collection kinds) { + Integer count = jdbc.queryForObject("select count(*) from reward_ledger where user_id = :userId and kind in (:kinds)", + new MapSqlParameterSource("userId", userId).addValue("kinds", names(kinds)), Integer.class); + return count != null ? count : 0; + } + + public long sumPoints(long userId, RewardKind kind) { + Long sum = jdbc.queryForObject(""" + select coalesce(sum(points), 0) from reward_ledger where user_id = :userId and kind = :kind + """, new MapSqlParameterSource("userId", userId).addValue("kind", kind.name()), Long.class); + return sum != null ? sum : 0; + } + + public List refs(long userId, RewardKind kind) { + return jdbc.queryForList("select ref from reward_ledger where user_id = :userId and kind = :kind", + new MapSqlParameterSource("userId", userId).addValue("kind", kind.name()), String.class); } /** diff --git a/src/main/java/core_service/modules/rewards/service/RewardService.java b/src/main/java/core_service/modules/rewards/service/RewardService.java index f7f86c5..4a43710 100644 --- a/src/main/java/core_service/modules/rewards/service/RewardService.java +++ b/src/main/java/core_service/modules/rewards/service/RewardService.java @@ -8,6 +8,8 @@ import core_service.modules.rewards.domain.RewardEntry; import core_service.modules.rewards.domain.RewardKind; import core_service.modules.rewards.domain.RewardRules; import core_service.modules.rewards.domain.ShopItem; +import core_service.modules.rewards.domain.Track; +import core_service.modules.rewards.domain.Unlocks; import core_service.modules.rewards.repo.RewardFactsRepository; import core_service.modules.rewards.repo.RewardLedgerRepository; import core_service.shared.security.Public; @@ -27,10 +29,11 @@ import java.time.LocalDate; import java.time.YearMonth; import java.time.ZoneId; import java.time.temporal.TemporalAdjusters; -import java.util.HashSet; +import java.util.EnumMap; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; /** * Pile Points. No code path pays points: a settle derives the rows from the data the library already keeps @@ -52,21 +55,23 @@ public class RewardService { private ZoneId zone; /** - * @param owned the codes the user may wear: bought, or all for a platform admin + * @param owned the codes the user may wear: bought or unlocked, or all for a platform admin + * @param progress the value of each track, see {@link Unlocks} * @param equipped the worn looks, only those the user still owns */ - public record Wallet(long balance, Set owned, Map equipped, List history) {} + public record Wallet(long balance, Set owned, Map equipped, List history, + Map progress) {} // ponytail: settles on every wallet read (the app reads it once per start); a settled_at column if that hurts. @Transactional public Wallet wallet(long userId, Role role) { - settleLocked(userId); - Set owned = Looks.owned(ledger.bought(userId), role); + Map progress = settleLocked(userId); + Set 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)); + ledger.history(userId, HISTORY_SIZE), progress); } - public enum BuyResult { BOUGHT, ALREADY_OWNED, NOT_ENOUGH_POINTS } + public enum BuyResult { BOUGHT, ALREADY_OWNED, NOT_ENOUGH_POINTS, NOT_FOR_SALE } public enum EquipResult { DONE, NOT_OWNED, WRONG_SLOT } @@ -76,9 +81,12 @@ public class RewardService { @Transactional public BuyResult buy(long userId, Role role, ShopItem item) { ledger.lockUser(userId); - if (Looks.owned(ledger.bought(userId), role).contains(item.name())) { + if (Looks.owned(ledger.itemCodes(userId), role).contains(item.name())) { return BuyResult.ALREADY_OWNED; } + if (item.unlockOnly()) { + return BuyResult.NOT_FOR_SALE; + } if (ledger.balance(userId) < item.price()) { return BuyResult.NOT_ENOUGH_POINTS; } @@ -98,7 +106,7 @@ public class RewardService { if (item.slot() != slot) { return EquipResult.WRONG_SLOT; } - if (!Looks.owned(ledger.bought(userId), role).contains(item.name())) { + if (!Looks.owned(ledger.itemCodes(userId), role).contains(item.name())) { return EquipResult.NOT_OWNED; } ledger.equip(userId, slot, item.name()); @@ -110,7 +118,7 @@ public class RewardService { */ @Public public Map publicLooks(long userId, Role role) { - return Looks.wearable(ledger.equipped(userId), Looks.owned(ledger.bought(userId), role)); + return Looks.wearable(ledger.equipped(userId), Looks.owned(ledger.itemCodes(userId), role)); } /** @@ -130,16 +138,36 @@ public class RewardService { } } - private void settleLocked(long userId) { + private Map settleLocked(long userId) { if (!facts.isActive(userId)) { - return; + return Map.of(); } ledger.lockUser(userId); int paid = settleStarts(userId) + settleFinishes(userId) + settleCompleted(userId) + settleMonths(userId) + settleStreaks(userId); + Map progress = progress(userId); + paid += Unlocks.reached(progress).stream() + .mapToInt(item -> book(userId, new RewardEntry(RewardKind.UNLOCK, item.name(), 0, Instant.now()))).sum(); if (paid > 0) { log.info("Reward settle of user {} booked {} rows", userId, paid); } + return progress; + } + + // The value of each track. It sees the rows booked in this run. Manual games have no playtime days, so "play more" + // cannot be farmed by typing numbers. + private Map progress(long userId) { + Set pileMonths = ledger.refs(userId, RewardKind.MONTH).stream().map(YearMonth::parse) + .collect(Collectors.toSet()); + Set playMore = Unlocks.playMoreMonths(facts.minutesPerMonth(userId), YearMonth.now(zone).minusMonths(1)); + int starter = (int) (ledger.sumPoints(userId, RewardKind.STARTER) / RewardRules.STARTER_PER_GAME); + Map progress = new EnumMap<>(Track.class); + progress.put(Track.GAMES_FINISHED, ledger.countKinds(userId, RewardKind.FINISH_KINDS) + starter); + progress.put(Track.GAMES_COMPLETED, ledger.countKinds(userId, List.of(RewardKind.COMPLETED))); + progress.put(Track.FINISH_WEEKS, Unlocks.longestWeekRun(finishWeeks(userId, Instant.EPOCH))); + progress.put(Track.PILE_DOWN_MONTHS, Unlocks.longestMonthRun(pileMonths)); + progress.put(Track.PLAY_MORE_MONTHS, Unlocks.longestMonthRun(playMore)); + return progress; } private int book(long userId, RewardEntry entry) { @@ -197,8 +225,7 @@ public class RewardService { private int settleStreaks(long userId) { LocalDate since = LocalDate.now(zone).with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) .minusWeeks(RewardRules.STREAK_WEEKS); - Set weeks = new HashSet<>(); - ledger.eventsOfKinds(userId, RewardKind.FINISH_KINDS, startOf(since)).forEach(at -> weeks.add(weekStart(at))); + Set weeks = finishWeeks(userId, startOf(since)); int paid = 0; for (LocalDate monday : weeks) { if (weeks.contains(monday.minusWeeks(1))) { @@ -209,6 +236,11 @@ public class RewardService { return paid; } + private Set finishWeeks(long userId, Instant since) { + return ledger.eventsOfKinds(userId, RewardKind.FINISH_KINDS, since).stream().map(this::weekStart) + .collect(Collectors.toSet()); + } + private LocalDate weekStart(Instant at) { return at.atZone(zone).toLocalDate().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); } diff --git a/src/test/java/core_service/modules/library/api/GameMergeUndoFlowTest.java b/src/test/java/core_service/modules/library/api/GameMergeUndoFlowTest.java index d81fc6c..b4183a7 100644 --- a/src/test/java/core_service/modules/library/api/GameMergeUndoFlowTest.java +++ b/src/test/java/core_service/modules/library/api/GameMergeUndoFlowTest.java @@ -318,6 +318,28 @@ class GameMergeUndoFlowTest extends AbstractIntegrationTest { assertThat(count("select count(*) from reward_ledger where ref = ?", String.valueOf(STEAM))).isZero(); } + @Test + void unlockRowAndFinishCount_SurviveMergeAndUndo_AndSettleBooksNoSecondUnlock() throws Exception { + manualRow(AUDITOR_ID, MANUAL, GameStatus.PLAYING); + for (long ref = 7_000_000; ref < 7_000_008; ref++) { + jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'FINISHED', ?, 30, now())", + AUDITOR_ID, String.valueOf(ref)); + } + jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values " + + "(?, 'FINISHED_MANUAL', ?, 15, now()), (?, 'FINISHED', ?, 30, now()), " + + "(?, 'UNLOCK', 'BADGE_FINISH_10', 0, now())", + AUDITOR_ID, String.valueOf(MANUAL), AUDITOR_ID, String.valueOf(STEAM), AUDITOR_ID); + String finishes = "select count(*) from reward_ledger where user_id = ? and kind in ('FINISHED', 'FINISHED_MANUAL')"; + int before = count(finishes, AUDITOR_ID); + + merge(MANUAL, STEAM).andExpect(status().isOk()); + undo(lastMerge()).andExpect(status().isNoContent()); + rewardService.settleAll(); + + assertThat(count(finishes, AUDITOR_ID)).isEqualTo(before); + assertThat(count("select count(*) from reward_ledger where user_id = ? and kind = 'UNLOCK'", AUDITOR_ID)).isEqualTo(1); + } + @Test void movedRowTakenOverBySteam_StaysWhereItIs() throws Exception { manualRow(AUDITOR_ID, MANUAL, GameStatus.PLAYING); diff --git a/src/test/java/core_service/modules/rewards/api/RewardControllerTest.java b/src/test/java/core_service/modules/rewards/api/RewardControllerTest.java index ee7e9d6..c453215 100644 --- a/src/test/java/core_service/modules/rewards/api/RewardControllerTest.java +++ b/src/test/java/core_service/modules/rewards/api/RewardControllerTest.java @@ -16,6 +16,8 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -153,6 +155,18 @@ class RewardControllerTest extends AbstractIntegrationTest { userId, points); } + private void finishes(int count) { + for (int i = 0; i < count; i++) { + jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'FINISHED', ?, 30, now())", + USER, String.valueOf(5_000_000 + i)); + } + } + + private List unlocks(long userId) { + return jdbc.queryForList("select ref from reward_ledger where user_id = ? and kind = 'UNLOCK' order by ref", + String.class, userId); + } + private static MockHttpServletRequestBuilder asAdmin( MockHttpServletRequestBuilder request) { return request.header("Authorization", "Bearer " + ContractTokens.loginToken(ADMIN, Role.PLATFORM_ADMIN)); @@ -225,4 +239,96 @@ class RewardControllerTest extends AbstractIntegrationTest { .andExpect(jsonPath("$.owned").isEmpty()) .andExpect(jsonPath("$.equipped").isEmpty()); } + + @Test + void wallet_TenFinishes_UnlockTheBadgeOnceAndKeepTheBalance() throws Exception { + finishes(10); + + mockMvc.perform(get("/api/rewards/wallet")) + .andExpect(jsonPath("$.progress.GAMES_FINISHED").value(10)) + .andExpect(jsonPath("$.balance").value(300)) + .andExpect(jsonPath("$.owned[0]").value("BADGE_FINISH_10")); + wallet(); + rewardService.settleAll(); + + assertThat(unlocks(USER)).containsExactly("BADGE_FINISH_10"); + assertThat(ledgerSum()).isEqualTo(300); + } + + @Test + void wallet_ThirtyFinishes_UnlockEveryReachedLevelAtOnce() throws Exception { + finishes(30); + + wallet(); + + assertThat(unlocks(USER)).containsExactly("BADGE_FINISH_10", "BORDER_FINISH_25"); + } + + @Test + void wallet_NineFinishes_StayLockedWithTheirProgress_StarterGamesCountTwenty() throws Exception { + finishes(9); + + mockMvc.perform(get("/api/rewards/wallet")) + .andExpect(jsonPath("$.progress.GAMES_FINISHED").value(9)) + .andExpect(jsonPath("$.owned").isEmpty()); + points(USER, 40); + jdbc.update("delete from reward_ledger where user_id = ? and kind = 'FINISHED' and ref = '5000000'", USER); + finishes(0); + jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'FINISHED', '5000100', 30, now())", USER); + + mockMvc.perform(get("/api/rewards/wallet")).andExpect(jsonPath("$.progress.GAMES_FINISHED").value(11)); + assertThat(unlocks(USER)).containsExactly("BADGE_FINISH_10"); + } + + @Test + void buy_UnlockOnlyItem_Answers409AndWritesNoRow() throws Exception { + points(USER, 5000); + + buy("SKIN_STEAM").andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("NOT_FOR_SALE")); + + assertThat(rows("BUY")).isZero(); + } + + @Test + void equip_UnlockOnlySkin_NeedsTheUnlock() throws Exception { + equip("SKIN", "SKIN_STEAM").andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("NOT_OWNED")); + + finishes(100); + wallet(); + + equip("SKIN", "SKIN_STEAM").andExpect(status().isOk()).andExpect(jsonPath("$.equipped.SKIN").value("SKIN_STEAM")); + } + + @Test + void skin_CanBeBoughtEquippedAndTakenOff() throws Exception { + points(USER, 600); + + buy("SKIN_PASTEL").andExpect(status().isOk()).andExpect(jsonPath("$.balance").value(0)); + equip("SKIN", "SKIN_PASTEL").andExpect(jsonPath("$.equipped.SKIN").value("SKIN_PASTEL")); + equip("SKIN", null).andExpect(jsonPath("$.equipped.SKIN").doesNotExist()); + } + + @Test + void admin_OwnsEverythingWithoutAnUnlockRow_AndAUserWearsNothingUnearned() throws Exception { + mockMvc.perform(asAdmin(get("/api/rewards/wallet"))) + .andExpect(jsonPath("$.owned", org.hamcrest.Matchers.hasItem("SKIN_STEAM"))); + assertThat(unlocks(ADMIN)).isEmpty(); + mockMvc.perform(asAdmin(put("/api/rewards/equipped/SKIN")).header(ClientHeaderFilter.CLIENT_HEADER, "test") + .contentType(MediaType.APPLICATION_JSON).content("{\"item\":\"SKIN_STEAM\"}")).andExpect(status().isOk()); + + mockMvc.perform(get("/api/rewards/wallet").header("Authorization", "Bearer " + ContractTokens.loginToken(ADMIN))) + .andExpect(jsonPath("$.equipped").isEmpty()); + } + + @Test + void wallet_BlockedUser_GetsNoUnlock() throws Exception { + finishes(10); + jdbc.update("insert into blocked_user (user_id) values (?)", USER); + try { + wallet(); + assertThat(unlocks(USER)).isEmpty(); + } finally { + jdbc.update("delete from blocked_user where user_id = ?", USER); + } + } } diff --git a/src/test/java/core_service/modules/rewards/domain/LooksTest.java b/src/test/java/core_service/modules/rewards/domain/LooksTest.java index 6fd712b..58e748e 100644 --- a/src/test/java/core_service/modules/rewards/domain/LooksTest.java +++ b/src/test/java/core_service/modules/rewards/domain/LooksTest.java @@ -16,6 +16,12 @@ class LooksTest extends AbstractUnitTest { assertThat(Looks.owned(Set.of(), Role.PLATFORM_ADMIN)).isEqualTo(ShopItem.codes()); } + @Test + void owned_AdminOwnsSkinsAndEveryUnlockItem() { + assertThat(Looks.owned(Set.of(), Role.PLATFORM_ADMIN)).contains("SKIN_STEAM", "SKIN_PASTEL", "BN_EQUALIZER", + "BADGE_FINISH_10"); + } + @Test void owned_UserOwnsOnlyWhatIsBought() { assertThat(Looks.owned(Set.of("BORDER_GOLD"), Role.USER)).containsExactly("BORDER_GOLD"); diff --git a/src/test/java/core_service/modules/rewards/domain/UnlocksTest.java b/src/test/java/core_service/modules/rewards/domain/UnlocksTest.java new file mode 100644 index 0000000..29ee44f --- /dev/null +++ b/src/test/java/core_service/modules/rewards/domain/UnlocksTest.java @@ -0,0 +1,69 @@ +package core_service.modules.rewards.domain; + +import core_service.shared.test.AbstractUnitTest; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class UnlocksTest extends AbstractUnitTest { + + @Test + void longestWeekRun_AGapBreaksTheRun_DuplicatesCountOnce() { + LocalDate monday = LocalDate.of(2026, 9, 7); + + assertThat(Unlocks.longestWeekRun(List.of(monday, monday, monday.plusWeeks(1), monday.plusWeeks(3), + monday.plusWeeks(4), monday.plusWeeks(5)))).isEqualTo(3); + assertThat(Unlocks.longestWeekRun(List.of())).isZero(); + } + + @Test + void longestWeekRun_RunsAcrossTheIsoYearEnd() { + assertThat(Unlocks.longestWeekRun(List.of(LocalDate.of(2026, 12, 28), LocalDate.of(2027, 1, 4)))).isEqualTo(2); + } + + @Test + void longestMonthRun_RunsFromDecemberToJanuary() { + assertThat(Unlocks.longestMonthRun(List.of(YearMonth.of(2026, 12), YearMonth.of(2027, 1), YearMonth.of(2027, 3)))) + .isEqualTo(2); + } + + @Test + void playMoreMonths_NeedsStrictlyMoreThanANonEmptyMonthBefore_AndNeverTheRunningMonth() { + YearMonth lastFull = YearMonth.of(2026, 9); + Map minutes = Map.of( + YearMonth.of(2026, 5), 100L, + YearMonth.of(2026, 6), 100L, + YearMonth.of(2026, 7), 101L, + YearMonth.of(2026, 9), 500L, + YearMonth.of(2026, 10), 900L); + + assertThat(Unlocks.playMoreMonths(minutes, lastFull)).containsExactly(YearMonth.of(2026, 7)); + } + + @Test + void reached_ExactTargetReaches_OneBelowDoesNot_BuyableItemsNever() { + assertThat(Unlocks.reached(Map.of(Track.GAMES_FINISHED, 10))) + .containsExactly(ShopItem.BADGE_FINISH_10); + assertThat(Unlocks.reached(Map.of(Track.GAMES_FINISHED, 9))).isEmpty(); + assertThat(Unlocks.reached(Map.of())).isEmpty(); + } + + @Test + void catalog_UnlockOnlyItemsAreFreeWithATargetAndEachLevelIsUnique() { + List unlock = Arrays.stream(ShopItem.values()).filter(ShopItem::unlockOnly).toList(); + + assertThat(unlock).hasSize(12).allSatisfy(item -> { + assertThat(item.price()).isZero(); + assertThat(item.target()).isPositive(); + }); + assertThat(unlock.stream().map(item -> item.track() + ":" + item.target()).collect(Collectors.toSet())) + .hasSameSizeAs(unlock); + } +} diff --git a/src/test/java/core_service/modules/rewards/repo/RewardFactsRepositoryTest.java b/src/test/java/core_service/modules/rewards/repo/RewardFactsRepositoryTest.java index c5ac359..fd57f97 100644 --- a/src/test/java/core_service/modules/rewards/repo/RewardFactsRepositoryTest.java +++ b/src/test/java/core_service/modules/rewards/repo/RewardFactsRepositoryTest.java @@ -219,6 +219,20 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest { assertThat(repository.pileFirstAndLast(USER, month)).hasValueSatisfying(pile -> assertThat(pile).containsExactly(10, 8)); } + @Test + void minutesPerMonth_SumsPerMonthIncludingHiddenGames() { + jdbc.update("insert into steam_game (appid, name) values (221, 'Portal') on conflict do nothing"); + jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes, hidden_at) values (?, 221, 'BACKLOG', 0, now())", USER); + game("BACKLOG", 0); + playedOn("2026-08-31"); + playedOn("2026-09-01"); + playedOn("2026-09-30"); + jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, '2026-09-02', 221, 500)", USER); + + assertThat(repository.minutesPerMonth(USER)).containsOnly( + java.util.Map.entry(YearMonth.of(2026, 8), 30L), java.util.Map.entry(YearMonth.of(2026, 9), 560L)); + } + @Test void isActive_FalseForDeletedAndBlockedUsers() { game("BACKLOG", 0); diff --git a/src/test/java/core_service/modules/rewards/repo/RewardLedgerRepositoryTest.java b/src/test/java/core_service/modules/rewards/repo/RewardLedgerRepositoryTest.java index 533b780..4cde1b6 100644 --- a/src/test/java/core_service/modules/rewards/repo/RewardLedgerRepositoryTest.java +++ b/src/test/java/core_service/modules/rewards/repo/RewardLedgerRepositoryTest.java @@ -55,7 +55,37 @@ class RewardLedgerRepositoryTest extends AbstractPersistenceTest { assertThat(repository.balance(USER)).isEqualTo(30); assertThat(repository.balance(99L)).isZero(); - assertThat(repository.bought(USER)).containsExactly("BORDER_GOLD"); + assertThat(repository.itemCodes(USER)).containsExactly("BORDER_GOLD"); + } + + @Test + void itemCodes_AreBuyAndUnlockRefsNotMonths() { + repository.add(USER, new RewardEntry(RewardKind.BUY, "BORDER_GOLD", -50, JAN_1)); + repository.add(USER, new RewardEntry(RewardKind.UNLOCK, "BADGE_FINISH_10", 0, JAN_1)); + repository.add(USER, new RewardEntry(RewardKind.MONTH, "2026-09", 40, JAN_1)); + + assertThat(repository.itemCodes(USER)).containsExactlyInAnyOrder("BORDER_GOLD", "BADGE_FINISH_10"); + } + + @Test + void countKindsSumPointsAndRefs_ReadOneUsersRowsOfTheKinds() { + repository.add(USER, new RewardEntry(RewardKind.FINISHED, "220", 30, JAN_1)); + repository.add(USER, new RewardEntry(RewardKind.FINISHED_MANUAL, "-1942", 15, JAN_1)); + repository.add(USER, new RewardEntry(RewardKind.STARTER, "STARTER", 60, JAN_1)); + repository.add(USER, new RewardEntry(RewardKind.MONTH, "2026-09", 40, JAN_1)); + repository.add(USER + 1, new RewardEntry(RewardKind.FINISHED, "220", 30, JAN_1)); + + assertThat(repository.countKinds(USER, RewardKind.FINISH_KINDS)).isEqualTo(2); + assertThat(repository.sumPoints(USER, RewardKind.STARTER)).isEqualTo(60); + assertThat(repository.sumPoints(99L, RewardKind.STARTER)).isZero(); + assertThat(repository.refs(USER, RewardKind.MONTH)).containsExactly("2026-09"); + } + + @Test + void equip_ASkinRoundTrips() { + repository.equip(USER, LookSlot.SKIN, "SKIN_RETRO_CRT"); + + assertThat(repository.equipped(USER)).containsOnly(Map.entry(LookSlot.SKIN, "SKIN_RETRO_CRT")); } @Test diff --git a/src/test/java/core_service/modules/rewards/service/RewardServiceUnlockTest.java b/src/test/java/core_service/modules/rewards/service/RewardServiceUnlockTest.java new file mode 100644 index 0000000..a56d9e7 --- /dev/null +++ b/src/test/java/core_service/modules/rewards/service/RewardServiceUnlockTest.java @@ -0,0 +1,143 @@ +package core_service.modules.rewards.service; + +import core_service.shared.security.Role; +import core_service.shared.test.AbstractIntegrationTest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.sql.Timestamp; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.ZoneId; +import java.time.temporal.TemporalAdjusters; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class RewardServiceUnlockTest extends AbstractIntegrationTest { + + private static final long USER = 14L; + private static final long BIG_GAME = 990001L; + private static final long SMALL_GAME = 990002L; + private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin"); + + @Autowired + private RewardService rewardService; + + @Autowired + private JdbcTemplate jdbc; + + @BeforeEach + @AfterEach + void clean() { + jdbc.update("delete from reward_ledger where user_id = ?", USER); + jdbc.update("delete from user_game_playtime_day where user_id = ?", USER); + jdbc.update("delete from user_game where user_id = ? and appid in (?, ?)", USER, BIG_GAME, SMALL_GAME); + } + + private List unlocks() { + return jdbc.queryForList("select ref from reward_ledger where user_id = ? and kind = 'UNLOCK' order by ref", + String.class, USER); + } + + private void ledger(String kind, String ref, LocalDate day) { + jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, ?, ?, 0, ?)", + USER, kind, ref, Timestamp.from(day.atTime(12, 0).atZone(BERLIN).toInstant())); + } + + private void played(YearMonth month, int minutes) { + jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, ?, 1, ?)", + USER, java.sql.Date.valueOf(month.atDay(5)), minutes); + } + + @Test + void wallet_FourWeeksInARowWithAFinish_UnlockTheStreakBadge() { + LocalDate monday = LocalDate.now(BERLIN).with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).minusWeeks(5); + for (int i = 0; i < 4; i++) { + ledger("FINISHED", "60000" + i, monday.plusWeeks(i)); + } + + rewardService.wallet(USER, Role.USER); + + assertThat(unlocks()).containsExactly("BADGE_STREAK_4"); + } + + @Test + void wallet_FiveCompletedRows_UnlockTheCompletionistBadge_FourDoNot() { + for (int i = 0; i < 4; i++) { + ledger("COMPLETED", "70000" + i, LocalDate.now(BERLIN).minusDays(10)); + } + rewardService.wallet(USER, Role.USER); + assertThat(unlocks()).isEmpty(); + + ledger("COMPLETED", "700004", LocalDate.now(BERLIN).minusDays(10)); + rewardService.wallet(USER, Role.USER); + + assertThat(unlocks()).containsExactly("BADGE_PERFECT_5"); + } + + @Test + void wallet_ThreeMonthsInARowSmallerPile_UnlockTheFrame() { + YearMonth month = YearMonth.now(BERLIN).minusMonths(4); + for (int i = 0; i < 3; i++) { + ledger("MONTH", month.plusMonths(i).toString(), month.atDay(1)); + } + + rewardService.wallet(USER, Role.USER); + + assertThat(unlocks()).containsExactly("FRAME_PILE_DOWN_3"); + } + + @Test + void wallet_PlaytimeRisingOverFullMonths_UnlocksPlayMore() { + YearMonth first = YearMonth.now(BERLIN).minusMonths(4); + played(first, 60); + played(first.plusMonths(1), 120); + played(first.plusMonths(2), 180); + played(first.plusMonths(3), 240); + + rewardService.wallet(USER, Role.USER); + + assertThat(unlocks()).containsExactly("BADGE_PLAY_MORE_1", "BORDER_PLAY_MORE_3"); + } + + @Test + void wallet_MoreMinutesThanAnEmptyMonthBefore_UnlocksNothing() { + played(YearMonth.now(BERLIN).minusMonths(2), 600); + + rewardService.wallet(USER, Role.USER); + + assertThat(unlocks()).isEmpty(); + } + + // Hiding is free and reversible, so hidden games count for this track. + @Test + void hidingAGameForOneSettle_DoesNotUnlockPlayMore() { + YearMonth first = YearMonth.now(BERLIN).minusMonths(3); + for (long appid : List.of(BIG_GAME, SMALL_GAME)) { + jdbc.update("insert into steam_game (appid, name) values (?, 'Security Game') on conflict do nothing", appid); + jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes) values (?, ?, 'BACKLOG', 0)", + USER, appid); + } + // All games: 610 then 20 minutes, so the user played less, not more. + playedGame(BIG_GAME, first, 600); + playedGame(SMALL_GAME, first, 10); + playedGame(SMALL_GAME, first.plusMonths(1), 20); + + jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = ?", USER, BIG_GAME); + rewardService.wallet(USER, Role.USER); + jdbc.update("update user_game set hidden_at = null where user_id = ? and appid = ?", USER, BIG_GAME); + rewardService.wallet(USER, Role.USER); + + assertThat(unlocks()).isEmpty(); + } + + private void playedGame(long appid, YearMonth month, int minutes) { + jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, ?, ?, ?)", + USER, java.sql.Date.valueOf(month.atDay(5)), appid, minutes); + } +}