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

@@ -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);
}
}