Public Access
feat(rewards): unlock items by progress and skins
Five progress tracks: games finished, completed, weekly finish streaks, pile-down months and play-more months. Settle books UNLOCK rows once, also retroactively, when a track reaches an item's target. 12 unlock-only items (incl. the Steam skin) and 3 skins to buy. The wallet returns progress; buying an unlock item answers NOT_FOR_SALE.
This commit is contained in:
1 parent
bb8341dfef
commit
8eab98df74
19 files changed
+633
-31
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user