feat(rewards): ledger, earning rules and nightly settle

New module rewards tracks Pile Points in an append-only ledger. A settle
derives rows from the status log, playtime days and achievements, once per
user, kind and game. The balance is the sum of all rows. Settle runs on
every wallet read and nightly at 02:30. The unique key (user_id, kind, ref)
makes every run idempotent. V26 also pays a one-time starter grant for
finishes from before the status log.
This commit is contained in:
lobo committed 2026-10-01 13:15:04 +02:00
1 parent 732970697d
commit 837dad8a6f
28 files changed
+1470 -3

No files matched your search

@@ -22,7 +22,7 @@ public class AccountCleanupRepository {
// Children before user_game (user_game_status_change would also go by its cascade), the tombstones last.
static final List<String> TABLES = List.of("user_game_status_change", "user_game_achievement",
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
"user_steam_sync", "user_game", "game_import", "blocked_user", "deleted_user");
"user_steam_sync", "user_game", "game_import", "reward_ledger", "reward_equipped", "blocked_user", "deleted_user");
// Every user with data has a row in one of these; deleteRows then removes the child rows too.
private static final List<String> ORPHAN_SOURCES = List.of("user_game", "user_steam_sync", "wishlist_game",
@@ -87,6 +87,13 @@ public class GameMergeRepository {
jdbc.update("delete from user_game f where f.appid = :from and exists "
+ "(select 1 from user_game t where t.user_id = f.user_id and t.appid = :to)", keys);
int moved = jdbc.update("update user_game set appid = :to where appid = :from", keys);
// Paid games stay paid under the kept key; a user who earned both keys keeps both rows.
jdbc.update("""
update reward_ledger l set ref = cast(:to as text)
where l.ref = cast(:from as text) and l.kind in ('STARTED', 'FINISHED', 'FINISHED_MANUAL', 'COMPLETED')
and not exists (select 1 from reward_ledger o where o.user_id = l.user_id and o.kind = l.kind
and o.ref = cast(:to as text))
""", keys);
for (String table : CATALOG_TABLES) {
jdbc.update("delete from " + table + " where appid = :from", keys);
}
@@ -0,0 +1,27 @@
package core_service.modules.rewards.api;
import core_service.modules.rewards.api.dto.WalletDto;
import core_service.modules.rewards.service.RewardService;
import core_service.shared.security.UserPrincipal;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/rewards")
@RequiredArgsConstructor
public class RewardController {
private final RewardService rewardService;
/**
* Settles first, so the wallet is current.
*/
@GetMapping("/wallet")
public ResponseEntity<WalletDto> getWallet(@AuthenticationPrincipal UserPrincipal principal) {
return ResponseEntity.ok(WalletDto.of(rewardService.wallet(principal.getIdAsLong(), principal.role())));
}
}
@@ -0,0 +1,35 @@
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.service.RewardService;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* @param owned the item codes the user may wear
* @param equipped the worn item code per slot
*/
public record WalletDto(long balance, List<ItemDto> catalog, Set<String> owned, Map<LookSlot, String> equipped,
List<EntryDto> history) {
public record ItemDto(String code, LookSlot slot, int price) {}
/**
* @param gameName null when the ref is no game
*/
public record EntryDto(RewardKind kind, String ref, String gameName, int points, Instant eventAt) {}
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(),
wallet.owned(), wallet.equipped(),
wallet.history().stream()
.map(h -> new EntryDto(h.kind(), h.ref(), h.gameName(), h.points(), h.eventAt())).toList());
}
}
@@ -0,0 +1,16 @@
package core_service.modules.rewards.domain;
import java.time.Instant;
/**
* Raw facts of one finished game; {@link RewardRules} decides.
*
* @param playedSinceMinutes minutes of the playtime days on or after the finish day (playtimeMinutes has the old play too)
* @param normallyMinutes IGDB normal time to beat, null when unknown
* @param endingUnlocked the ending achievement is unlocked, and not earlier than 30 days before the finish
* @param playedAround a playtime day within 30 days before the finish or any time after
* @param allAchievements all achievements of a game with some are unlocked
* @param finishedAt the first status change to FINISHED or COMPLETED
*/
public record FinishFacts(long appid, boolean manual, boolean completed, int playtimeMinutes, int playedSinceMinutes, Integer normallyMinutes,
boolean endingUnlocked, boolean playedAround, boolean allAchievements, Instant finishedAt) {}
@@ -0,0 +1,8 @@
package core_service.modules.rewards.domain;
import java.time.Instant;
/**
* @param gameName null when the ref is no game
*/
public record HistoryEntry(RewardKind kind, String ref, String gameName, int points, Instant eventAt) {}
@@ -0,0 +1,5 @@
package core_service.modules.rewards.domain;
public enum LookSlot {
AVATAR_BORDER, BADGE, ACCENT, BANNER_FRAME
}
@@ -0,0 +1,34 @@
package core_service.modules.rewards.domain;
import core_service.shared.security.Role;
import java.util.EnumMap;
import java.util.Map;
import java.util.Set;
public final class Looks {
private Looks() {
}
/**
* A platform admin owns every item of the catalog, so later items need no extra work.
*/
public static Set<String> owned(Set<String> bought, Role role) {
return role == Role.PLATFORM_ADMIN ? ShopItem.codes() : bought;
}
/**
* The worn looks the user may show now: a code that is not owned or not in the catalog drops out, e.g. the free
* items of an admin who is no admin any more.
*/
public static Map<LookSlot, String> wearable(Map<LookSlot, String> equipped, Set<String> owned) {
Map<LookSlot, String> result = new EnumMap<>(LookSlot.class);
equipped.forEach((slot, code) -> {
if (owned.contains(code) && ShopItem.codes().contains(code)) {
result.put(slot, code);
}
});
return result;
}
}
@@ -0,0 +1,11 @@
package core_service.modules.rewards.domain;
import java.time.Instant;
/**
* A ledger row to write.
*
* @param ref see V26
* @param eventAt when the game event happened, not when it is booked
*/
public record RewardEntry(RewardKind kind, String ref, int points, Instant eventAt) {}
@@ -0,0 +1,17 @@
package core_service.modules.rewards.domain;
import java.util.List;
public enum RewardKind {
STARTED, FINISHED, FINISHED_MANUAL, COMPLETED, MONTH, STREAK, STARTER, BUY;
/**
* The kinds whose ref is a game (an appid).
*/
public static final List<RewardKind> GAME_KINDS = List.of(STARTED, FINISHED, FINISHED_MANUAL, COMPLETED);
/**
* The kinds that count as "finished a game" for the weekly streak.
*/
public static final List<RewardKind> FINISH_KINDS = List.of(FINISHED, FINISHED_MANUAL);
}
@@ -0,0 +1,88 @@
package core_service.modules.rewards.domain;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.temporal.IsoFields;
import java.util.Optional;
/**
* The numbers of Pile Points and who earns what. Pure rules, no database. The ledger is append-only, so a change here
* never touches paid rows. The starter grant (20 per finished game, cap 250) is in V26.
*/
public final class RewardRules {
public static final int START = 5;
public static final int FINISH_SHORT = 30;
public static final int FINISH_MID = 50;
public static final int FINISH_LONG = 80;
public static final int MID_HOURS = 10;
public static final int LONG_HOURS = 30;
public static final int COMPLETED_BONUS = 40;
public static final int MANUAL_FINISH = 15;
public static final int MANUAL_PER_WEEK = 3;
public static final int MONTH_BONUS = 40;
public static final int STREAK_BONUS = 20;
public static final double PROOF_SHARE = 0.5;
public static final int PLAYED_AROUND_DAYS = 30;
public static final int MONTHS_BACK = 2;
public static final int STREAK_WEEKS = 12;
private RewardRules() {
}
/**
* Steam games only: a game added by hand has no playtime to prove the start.
*/
public static Optional<RewardEntry> start(StartFacts f) {
if (f.manual() || !f.playedAfter()) {
return Optional.empty();
}
return Optional.of(new RewardEntry(RewardKind.STARTED, String.valueOf(f.appid()), START, f.startedAt()));
}
/**
* A game added by hand pays {@link #MANUAL_FINISH} (the weekly cap is the caller's job). A Steam game pays by its
* length and needs playtime plus one proof: the ending achievement, playtime around the finish, or playtime of
* at least {@link #PROOF_SHARE} of the normal time.
*/
public static Optional<RewardEntry> finish(FinishFacts f) {
String ref = String.valueOf(f.appid());
if (f.manual()) {
return Optional.of(new RewardEntry(RewardKind.FINISHED_MANUAL, ref, MANUAL_FINISH, f.finishedAt()));
}
if (f.playtimeMinutes() <= 0 || !hasProof(f)) {
return Optional.empty();
}
return Optional.of(new RewardEntry(RewardKind.FINISHED, ref, finishPoints(f.normallyMinutes()), f.finishedAt()));
}
private static boolean hasProof(FinishFacts f) {
return f.endingUnlocked() || f.playedAround()
|| (f.normallyMinutes() != null && f.playedSinceMinutes() >= PROOF_SHARE * f.normallyMinutes());
}
private static int finishPoints(Integer normallyMinutes) {
if (normallyMinutes == null || normallyMinutes < MID_HOURS * 60) {
return FINISH_SHORT;
}
return normallyMinutes <= LONG_HOURS * 60 ? FINISH_MID : FINISH_LONG;
}
public static Optional<RewardEntry> completed(FinishFacts f) {
if (!f.completed() || f.manual() || !f.allAchievements()) {
return Optional.empty();
}
return Optional.of(new RewardEntry(RewardKind.COMPLETED, String.valueOf(f.appid()), COMPLETED_BONUS, f.finishedAt()));
}
public static String monthRef(YearMonth month) {
return month.toString();
}
/**
* ISO week, e.g. {@code 2026-W41}; the year is the week-based year.
*/
public static String weekRef(LocalDate day) {
return "%d-W%02d".formatted(day.get(IsoFields.WEEK_BASED_YEAR), day.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR));
}
}
@@ -0,0 +1,39 @@
package core_service.modules.rewards.domain;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
/**
* The whitelist of looks. The frontend maps a code to its visuals; nothing here is ever uploaded CSS.
*/
public enum ShopItem {
BORDER_GOLD(LookSlot.AVATAR_BORDER, 150),
BORDER_NEON(LookSlot.AVATAR_BORDER, 200),
BADGE_PILE_SLAYER(LookSlot.BADGE, 100),
BADGE_COMPLETIONIST(LookSlot.BADGE, 150),
ACCENT_GOLD(LookSlot.ACCENT, 200),
ACCENT_TEAL(LookSlot.ACCENT, 200),
FRAME_GOLD(LookSlot.BANNER_FRAME, 150),
FRAME_NEON(LookSlot.BANNER_FRAME, 200);
private final LookSlot slot;
private final int price;
ShopItem(LookSlot slot, int price) {
this.slot = slot;
this.price = price;
}
public LookSlot slot() {
return slot;
}
public int price() {
return price;
}
public static Set<String> codes() {
return Arrays.stream(values()).map(Enum::name).collect(Collectors.toSet());
}
}
@@ -0,0 +1,9 @@
package core_service.modules.rewards.domain;
import java.time.Instant;
/**
* @param manual a game added by hand: it has no playtime proof
* @param playedAfter a playtime day on or after the day of the start
*/
public record StartFacts(long appid, boolean manual, Instant startedAt, boolean playedAfter) {}
@@ -0,0 +1,141 @@
package core_service.modules.rewards.repo;
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.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
import java.sql.Date;
import java.time.YearMonth;
import java.time.ZoneId;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
/**
* What the library tables say about a user's games, as raw facts for {@link RewardRules}. Plain SQL, because the
* library module owns the entities and a module may not depend on another.
*/
@Repository
@RequiredArgsConstructor
public class RewardFactsRepository {
private static final String NOT_DELETED_OR_BLOCKED =
"user_id not in (select user_id from deleted_user) and user_id not in (select user_id from blocked_user)";
private final NamedParameterJdbcTemplate jdbc;
public boolean isActive(long userId) {
return Boolean.TRUE.equals(jdbc.queryForObject("""
select not exists (select 1 from deleted_user where user_id = :userId)
and not exists (select 1 from blocked_user where user_id = :userId)
""", new MapSqlParameterSource("userId", userId), Boolean.class));
}
public List<Long> activeUserIds() {
return jdbc.queryForList("select distinct user_id from user_game where " + NOT_DELETED_OR_BLOCKED
+ " order by user_id", new MapSqlParameterSource(), Long.class);
}
/**
* The first BACKLOG to PLAYING change of each game not paid yet.
*/
public List<StartFacts> startCandidates(long userId, ZoneId zone) {
return jdbc.query("""
select c.appid, ug.source = 'MANUAL' as manual, c.started_at,
exists (select 1 from user_game_playtime_day d where d.user_id = c.user_id and d.appid = c.appid
and d.day >= (c.started_at at time zone :zone)::date) as played_after
from (select user_id, appid, min(changed_at) as started_at from user_game_status_change
where user_id = :userId and from_status = 'BACKLOG' and to_status = 'PLAYING'
group by user_id, appid) c
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid and ug.removed_at is null
and ug.hidden_at is null
where not exists (select 1 from reward_ledger l where l.user_id = c.user_id and l.kind = 'STARTED'
and l.ref = c.appid::text)
order by c.started_at
""", new MapSqlParameterSource("userId", userId).addValue("zone", zone.getId()),
(rs, i) -> new StartFacts(rs.getLong("appid"), rs.getBoolean("manual"),
rs.getTimestamp("started_at").toInstant(), rs.getBoolean("played_after")));
}
/**
* Games that are FINISHED or COMPLETED now, with their first change to one of the two, and no FINISHED or
* FINISHED_MANUAL row yet. Oldest first. Only fresh play proves a finish: the ending achievement must be unlocked
* no earlier than {@link RewardRules#PLAYED_AROUND_DAYS} before that change, and the playtime share counts only
* the playtime days since it. Hidden games pay nothing.
*/
public List<FinishFacts> finishCandidates(long userId, ZoneId zone) {
return jdbc.query("""
select c.appid, ug.source = 'MANUAL' as manual, ug.status = 'COMPLETED' as completed,
ug.playtime_minutes, t.normally_minutes,
coalesce(ug.finished_at_estimate >= c.finished_at - make_interval(days => :aroundDays), false)
as ending_unlocked,
(select coalesce(sum(d.minutes), 0) from user_game_playtime_day d
where d.user_id = c.user_id and d.appid = c.appid
and d.day >= (c.finished_at at time zone :zone)::date) as played_since,
exists (select 1 from user_game_playtime_day d where d.user_id = c.user_id and d.appid = c.appid
and d.day >= (c.finished_at at time zone :zone)::date - :aroundDays) as played_around,
coalesce(s.achievements_total > 0 and m.achievements_unlocked >= s.achievements_total, false)
as all_achievements,
c.finished_at
from (select user_id, appid, min(changed_at) as finished_at from user_game_status_change
where user_id = :userId and to_status in ('FINISHED', 'COMPLETED')
group by user_id, appid) c
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid and ug.removed_at is null
and ug.status in ('FINISHED', 'COMPLETED') and ug.hidden_at is null
left join game_time_to_beat t on t.appid = c.appid
left join user_game_achievement_meta m on m.user_id = c.user_id and m.appid = c.appid
left join game_achievement_schema_meta s on s.appid = c.appid
where not exists (select 1 from reward_ledger l where l.user_id = c.user_id
and l.kind in ('FINISHED', 'FINISHED_MANUAL') and l.ref = c.appid::text)
order by c.finished_at
""", new MapSqlParameterSource("userId", userId).addValue("zone", zone.getId())
.addValue("aroundDays", RewardRules.PLAYED_AROUND_DAYS),
(rs, i) -> new FinishFacts(rs.getLong("appid"), rs.getBoolean("manual"), rs.getBoolean("completed"),
rs.getInt("playtime_minutes"), rs.getInt("played_since"),
rs.getObject("normally_minutes", Integer.class),
rs.getBoolean("ending_unlocked"), rs.getBoolean("played_around"),
rs.getBoolean("all_achievements"), rs.getTimestamp("finished_at").toInstant()));
}
/**
* COMPLETED Steam games with a paid FINISHED row and no COMPLETED row yet; finishedAt is the FINISHED event.
*/
public List<FinishFacts> completedCandidates(long userId) {
return jdbc.query("""
select ug.appid, ug.playtime_minutes, f.event_at,
coalesce(s.achievements_total > 0 and m.achievements_unlocked >= s.achievements_total, false)
as all_achievements
from user_game ug
join reward_ledger f on f.user_id = ug.user_id and f.kind = 'FINISHED' and f.ref = ug.appid::text
left join user_game_achievement_meta m on m.user_id = ug.user_id and m.appid = ug.appid
left join game_achievement_schema_meta s on s.appid = ug.appid
where ug.user_id = :userId and ug.status = 'COMPLETED' and ug.removed_at is null and ug.hidden_at is null
and not exists (select 1 from reward_ledger l where l.user_id = ug.user_id and l.kind = 'COMPLETED'
and l.ref = ug.appid::text)
""", new MapSqlParameterSource("userId", userId),
(rs, i) -> new FinishFacts(rs.getLong("appid"), false, true, rs.getInt("playtime_minutes"), 0, null,
false, false, rs.getBoolean("all_achievements"), rs.getTimestamp("event_at").toInstant()));
}
/**
* The pile (backlog_games) on the first and on the last day of the month, {first, last}. Empty unless both days
* have a row.
*/
public Optional<int[]> pileFirstAndLast(long userId, YearMonth month) {
List<int[]> rows = jdbc.query("""
select (select backlog_games from backlog_snapshot where user_id = :userId and day = :first) as first_day,
(select backlog_games from backlog_snapshot where user_id = :userId and day = :last) as last_day
""", new MapSqlParameterSource("userId", userId).addValue("first", Date.valueOf(month.atDay(1)))
.addValue("last", Date.valueOf(month.atEndOfMonth())),
(rs, i) -> {
Integer first = rs.getObject("first_day", Integer.class);
Integer last = rs.getObject("last_day", Integer.class);
return first == null || last == null ? null : new int[]{first, last};
});
return rows.stream().filter(Objects::nonNull).findFirst();
}
}
@@ -0,0 +1,127 @@
package core_service.modules.rewards.repo;
import core_service.modules.rewards.domain.HistoryEntry;
import core_service.modules.rewards.domain.LookSlot;
import core_service.modules.rewards.domain.RewardEntry;
import core_service.modules.rewards.domain.RewardKind;
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;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.Collection;
import java.util.EnumMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* The tables reward_ledger and reward_equipped. The balance is always the sum of the rows.
*/
@Repository
@RequiredArgsConstructor
public class RewardLedgerRepository {
private final NamedParameterJdbcTemplate jdbc;
private static List<String> names(Collection<RewardKind> kinds) {
return kinds.stream().map(Enum::name).toList();
}
/**
* Holds until the end of the transaction. Settle and buy take it first: the weekly cap and the balance are
* checked against rows another request could write at the same time.
*/
public void lockUser(long userId) {
jdbc.query("select pg_advisory_xact_lock(:userId)", new MapSqlParameterSource("userId", userId),
(RowCallbackHandler) rs -> { });
}
public long balance(long userId) {
Long sum = jdbc.queryForObject("select coalesce(sum(points), 0) from reward_ledger where user_id = :userId",
new MapSqlParameterSource("userId", userId), Long.class);
return sum != null ? sum : 0;
}
/**
* @return false when the user already has a row with this kind and ref
*/
public boolean add(long userId, RewardEntry entry) {
return jdbc.update("""
insert into reward_ledger (user_id, kind, ref, points, event_at)
values (:userId, :kind, :ref, :points, :eventAt)
on conflict (user_id, kind, ref) do nothing
""", new MapSqlParameterSource("userId", userId).addValue("kind", entry.kind().name())
.addValue("ref", entry.ref()).addValue("points", entry.points())
.addValue("eventAt", Timestamp.from(entry.eventAt()))) > 0;
}
/**
* Newest first. A manual game is in steam_game too (negative appid), so it has its name as well.
*/
public List<HistoryEntry> history(long userId, int limit) {
return jdbc.query("""
select l.kind, l.ref, sg.name, l.points, l.event_at
from reward_ledger l
left join steam_game sg on sg.appid::text = l.ref and l.kind in (:gameKinds)
where l.user_id = :userId
order by l.created_at desc, l.id desc
limit :limit
""", new MapSqlParameterSource("userId", userId).addValue("limit", limit)
.addValue("gameKinds", names(RewardKind.GAME_KINDS)),
(rs, i) -> new HistoryEntry(RewardKind.valueOf(rs.getString("kind")), rs.getString("ref"),
rs.getString("name"), rs.getInt("points"), rs.getTimestamp("event_at").toInstant()));
}
/**
* The codes of the items the user bought.
*/
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));
}
/**
* Rows with an event_at in [from, to).
*/
public int countKindInRange(long userId, Collection<RewardKind> kinds, Instant from, Instant to) {
Integer count = jdbc.queryForObject("""
select count(*) from reward_ledger
where user_id = :userId and kind in (:kinds) and event_at >= :from and event_at < :to
""", new MapSqlParameterSource("userId", userId).addValue("kinds", names(kinds))
.addValue("from", Timestamp.from(from)).addValue("to", Timestamp.from(to)), Integer.class);
return count != null ? count : 0;
}
public List<Instant> eventsOfKinds(long userId, Collection<RewardKind> kinds, Instant since) {
return jdbc.query("""
select event_at from reward_ledger
where user_id = :userId and kind in (:kinds) and event_at >= :since order by event_at
""", new MapSqlParameterSource("userId", userId).addValue("kinds", names(kinds))
.addValue("since", Timestamp.from(since)), (rs, i) -> rs.getTimestamp("event_at").toInstant());
}
public Map<LookSlot, String> equipped(long userId) {
Map<LookSlot, String> result = new EnumMap<>(LookSlot.class);
jdbc.query("select slot, item_code from reward_equipped where user_id = :userId",
new MapSqlParameterSource("userId", userId),
(RowCallbackHandler) rs -> result.put(LookSlot.valueOf(rs.getString("slot")), rs.getString("item_code")));
return result;
}
public void equip(long userId, LookSlot slot, String code) {
jdbc.update("""
insert into reward_equipped (user_id, slot, item_code) values (:userId, :slot, :code)
on conflict (user_id, slot) do update set item_code = excluded.item_code
""", new MapSqlParameterSource("userId", userId).addValue("slot", slot.name()).addValue("code", code));
}
public void unequip(long userId, LookSlot slot) {
jdbc.update("delete from reward_equipped where user_id = :userId and slot = :slot",
new MapSqlParameterSource("userId", userId).addValue("slot", slot.name()));
}
}
@@ -0,0 +1,170 @@
package core_service.modules.rewards.service;
import core_service.modules.rewards.domain.FinishFacts;
import core_service.modules.rewards.domain.HistoryEntry;
import core_service.modules.rewards.domain.LookSlot;
import core_service.modules.rewards.domain.Looks;
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.repo.RewardFactsRepository;
import core_service.modules.rewards.repo.RewardLedgerRepository;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.DayOfWeek;
import java.time.Instant;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.ZoneId;
import java.time.temporal.TemporalAdjusters;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Pile Points. No code path pays points: a settle derives the rows from the data the library already keeps
* (status log, playtime days, achievements, pile snapshots) and inserts them once per (user, kind, ref).
*/
@Slf4j
@Service
@RequiresRole(Role.USER)
@RequiredArgsConstructor
public class RewardService {
private static final int HISTORY_SIZE = 50;
private final RewardLedgerRepository ledger;
private final RewardFactsRepository facts;
private final TransactionTemplate transactionTemplate;
@Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone;
/**
* @param owned the codes the user may wear: bought, or all for a platform admin
* @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) {}
// 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);
return new Wallet(ledger.balance(userId), owned, Looks.wearable(ledger.equipped(userId), owned),
ledger.history(userId, HISTORY_SIZE));
}
/**
* Every active user, each in its own transaction; one failure does not stop the others. Runs after the nightly
* sync and the image repair, so month and streak bonuses also reach users who do not open the app.
*/
@Scheduled(cron = "${app.rewards.settle-cron:0 30 2 * * *}", zone = "${app.library.zone:Europe/Berlin}")
public void settleAll() {
List<Long> userIds = facts.activeUserIds();
log.info("Nightly reward settle for {} users", userIds.size());
for (long userId : userIds) {
try {
transactionTemplate.executeWithoutResult(status -> settleLocked(userId));
} catch (Exception e) {
log.error("Reward settle of user {} failed", userId, e);
}
}
}
private void settleLocked(long userId) {
if (!facts.isActive(userId)) {
return;
}
ledger.lockUser(userId);
int paid = settleStarts(userId) + settleFinishes(userId) + settleCompleted(userId)
+ settleMonths(userId) + settleStreaks(userId);
if (paid > 0) {
log.info("Reward settle of user {} booked {} rows", userId, paid);
}
}
private int book(long userId, RewardEntry entry) {
return ledger.add(userId, entry) ? 1 : 0;
}
private int settleStarts(long userId) {
return facts.startCandidates(userId, zone).stream()
.flatMap(f -> RewardRules.start(f).stream()).mapToInt(e -> book(userId, e)).sum();
}
private int settleFinishes(long userId) {
int paid = 0;
for (FinishFacts f : facts.finishCandidates(userId, zone)) {
RewardEntry entry = RewardRules.finish(f).orElse(null);
if (entry == null) {
continue;
}
if (entry.kind() == RewardKind.FINISHED_MANUAL) {
LocalDate monday = weekStart(entry.eventAt());
// The rows booked earlier in this run are in the count already.
int used = ledger.countKindInRange(userId, List.of(RewardKind.FINISHED_MANUAL),
startOf(monday), startOf(monday.plusWeeks(1)));
if (used >= RewardRules.MANUAL_PER_WEEK) {
continue;
}
}
paid += book(userId, entry);
}
return paid;
}
private int settleCompleted(long userId) {
return facts.completedCandidates(userId).stream()
.flatMap(f -> RewardRules.completed(f).stream()).mapToInt(e -> book(userId, e)).sum();
}
// Only full months: the running month is not over.
private int settleMonths(long userId) {
int paid = 0;
for (int back = 1; back <= RewardRules.MONTHS_BACK; back++) {
YearMonth month = YearMonth.now(zone).minusMonths(back);
Instant from = startOf(month.atDay(1));
Instant to = startOf(month.plusMonths(1).atDay(1));
boolean smaller = facts.pileFirstAndLast(userId, month).filter(pile -> pile[1] < pile[0]).isPresent();
// Hiding games alone shrinks the pile: a month pays only with a finished game in it.
if (smaller && ledger.countKindInRange(userId, RewardKind.FINISH_KINDS, from, to) > 0) {
paid += book(userId, new RewardEntry(RewardKind.MONTH, RewardRules.monthRef(month),
RewardRules.MONTH_BONUS, to));
}
}
return paid;
}
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)));
int paid = 0;
for (LocalDate monday : weeks) {
if (weeks.contains(monday.minusWeeks(1))) {
paid += book(userId, new RewardEntry(RewardKind.STREAK, RewardRules.weekRef(monday),
RewardRules.STREAK_BONUS, startOf(monday)));
}
}
return paid;
}
private LocalDate weekStart(Instant at) {
return at.atZone(zone).toLocalDate().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
}
private Instant startOf(LocalDate day) {
return day.atStartOfDay(zone).toInstant();
}
}
+3
View File
@@ -67,6 +67,9 @@ app:
image-repair-batch: 200
image-repair-max-attempts: 3
image-repair-retry-days: 30
rewards:
# Pile Points of every user, after the nightly sync (23:55) and the image repair (01:30).
settle-cron: "0 30 2 * * *"
account-cleanup:
# Log only, delete nothing. Auth's nightly cleanup drives this; see the Deployment README before flipping it.
dry-run: ${APP_ACCOUNT_CLEANUP_DRY_RUN:true}
@@ -0,0 +1,29 @@
-- Pile Points (ROADMAP item 17): every earn and spend is a row, the balance is the sum, never one stored number.
-- (user_id, kind, ref) is the "once per game and kind, ever" rule; ref is the appid, a month (2026-10), an ISO
-- week (2026-W41), an item code (BUY) or STARTER. event_at is when the game event happened (the status change),
-- created_at when the row was written; a later settle still books the event in its own week and month.
CREATE TABLE reward_ledger (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
kind VARCHAR(16) NOT NULL,
ref VARCHAR(32) NOT NULL,
points INTEGER NOT NULL,
event_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, kind, ref)
);
CREATE TABLE reward_equipped (
user_id BIGINT NOT NULL,
slot VARCHAR(16) NOT NULL,
item_code VARCHAR(32) NOT NULL,
PRIMARY KEY (user_id, slot)
);
-- Starter grant: finishes from before the status log (V14) can never be paid by the rules; 20 per game, cap 250.
INSERT INTO reward_ledger (user_id, kind, ref, points, event_at)
SELECT ug.user_id, 'STARTER', 'STARTER', LEAST(250, 20 * COUNT(*)), NOW()
FROM listed_user_game ug
WHERE ug.status IN ('FINISHED', 'COMPLETED') AND ug.playtime_minutes > 0
AND ug.user_id NOT IN (SELECT user_id FROM deleted_user)
AND NOT EXISTS (SELECT 1 FROM user_game_status_change c WHERE c.user_id = ug.user_id AND c.appid = ug.appid
AND c.to_status IN ('FINISHED', 'COMPLETED'))
GROUP BY ug.user_id;
@@ -17,7 +17,7 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
private static final List<String> USER_TABLES = List.of("user_game_status_change", "user_game_achievement",
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
"user_steam_sync", "user_game", "game_import");
"user_steam_sync", "user_game", "game_import", "reward_ledger", "reward_equipped");
@Autowired
private AccountCleanupRepository repository;
@@ -43,6 +43,8 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
insert into user_steam_sync (user_id, steam_id, user_seen_at) values (?, '76561198000000001', now())""",
userId);
jdbc.update("insert into game_import (user_id, source, status, total) values (?, 'TEXT', 'SAVED', 0)", userId);
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'STARTER', 'STARTER', 20, now())", userId);
jdbc.update("insert into reward_equipped (user_id, slot, item_code) values (?, 'BADGE', 'BADGE_PILE_SLAYER')", userId);
}
}
@@ -79,6 +79,7 @@ class GameMergeFlowTest extends AbstractIntegrationTest {
void seed() {
jdbc.update("delete from game_alias");
jdbc.update("delete from admin_audit");
jdbc.update("delete from reward_ledger where user_id in (?, ?)", AUDITOR_ID, OTHER_USER);
gameCorrectionRepository.deleteAll();
userGameRepository.deleteAll();
gameMoodsRepository.deleteAll();
@@ -160,6 +161,22 @@ class GameMergeFlowTest extends AbstractIntegrationTest {
+ " \"Hades (Switch)\" into \"Hades\": 1 moved, 0 combined");
}
@Test
void paidGameRows_MoveToTheKeptKey_UnlessTheUserHoldsTheKindThereAlready() throws Exception {
manualRow(AUDITOR_ID, MANUAL, GameStatus.FINISHED);
for (Object[] row : List.of(new Object[]{"FINISHED_MANUAL", MANUAL}, new Object[]{"STARTED", MANUAL},
new Object[]{"STARTED", STEAM}, new Object[]{"MONTH", "2026-09"})) {
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, ?, ?, 5, now())",
AUDITOR_ID, row[0], String.valueOf(row[1]));
}
merge(Role.AUDITOR, MANUAL, STEAM).andExpect(status().isOk());
assertThat(jdbc.queryForList("select kind || ' ' || ref from reward_ledger where user_id = ? ",
String.class, AUDITOR_ID)).containsExactlyInAnyOrder("FINISHED_MANUAL " + STEAM, "MONTH 2026-09",
"STARTED " + MANUAL, "STARTED " + STEAM);
}
@Test
void userWithBothRows_KeepsTheSteamRowWithTheNewerHandSetFields() throws Exception {
UserGame steam = new UserGame(AUDITOR_ID, STEAM, 300, Instant.now(), Instant.now());
@@ -0,0 +1,140 @@
package core_service.modules.rewards.api;
import core_service.modules.rewards.service.RewardService;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MockMvc;
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.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The dev profile authenticates every request as user 1.
*/
@AutoConfigureMockMvc
class RewardControllerTest extends AbstractIntegrationTest {
private static final long USER = 1L;
private static final long GAME = 4_100_001L;
@Autowired
private MockMvc mockMvc;
@Autowired
private RewardService rewardService;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void clean() {
jdbc.update("delete from reward_ledger where user_id = ?", USER);
jdbc.update("delete from reward_equipped 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 = ?", USER);
}
private void game(long appid, String source, String status, int playtime) {
jdbc.update("insert into steam_game (appid, name) values (?, ?) on conflict do nothing", appid, "Game " + appid);
jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes, source) values (?, ?, ?, ?, ?)",
USER, appid, status, playtime, source);
}
private void setStatus(long appid, String status) {
jdbc.update("update user_game set status = ? where user_id = ? and appid = ?", status, USER, appid);
}
private int rows(String kind) {
Integer count = jdbc.queryForObject("select count(*) from reward_ledger where user_id = ? and kind = ?",
Integer.class, USER, kind);
return count != null ? count : 0;
}
private void wallet() throws Exception {
mockMvc.perform(get("/api/rewards/wallet")).andExpect(status().isOk());
}
@Test
void wallet_SteamFinishWithPlaytimeProof_PaysOnceEvenAfterBacklogAndFinishedAgain() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, current_date, ?, 30)",
USER, GAME);
setStatus(GAME, "FINISHED");
mockMvc.perform(get("/api/rewards/wallet"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.balance").value(30))
.andExpect(jsonPath("$.history[0].kind").value("FINISHED"))
.andExpect(jsonPath("$.history[0].gameName").value("Game " + GAME))
.andExpect(jsonPath("$.catalog.length()").value(8))
.andExpect(jsonPath("$.owned").isEmpty());
setStatus(GAME, "BACKLOG");
setStatus(GAME, "FINISHED");
wallet();
assertThat(rows("FINISHED")).isEqualTo(1);
}
@Test
void wallet_HandSetFinishedWithoutPlaytime_PaysNothingEvenWithTheEndingProof() throws Exception {
game(GAME, "STEAM", "BACKLOG", 0);
jdbc.update("update user_game set finished_at_estimate = now() where user_id = ?", USER);
setStatus(GAME, "FINISHED");
mockMvc.perform(get("/api/rewards/wallet")).andExpect(jsonPath("$.balance").value(0));
assertThat(rows("FINISHED")).isZero();
}
@Test
void wallet_FourManualFinishesInOneWeek_PayThree() throws Exception {
for (long appid = -4_100_010L; appid > -4_100_014L; appid--) {
game(appid, "MANUAL", "BACKLOG", 0);
setStatus(appid, "FINISHED");
}
mockMvc.perform(get("/api/rewards/wallet")).andExpect(jsonPath("$.balance").value(45));
assertThat(rows("FINISHED_MANUAL")).isEqualTo(3);
}
@Test
void wallet_ManualGameInTheHistoryHasItsName() throws Exception {
game(-4_100_020L, "MANUAL", "BACKLOG", 0);
setStatus(-4_100_020L, "FINISHED");
mockMvc.perform(get("/api/rewards/wallet"))
.andExpect(jsonPath("$.history[0].kind").value("FINISHED_MANUAL"))
.andExpect(jsonPath("$.history[0].gameName").value("Game -4100020"));
}
@Test
void settleAll_RunTwice_WritesTheSameRows() {
game(GAME, "STEAM", "BACKLOG", 120);
setStatus(GAME, "PLAYING");
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, current_date, ?, 30)",
USER, GAME);
setStatus(GAME, "FINISHED");
rewardService.settleAll();
long first = ledgerSum();
rewardService.settleAll();
assertThat(first).isEqualTo(35);
assertThat(ledgerSum()).isEqualTo(first);
assertThat(rows("STARTED")).isEqualTo(1);
assertThat(rows("FINISHED")).isEqualTo(1);
}
private long ledgerSum() {
Long sum = jdbc.queryForObject("select coalesce(sum(points), 0) from reward_ledger where user_id = ?", Long.class, USER);
return sum != null ? sum : 0;
}
}
@@ -0,0 +1,33 @@
package core_service.modules.rewards.domain;
import core_service.shared.security.Role;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class LooksTest extends AbstractUnitTest {
@Test
void owned_AdminOwnsTheWholeCatalog() {
assertThat(Looks.owned(Set.of(), Role.PLATFORM_ADMIN)).isEqualTo(ShopItem.codes());
}
@Test
void owned_UserOwnsOnlyWhatIsBought() {
assertThat(Looks.owned(Set.of("BORDER_GOLD"), Role.USER)).containsExactly("BORDER_GOLD");
assertThat(Looks.owned(Set.of(), Role.AUDITOR)).isEmpty();
}
@Test
void wearable_DropsLooksThatAreNotOwnedOrNotInTheCatalog() {
Map<LookSlot, String> equipped = Map.of(LookSlot.AVATAR_BORDER, "BORDER_GOLD", LookSlot.BADGE, "BADGE_PILE_SLAYER",
LookSlot.ACCENT, "ACCENT_NEON_PINK");
assertThat(Looks.wearable(equipped, Set.of("BORDER_GOLD", "ACCENT_NEON_PINK")))
.containsOnly(Map.entry(LookSlot.AVATAR_BORDER, "BORDER_GOLD"));
}
}
@@ -0,0 +1,92 @@
package core_service.modules.rewards.domain;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
class RewardRulesTest extends AbstractUnitTest {
private static final Instant AT = Instant.parse("2026-10-01T10:00:00Z");
private static FinishFacts steam(int playtime, Integer normallyMinutes, boolean ending, boolean around) {
return new FinishFacts(220L, false, false, playtime, playtime, normallyMinutes, ending, around, false, AT);
}
private static int points(FinishFacts facts) {
return RewardRules.finish(facts).map(RewardEntry::points).orElse(0);
}
@Test
void finish_PaysByTheLengthOfTheGame() {
assertThat(points(steam(60, null, true, false))).isEqualTo(30);
assertThat(points(steam(60, 9 * 60 + 59, true, false))).isEqualTo(30);
assertThat(points(steam(60, 10 * 60, true, false))).isEqualTo(50);
assertThat(points(steam(60, 30 * 60, true, false))).isEqualTo(50);
assertThat(points(steam(60, 31 * 60, true, false))).isEqualTo(80);
}
@Test
void finish_WithoutPlaytime_PaysNothingEvenWithTheEndingProof() {
assertThat(RewardRules.finish(steam(0, 600, true, true))).isEmpty();
}
@Test
void finish_NeedsOneProof() {
assertThat(points(steam(60, 300, false, false))).isZero();
assertThat(points(steam(60, 300, true, false))).isEqualTo(30);
assertThat(points(steam(60, 300, false, true))).isEqualTo(30);
}
@Test
void finish_HalfOfTheNormalTimeIsAProof() {
assertThat(points(steam(149, 300, false, false))).isZero();
assertThat(points(steam(150, 300, false, false))).isEqualTo(30);
assertThat(points(steam(150, null, false, false))).isZero();
}
@Test
void finish_ShareProofCountsOnlyPlaytimeSinceTheFinish() {
FinishFacts oldPlay = new FinishFacts(220L, false, false, 500, 100, 300, false, false, false, AT);
assertThat(RewardRules.finish(oldPlay)).isEmpty();
}
@Test
void finish_ManualGame_PaysTheSmallPriceWithoutProof() {
FinishFacts manual = new FinishFacts(-5L, true, false, 0, 0, null, false, false, false, AT);
assertThat(RewardRules.finish(manual)).contains(new RewardEntry(RewardKind.FINISHED_MANUAL, "-5", 15, AT));
}
@Test
void completed_NeedsAllAchievementsAndASteamGame() {
FinishFacts all = new FinishFacts(220L, false, true, 60, 60, null, true, true, true, AT);
assertThat(RewardRules.completed(all)).contains(new RewardEntry(RewardKind.COMPLETED, "220", 40, AT));
assertThat(RewardRules.completed(new FinishFacts(220L, false, true, 60, 60, null, true, true, false, AT))).isEmpty();
assertThat(RewardRules.completed(new FinishFacts(220L, false, false, 60, 60, null, true, true, true, AT))).isEmpty();
assertThat(RewardRules.completed(new FinishFacts(220L, true, true, 60, 60, null, true, true, true, AT))).isEmpty();
}
@Test
void start_NeedsASteamGameWithPlaytimeAfterTheStart() {
Optional<RewardEntry> paid = RewardRules.start(new StartFacts(220L, false, AT, true));
assertThat(paid).contains(new RewardEntry(RewardKind.STARTED, "220", 5, AT));
assertThat(RewardRules.start(new StartFacts(220L, false, AT, false))).isEmpty();
assertThat(RewardRules.start(new StartFacts(-5L, true, AT, true))).isEmpty();
}
@Test
void refs_UseTheIsoWeekAndTheMonth() {
assertThat(RewardRules.monthRef(YearMonth.of(2026, 10))).isEqualTo("2026-10");
assertThat(RewardRules.weekRef(LocalDate.of(2026, 10, 8))).isEqualTo("2026-W41");
assertThat(RewardRules.weekRef(LocalDate.of(2027, 1, 1))).isEqualTo("2026-W53");
}
}
@@ -0,0 +1,234 @@
package core_service.modules.rewards.repo;
import core_service.modules.rewards.domain.FinishFacts;
import core_service.modules.rewards.domain.RewardEntry;
import core_service.modules.rewards.domain.RewardKind;
import core_service.modules.rewards.domain.StartFacts;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import java.time.YearMonth;
import java.time.ZoneId;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@Import({RewardFactsRepository.class, RewardLedgerRepository.class})
class RewardFactsRepositoryTest extends AbstractPersistenceTest {
private static final long USER = 7L;
private static final long GAME = 220L;
private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin");
@Autowired
private RewardFactsRepository repository;
@Autowired
private RewardLedgerRepository ledger;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
jdbc.update("delete from reward_ledger");
jdbc.update("delete from backlog_snapshot");
jdbc.update("insert into steam_game (appid, name) values (?, 'Half-Life 2') on conflict do nothing", GAME);
jdbc.update("insert into game_time_to_beat (appid, normally_minutes, fetched_at) values (?, 600, now()) "
+ "on conflict do nothing", GAME);
}
private void game(String status, int playtime) {
jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes) values (?, ?, ?, ?)",
USER, GAME, status, playtime);
}
private void setStatus(String status) {
jdbc.update("update user_game set status = ? where user_id = ? and appid = ?", status, USER, GAME);
}
// The trigger stamps every change with the transaction start; this moves them apart.
private void changedAt(String toStatus, String timestamp) {
jdbc.update("update user_game_status_change set changed_at = ?::timestamptz where user_id = ? and appid = ? "
+ "and to_status = ?", timestamp, USER, GAME, toStatus);
}
private void playedOn(String day) {
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, ?::date, ?, 30)",
USER, day, GAME);
}
@Test
void finishCandidates_AppearsAfterTheChangeWithItsFacts() {
game("BACKLOG", 120);
setStatus("FINISHED");
changedAt("FINISHED", "2026-09-15T10:00:00Z");
playedOn("2026-08-20");
jdbc.update("update user_game set finished_at_estimate = now() where user_id = ?", USER);
List<FinishFacts> candidates = repository.finishCandidates(USER, BERLIN);
assertThat(candidates).singleElement().satisfies(f -> {
assertThat(f.appid()).isEqualTo(GAME);
assertThat(f.manual()).isFalse();
assertThat(f.completed()).isFalse();
assertThat(f.playtimeMinutes()).isEqualTo(120);
assertThat(f.normallyMinutes()).isEqualTo(600);
assertThat(f.endingUnlocked()).isTrue();
assertThat(f.playedAround()).isTrue();
assertThat(f.allAchievements()).isFalse();
assertThat(f.finishedAt()).isEqualTo(Instant.parse("2026-09-15T10:00:00Z"));
});
}
@Test
void finishCandidates_PlaytimeMoreThan30DaysBeforeIsNoProof() {
game("BACKLOG", 120);
setStatus("FINISHED");
changedAt("FINISHED", "2026-09-15T10:00:00Z");
playedOn("2026-08-10");
assertThat(repository.finishCandidates(USER, BERLIN)).singleElement()
.satisfies(f -> assertThat(f.playedAround()).isFalse());
}
@Test
void finishCandidates_AllAchievementsNeedTheSchemaTotalAndTheUnlockCount() {
game("BACKLOG", 120);
setStatus("COMPLETED");
jdbc.update("insert into game_achievement_schema_meta (appid, achievements_total, has_schema) values (?, 10, true)", GAME);
jdbc.update("insert into user_game_achievement_meta (user_id, appid, achievements_unlocked) values (?, ?, 10)", USER, GAME);
assertThat(repository.finishCandidates(USER, BERLIN)).singleElement().satisfies(f -> {
assertThat(f.completed()).isTrue();
assertThat(f.allAchievements()).isTrue();
});
}
@Test
void finishCandidates_GoneOnceAFinishedRowExistsOrTheGameWentBackToBacklog() {
game("BACKLOG", 120);
setStatus("FINISHED");
assertThat(repository.finishCandidates(USER, BERLIN)).hasSize(1);
setStatus("BACKLOG");
assertThat(repository.finishCandidates(USER, BERLIN)).isEmpty();
setStatus("FINISHED");
ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(GAME), 30, Instant.now()));
assertThat(repository.finishCandidates(USER, BERLIN)).isEmpty();
}
@Test
void finishCandidates_HiddenGameIsNoCandidate() {
game("BACKLOG", 120);
setStatus("FINISHED");
jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = ?", USER, GAME);
assertThat(repository.finishCandidates(USER, BERLIN)).isEmpty();
}
@Test
void startAndCompletedCandidates_HiddenGameIsNoCandidate() {
game("BACKLOG", 60);
setStatus("PLAYING");
assertThat(repository.startCandidates(USER, BERLIN)).hasSize(1);
setStatus("COMPLETED");
ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(GAME), 30, Instant.now()));
assertThat(repository.completedCandidates(USER)).hasSize(1);
jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = ?", USER, GAME);
assertThat(repository.startCandidates(USER, BERLIN)).isEmpty();
assertThat(repository.completedCandidates(USER)).isEmpty();
}
@Test
void finishCandidates_EndingUnlockedBeforeTheFinishWindowIsNoProof() {
game("BACKLOG", 120);
setStatus("FINISHED");
changedAt("FINISHED", "2026-09-15T10:00:00Z");
jdbc.update("update user_game set finished_at_estimate = '2015-01-01T00:00:00Z' where user_id = ?", USER);
assertThat(repository.finishCandidates(USER, BERLIN)).singleElement()
.satisfies(f -> assertThat(f.endingUnlocked()).isFalse());
jdbc.update("update user_game set finished_at_estimate = '2026-08-20T00:00:00Z' where user_id = ?", USER);
assertThat(repository.finishCandidates(USER, BERLIN)).singleElement()
.satisfies(f -> assertThat(f.endingUnlocked()).isTrue());
}
@Test
void finishCandidates_PlayedSinceCountsOnlyDaysFromTheFinishDay() {
game("BACKLOG", 500);
setStatus("FINISHED");
changedAt("FINISHED", "2026-09-15T10:00:00Z");
playedOn("2026-09-14");
playedOn("2026-09-15");
playedOn("2026-09-20");
assertThat(repository.finishCandidates(USER, BERLIN)).singleElement()
.satisfies(f -> assertThat(f.playedSinceMinutes()).isEqualTo(60));
}
@Test
void startCandidates_PlayedAfterTheStartDayAndGoneOncePaid() {
game("BACKLOG", 60);
setStatus("PLAYING");
changedAt("PLAYING", "2026-09-15T10:00:00Z");
assertThat(repository.startCandidates(USER, BERLIN)).singleElement()
.extracting(StartFacts::playedAfter).isEqualTo(false);
playedOn("2026-09-15");
assertThat(repository.startCandidates(USER, BERLIN)).singleElement()
.extracting(StartFacts::playedAfter).isEqualTo(true);
ledger.add(USER, new RewardEntry(RewardKind.STARTED, String.valueOf(GAME), 5, Instant.now()));
assertThat(repository.startCandidates(USER, BERLIN)).isEmpty();
}
@Test
void completedCandidates_NeedACompletedGameWithAPaidFinish() {
game("BACKLOG", 120);
setStatus("COMPLETED");
assertThat(repository.completedCandidates(USER)).isEmpty();
Instant finishedAt = Instant.parse("2026-09-15T10:00:00Z");
ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(GAME), 30, finishedAt));
assertThat(repository.completedCandidates(USER)).singleElement()
.extracting(FinishFacts::finishedAt).isEqualTo(finishedAt);
ledger.add(USER, new RewardEntry(RewardKind.COMPLETED, String.valueOf(GAME), 40, finishedAt));
assertThat(repository.completedCandidates(USER)).isEmpty();
}
@Test
void pileFirstAndLast_NeedsBothDays() {
YearMonth month = YearMonth.of(2026, 9);
jdbc.update("insert into backlog_snapshot values (?, '2026-09-01', 10, 0, 0, 10)", USER);
assertThat(repository.pileFirstAndLast(USER, month)).isEmpty();
jdbc.update("insert into backlog_snapshot values (?, '2026-09-30', 8, 0, 0, 10)", USER);
assertThat(repository.pileFirstAndLast(USER, month)).hasValueSatisfying(pile -> assertThat(pile).containsExactly(10, 8));
}
@Test
void isActive_FalseForDeletedAndBlockedUsers() {
game("BACKLOG", 0);
jdbc.update("insert into deleted_user (user_id) values (8)");
jdbc.update("insert into blocked_user (user_id) values (9)");
jdbc.update("insert into user_game (user_id, appid, status) values (8, ?, 'BACKLOG'), (9, ?, 'BACKLOG')", GAME, GAME);
assertThat(repository.isActive(USER)).isTrue();
assertThat(repository.isActive(8L)).isFalse();
assertThat(repository.isActive(9L)).isFalse();
assertThat(repository.activeUserIds()).contains(USER).doesNotContain(8L, 9L);
}
}
@@ -0,0 +1,102 @@
package core_service.modules.rewards.repo;
import core_service.modules.rewards.domain.HistoryEntry;
import core_service.modules.rewards.domain.LookSlot;
import core_service.modules.rewards.domain.RewardEntry;
import core_service.modules.rewards.domain.RewardKind;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.tuple;
@Import(RewardLedgerRepository.class)
class RewardLedgerRepositoryTest extends AbstractPersistenceTest {
private static final long USER = 7L;
private static final Instant JAN_1 = Instant.parse("2026-01-01T12:00:00Z");
@Autowired
private RewardLedgerRepository repository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
// V26 paid the starter grant of the migration day; the tests start empty.
jdbc.update("delete from reward_ledger");
jdbc.update("insert into steam_game (appid, name) values (220, 'Half-Life 2') on conflict do nothing");
jdbc.update("insert into steam_game (appid, name) values (-1942, 'Zelda') on conflict do nothing");
}
@Test
void add_TheSameKindAndRefTwice_WritesOneRow() {
assertThat(repository.add(USER, new RewardEntry(RewardKind.FINISHED, "220", 30, JAN_1))).isTrue();
assertThat(repository.add(USER, new RewardEntry(RewardKind.FINISHED, "220", 80, JAN_1))).isFalse();
assertThat(repository.balance(USER)).isEqualTo(30);
assertThat(repository.add(USER, new RewardEntry(RewardKind.STARTED, "220", 5, JAN_1))).isTrue();
}
@Test
void balance_IsTheSumIncludingSpends() {
repository.add(USER, new RewardEntry(RewardKind.FINISHED, "220", 80, JAN_1));
repository.add(USER, new RewardEntry(RewardKind.BUY, "BORDER_GOLD", -50, JAN_1));
repository.add(USER + 1, new RewardEntry(RewardKind.FINISHED, "220", 30, JAN_1));
assertThat(repository.balance(USER)).isEqualTo(30);
assertThat(repository.balance(99L)).isZero();
assertThat(repository.bought(USER)).containsExactly("BORDER_GOLD");
}
@Test
void countKindInRange_CountsByEventTimeFromInclusiveToExclusive() {
repository.add(USER, new RewardEntry(RewardKind.FINISHED_MANUAL, "-1", 15, JAN_1));
repository.add(USER, new RewardEntry(RewardKind.FINISHED_MANUAL, "-2", 15, JAN_1.plusSeconds(60)));
repository.add(USER, new RewardEntry(RewardKind.FINISHED, "220", 30, JAN_1));
List<RewardKind> manual = List.of(RewardKind.FINISHED_MANUAL);
assertThat(repository.countKindInRange(USER, manual, JAN_1, JAN_1.plusSeconds(60))).isEqualTo(1);
assertThat(repository.countKindInRange(USER, manual, JAN_1, JAN_1.plusSeconds(61))).isEqualTo(2);
assertThat(repository.countKindInRange(USER, RewardKind.FINISH_KINDS, JAN_1, JAN_1.plusSeconds(61))).isEqualTo(3);
assertThat(repository.eventsOfKinds(USER, manual, JAN_1.plusSeconds(1))).containsExactly(JAN_1.plusSeconds(60));
}
@Test
void history_CarriesTheGameNameOfSteamAndManualGamesAndNullForOtherRows() {
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.MONTH, "2026-09", 40, JAN_1));
List<HistoryEntry> history = repository.history(USER, 10);
assertThat(history).extracting(HistoryEntry::kind, HistoryEntry::gameName).containsExactlyInAnyOrder(
tuple(RewardKind.FINISHED, "Half-Life 2"),
tuple(RewardKind.FINISHED_MANUAL, "Zelda"),
tuple(RewardKind.MONTH, null));
assertThat(repository.history(USER, 2)).hasSize(2);
}
@Test
void equip_ReplacesTheItemOfTheSlotAndUnequipRemovesIt() {
repository.equip(USER, LookSlot.BADGE, "BADGE_PILE_SLAYER");
repository.equip(USER, LookSlot.BADGE, "BADGE_COMPLETIONIST");
repository.equip(USER, LookSlot.ACCENT, "ACCENT_GOLD");
assertThat(repository.equipped(USER)).containsOnly(
Map.entry(LookSlot.BADGE, "BADGE_COMPLETIONIST"), Map.entry(LookSlot.ACCENT, "ACCENT_GOLD"));
repository.unequip(USER, LookSlot.BADGE);
assertThat(repository.equipped(USER)).containsOnlyKeys(LookSlot.ACCENT);
}
}
@@ -0,0 +1,71 @@
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.Date;
import java.time.YearMonth;
import java.time.ZoneId;
import static org.assertj.core.api.Assertions.assertThat;
class RewardServiceMonthTest extends AbstractIntegrationTest {
private static final long USER = 13L;
private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin");
@Autowired
private RewardService rewardService;
@Autowired
private JdbcTemplate jdbc;
private final YearMonth month = YearMonth.now(BERLIN).minusMonths(1);
@BeforeEach
@AfterEach
void clean() {
jdbc.update("delete from reward_ledger where user_id = ?", USER);
jdbc.update("delete from backlog_snapshot where user_id = ?", USER);
}
private void smallerPile() {
jdbc.update("insert into backlog_snapshot values (?, ?, 10, 0, 0, 10)", USER, Date.valueOf(month.atDay(1)));
jdbc.update("insert into backlog_snapshot values (?, ?, 8, 0, 0, 10)", USER, Date.valueOf(month.atEndOfMonth()));
}
private void paid(String kind) {
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, ?, '1', 5, ?)",
USER, kind, java.sql.Timestamp.from(month.atDay(10).atStartOfDay(BERLIN).toInstant()));
}
private int monthRows() {
return jdbc.queryForObject("select count(*) from reward_ledger where user_id = ? and kind = 'MONTH'",
Integer.class, USER);
}
@Test
void wallet_MonthBonusNeedsAFinishedGameInTheMonth_AStartAloneIsNotEnough() {
smallerPile();
paid("STARTED");
rewardService.wallet(USER, Role.USER);
assertThat(monthRows()).isZero();
}
@Test
void wallet_SmallerPileAndAFinishedGame_PaysTheMonthBonus() {
smallerPile();
paid("FINISHED_MANUAL");
rewardService.wallet(USER, Role.USER);
assertThat(monthRows()).isEqualTo(1);
}
}
@@ -29,7 +29,7 @@ import java.util.concurrent.atomic.AtomicReference;
// import watchdog runs only when a test calls it.
// Every cached context keeps its own pool: small pools keep them all below Postgres' 100 clients.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {"app.library.nightly-sync-cron=-", "app.library.image-repair-cron=-", "app.speedrun.enabled=false",
properties = {"app.library.nightly-sync-cron=-", "app.library.image-repair-cron=-", "app.rewards.settle-cron=-", "app.speedrun.enabled=false",
"app.library.import-watchdog-ms=86400000",
"spring.datasource.hikari.maximum-pool-size=4"})
@ActiveProfiles("dev")