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
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 |
@@ -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"));
};
}
@@ -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<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
@@ -27,9 +33,11 @@ public record WalletDto(long balance, List<ItemDto> catalog, Set<String> 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());
}
}
@@ -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
}
@@ -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).
@@ -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() {
}
@@ -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() {
@@ -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.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<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
* 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) {
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<String> 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<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.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<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.
@Transactional
public Wallet wallet(long userId, Role role) {
settleLocked(userId);
Set<String> owned = Looks.owned(ledger.bought(userId), role);
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));
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<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)) {
return;
return Map.of();
}
ledger.lockUser(userId);
int paid = settleStarts(userId) + settleFinishes(userId) + settleCompleted(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) {
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) {
@@ -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<LocalDate> weeks = new HashSet<>();
ledger.eventsOfKinds(userId, RewardKind.FINISH_KINDS, startOf(since)).forEach(at -> weeks.add(weekStart(at)));
Set<LocalDate> 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<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) {
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();
}
@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);
@@ -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<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(
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);
}
}
}
@@ -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");
@@ -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));
}
@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);
@@ -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
@@ -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);
}
}