feat(rewards): unlock items by progress and skins
Build, Test & Push Core Service / quality-check (push) Successful in 1m11s
Build, Test & Push Core Service / docker (push) Successful in 1m27s

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.
This commit is contained in:
lobo committed 2026-10-02 15:40:46 +02:00
1 parent bb8341dfef
commit 8eab98df74
19 files changed
+633 -31

No files matched your search

+9 -4
View File
@@ -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 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 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 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 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,
backgrounds (V27 widens `reward_equipped.slot`); a `BUY` row proves ownership, 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` `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). 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 - **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 | | Method | Path | Auth |
|---|---|---| |---|---|---|
| `GET` | `/api/rewards/wallet` (balance, catalog, owned, equipped, last 50 ledger rows with the game name) | 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`) | 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 | | `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 | | `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 | | `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT |
@@ -44,6 +44,8 @@ public class RewardController {
.body(new ErrorResponse("ALREADY_OWNED", "The item is already owned")); .body(new ErrorResponse("ALREADY_OWNED", "The item is already owned"));
case NOT_ENOUGH_POINTS -> ResponseEntity.status(HttpStatus.CONFLICT) case NOT_ENOUGH_POINTS -> ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ErrorResponse("NOT_ENOUGH_POINTS", "Not enough Pile Points")); .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"));
}; };
} }
@@ -3,6 +3,7 @@ package core_service.modules.rewards.api.dto;
import core_service.modules.rewards.domain.LookSlot; import core_service.modules.rewards.domain.LookSlot;
import core_service.modules.rewards.domain.RewardKind; import core_service.modules.rewards.domain.RewardKind;
import core_service.modules.rewards.domain.ShopItem; import core_service.modules.rewards.domain.ShopItem;
import core_service.modules.rewards.domain.Track;
import core_service.modules.rewards.service.RewardService; import core_service.modules.rewards.service.RewardService;
import java.time.Instant; import java.time.Instant;
@@ -14,11 +15,16 @@ import java.util.Set;
/** /**
* @param owned the item codes the user may wear * @param owned the item codes the user may wear
* @param equipped the worn item code per slot * @param equipped the worn item code per slot
* @param progress the value of each unlock track
*/ */
public record WalletDto(long balance, List<ItemDto> catalog, Set<String> owned, Map<LookSlot, String> equipped, public record WalletDto(long balance, List<ItemDto> catalog, Set<String> owned, Map<LookSlot, String> equipped,
List<EntryDto> history) { List<EntryDto> history, Map<Track, Integer> 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 * @param gameName null when the ref is no game
@@ -27,9 +33,11 @@ public record WalletDto(long balance, List<ItemDto> catalog, Set<String> owned,
public static WalletDto of(RewardService.Wallet wallet) { public static WalletDto of(RewardService.Wallet wallet) {
return new WalletDto(wallet.balance(), 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.owned(), wallet.equipped(),
wallet.history().stream() wallet.history().stream()
.map(h -> new EntryDto(h.kind(), h.ref(), h.gameName(), h.points(), h.eventAt())).toList()); .map(h -> new EntryDto(h.kind(), h.ref(), h.gameName(), h.points(), h.eventAt())).toList(),
wallet.progress());
} }
} }
@@ -1,5 +1,5 @@
package core_service.modules.rewards.domain; package core_service.modules.rewards.domain;
public enum LookSlot { 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
} }
@@ -3,7 +3,12 @@ package core_service.modules.rewards.domain;
import java.util.List; import java.util.List;
public enum RewardKind { 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). * The kinds whose ref is a game (an appid).
@@ -26,6 +26,10 @@ public final class RewardRules {
public static final int PLAYED_AROUND_DAYS = 30; public static final int PLAYED_AROUND_DAYS = 30;
public static final int MONTHS_BACK = 2; public static final int MONTHS_BACK = 2;
public static final int STREAK_WEEKS = 12; 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() { private RewardRules() {
} }
@@ -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. * 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 * {@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 { public enum ShopItem {
BORDER_GOLD(LookSlot.AVATAR_BORDER, 150), BORDER_GOLD(LookSlot.AVATAR_BORDER, 150),
@@ -37,14 +38,55 @@ public enum ShopItem {
BN_MESH(LookSlot.BANNER_BACKGROUND, 300), BN_MESH(LookSlot.BANNER_BACKGROUND, 300),
BN_GRID(LookSlot.BANNER_BACKGROUND, 300), BN_GRID(LookSlot.BANNER_BACKGROUND, 300),
BN_WAVES(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 LookSlot slot;
private final int price; private final int price;
private final Track track;
private final int target;
ShopItem(LookSlot slot, int price) { ShopItem(LookSlot slot, int price) {
this.slot = slot; this.slot = slot;
this.price = price; 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() { public LookSlot slot() {
@@ -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
}
@@ -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<LocalDate> mondays) {
return longestRun(mondays, day -> day.plusWeeks(1));
}
public static int longestMonthRun(Collection<YearMonth> months) {
return longestRun(months, month -> month.plusMonths(1));
}
private static <T extends Comparable<? super T>> int longestRun(Collection<T> values, UnaryOperator<T> 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<YearMonth> playMoreMonths(Map<YearMonth, Long> 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<ShopItem> reached(Map<Track, Integer> progress) {
return Arrays.stream(ShopItem.values())
.filter(ShopItem::unlockOnly)
.filter(item -> progress.getOrDefault(item.track(), 0) >= item.target())
.toList();
}
}
@@ -4,6 +4,7 @@ import core_service.modules.rewards.domain.FinishFacts;
import core_service.modules.rewards.domain.RewardRules; import core_service.modules.rewards.domain.RewardRules;
import core_service.modules.rewards.domain.StartFacts; import core_service.modules.rewards.domain.StartFacts;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.RowCallbackHandler;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@@ -11,7 +12,9 @@ import org.springframework.stereotype.Repository;
import java.sql.Date; import java.sql.Date;
import java.time.YearMonth; import java.time.YearMonth;
import java.time.ZoneId; import java.time.ZoneId;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
@@ -121,6 +124,22 @@ public class RewardFactsRepository {
false, false, rs.getBoolean("all_achievements"), rs.getTimestamp("event_at").toInstant())); 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<YearMonth, Long> minutesPerMonth(long userId) {
Map<YearMonth, Long> 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 * The pile (backlog_games) on the first and on the last day of the month, {first, last}. Empty unless both days
* have a row. * have a row.
@@ -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<String> bought(long userId) { public Set<String> itemCodes(long userId) {
return new HashSet<>(jdbc.queryForList("select ref from reward_ledger where user_id = :userId and kind = 'BUY'", return new HashSet<>(jdbc.queryForList("""
new MapSqlParameterSource("userId", userId), String.class)); 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<RewardKind> 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<String> 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);
} }
/** /**
@@ -8,6 +8,8 @@ import core_service.modules.rewards.domain.RewardEntry;
import core_service.modules.rewards.domain.RewardKind; import core_service.modules.rewards.domain.RewardKind;
import core_service.modules.rewards.domain.RewardRules; import core_service.modules.rewards.domain.RewardRules;
import core_service.modules.rewards.domain.ShopItem; 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.RewardFactsRepository;
import core_service.modules.rewards.repo.RewardLedgerRepository; import core_service.modules.rewards.repo.RewardLedgerRepository;
import core_service.shared.security.Public; import core_service.shared.security.Public;
@@ -27,10 +29,11 @@ import java.time.LocalDate;
import java.time.YearMonth; import java.time.YearMonth;
import java.time.ZoneId; import java.time.ZoneId;
import java.time.temporal.TemporalAdjusters; import java.time.temporal.TemporalAdjusters;
import java.util.HashSet; import java.util.EnumMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; 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 * 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; 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 * @param equipped the worn looks, only those the user still owns
*/ */
public record Wallet(long balance, Set<String> owned, Map<LookSlot, String> equipped, List<HistoryEntry> history) {} public record Wallet(long balance, Set<String> owned, Map<LookSlot, String> equipped, List<HistoryEntry> history,
Map<Track, Integer> progress) {}
// ponytail: settles on every wallet read (the app reads it once per start); a settled_at column if that hurts. // ponytail: settles on every wallet read (the app reads it once per start); a settled_at column if that hurts.
@Transactional @Transactional
public Wallet wallet(long userId, Role role) { public Wallet wallet(long userId, Role role) {
settleLocked(userId); Map<Track, Integer> progress = settleLocked(userId);
Set<String> owned = Looks.owned(ledger.bought(userId), role); Set<String> owned = Looks.owned(ledger.itemCodes(userId), role);
return new Wallet(ledger.balance(userId), owned, Looks.wearable(ledger.equipped(userId), owned), return new Wallet(ledger.balance(userId), owned, Looks.wearable(ledger.equipped(userId), owned),
ledger.history(userId, HISTORY_SIZE)); 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 } public enum EquipResult { DONE, NOT_OWNED, WRONG_SLOT }
@@ -76,9 +81,12 @@ public class RewardService {
@Transactional @Transactional
public BuyResult buy(long userId, Role role, ShopItem item) { public BuyResult buy(long userId, Role role, ShopItem item) {
ledger.lockUser(userId); 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; return BuyResult.ALREADY_OWNED;
} }
if (item.unlockOnly()) {
return BuyResult.NOT_FOR_SALE;
}
if (ledger.balance(userId) < item.price()) { if (ledger.balance(userId) < item.price()) {
return BuyResult.NOT_ENOUGH_POINTS; return BuyResult.NOT_ENOUGH_POINTS;
} }
@@ -98,7 +106,7 @@ public class RewardService {
if (item.slot() != slot) { if (item.slot() != slot) {
return EquipResult.WRONG_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; return EquipResult.NOT_OWNED;
} }
ledger.equip(userId, slot, item.name()); ledger.equip(userId, slot, item.name());
@@ -110,7 +118,7 @@ public class RewardService {
*/ */
@Public @Public
public Map<LookSlot, String> publicLooks(long userId, Role role) { public Map<LookSlot, String> 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<Track, Integer> settleLocked(long userId) {
if (!facts.isActive(userId)) { if (!facts.isActive(userId)) {
return; return Map.of();
} }
ledger.lockUser(userId); ledger.lockUser(userId);
int paid = settleStarts(userId) + settleFinishes(userId) + settleCompleted(userId) int paid = settleStarts(userId) + settleFinishes(userId) + settleCompleted(userId)
+ settleMonths(userId) + settleStreaks(userId); + settleMonths(userId) + settleStreaks(userId);
Map<Track, Integer> 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) { if (paid > 0) {
log.info("Reward settle of user {} booked {} rows", userId, paid); 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<Track, Integer> progress(long userId) {
Set<YearMonth> pileMonths = ledger.refs(userId, RewardKind.MONTH).stream().map(YearMonth::parse)
.collect(Collectors.toSet());
Set<YearMonth> playMore = Unlocks.playMoreMonths(facts.minutesPerMonth(userId), YearMonth.now(zone).minusMonths(1));
int starter = (int) (ledger.sumPoints(userId, RewardKind.STARTER) / RewardRules.STARTER_PER_GAME);
Map<Track, Integer> 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) { private int book(long userId, RewardEntry entry) {
@@ -197,8 +225,7 @@ public class RewardService {
private int settleStreaks(long userId) { private int settleStreaks(long userId) {
LocalDate since = LocalDate.now(zone).with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) LocalDate since = LocalDate.now(zone).with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
.minusWeeks(RewardRules.STREAK_WEEKS); .minusWeeks(RewardRules.STREAK_WEEKS);
Set<LocalDate> weeks = new HashSet<>(); Set<LocalDate> weeks = finishWeeks(userId, startOf(since));
ledger.eventsOfKinds(userId, RewardKind.FINISH_KINDS, startOf(since)).forEach(at -> weeks.add(weekStart(at)));
int paid = 0; int paid = 0;
for (LocalDate monday : weeks) { for (LocalDate monday : weeks) {
if (weeks.contains(monday.minusWeeks(1))) { if (weeks.contains(monday.minusWeeks(1))) {
@@ -209,6 +236,11 @@ public class RewardService {
return paid; return paid;
} }
private Set<LocalDate> 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) { private LocalDate weekStart(Instant at) {
return at.atZone(zone).toLocalDate().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); return at.atZone(zone).toLocalDate().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
} }
@@ -318,6 +318,28 @@ class GameMergeUndoFlowTest extends AbstractIntegrationTest {
assertThat(count("select count(*) from reward_ledger where ref = ?", String.valueOf(STEAM))).isZero(); 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 @Test
void movedRowTakenOverBySteam_StaysWhereItIs() throws Exception { void movedRowTakenOverBySteam_StaysWhereItIs() throws Exception {
manualRow(AUDITOR_ID, MANUAL, GameStatus.PLAYING); manualRow(AUDITOR_ID, MANUAL, GameStatus.PLAYING);
@@ -16,6 +16,8 @@ import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; 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.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@@ -153,6 +155,18 @@ class RewardControllerTest extends AbstractIntegrationTest {
userId, points); 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<String> 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( private static MockHttpServletRequestBuilder asAdmin(
MockHttpServletRequestBuilder request) { MockHttpServletRequestBuilder request) {
return request.header("Authorization", "Bearer " + ContractTokens.loginToken(ADMIN, Role.PLATFORM_ADMIN)); 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("$.owned").isEmpty())
.andExpect(jsonPath("$.equipped").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);
}
}
} }
@@ -16,6 +16,12 @@ class LooksTest extends AbstractUnitTest {
assertThat(Looks.owned(Set.of(), Role.PLATFORM_ADMIN)).isEqualTo(ShopItem.codes()); 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 @Test
void owned_UserOwnsOnlyWhatIsBought() { void owned_UserOwnsOnlyWhatIsBought() {
assertThat(Looks.owned(Set.of("BORDER_GOLD"), Role.USER)).containsExactly("BORDER_GOLD"); assertThat(Looks.owned(Set.of("BORDER_GOLD"), Role.USER)).containsExactly("BORDER_GOLD");
@@ -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<YearMonth, Long> 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<ShopItem> 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);
}
}
@@ -219,6 +219,20 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.pileFirstAndLast(USER, month)).hasValueSatisfying(pile -> assertThat(pile).containsExactly(10, 8)); 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 @Test
void isActive_FalseForDeletedAndBlockedUsers() { void isActive_FalseForDeletedAndBlockedUsers() {
game("BACKLOG", 0); game("BACKLOG", 0);
@@ -55,7 +55,37 @@ class RewardLedgerRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.balance(USER)).isEqualTo(30); assertThat(repository.balance(USER)).isEqualTo(30);
assertThat(repository.balance(99L)).isZero(); 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 @Test
@@ -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<String> 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);
}
}