diff --git a/readme.md b/readme.md index e2d95b9..445fcb4 100644 --- a/readme.md +++ b/readme.md @@ -57,6 +57,13 @@ To Be Played is split into three repositories: - **Status history**: a database trigger logs every status change of a library game in `user_game_status_change` (old and new status, time, whether the user chose it), whatever code path made it. It starts with V14; older changes are not known. +- **Pile Points** (`modules/rewards`): users earn points for playing games off the pile, in an append-only ledger + (`reward_ledger`, V26; the balance is the sum). No code path pays: a settle (on `GET /api/rewards/wallet` and + nightly at 02:30, `app.rewards.settle-cron`) derives rows from the status log, playtime days, achievements and pile + snapshots, once per user, kind and game (`ref`). Rules and numbers: `RewardRules`; a Steam finish needs playtime and one + proof of fresh play (ending achievement or playtime around the finish, or half of the IGDB time played since the finish; old history pays nothing). The month bonus needs a finished game in that month. Games added by hand pay 15 for + a finish, at most 3 per ISO week. The settle takes a per-user advisory lock. V26 also pays a one-time starter grant + for finishes from before the status log. - **Wishlist**: synced from Steam with store price and discount. - **Broken image repair**: the frontend reports a Steam cover or achievement icon that does not load. At 01:30 (Europe/Berlin) a job checks up to 200 reports. A cover that loads again is left alone; else the store's hashed @@ -114,6 +121,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut | Method | Path | Auth | |---|---|---| +| `GET` | `/api/rewards/wallet` (balance, catalog, owned, equipped, last 50 ledger rows with the game name) | JWT | | `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT | | `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT | | `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR | @@ -165,6 +173,7 @@ flowchart LR Stats["stats"] Version["version"] Account["account"] + Rewards["rewards"] Steam["steam_adapter"] Igdb["igdb_adapter"] Speedrun["speedrun_adapter"] @@ -173,6 +182,7 @@ flowchart LR LibService --> Speedrun end Browser -->|"tbp_access cookie"| Security + Rewards --> Db Security -->|"JWKS by kid"| Auth["ToBePlayed-Auth"] Steam --> SteamApi["Steam Web API"] Steam -->|"HEAD"| SteamCdn["Steam CDN"] diff --git a/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java index e82d116..4aaa513 100644 --- a/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java +++ b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java @@ -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 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 ORPHAN_SOURCES = List.of("user_game", "user_steam_sync", "wishlist_game", diff --git a/src/main/java/core_service/modules/library/repo/GameMergeRepository.java b/src/main/java/core_service/modules/library/repo/GameMergeRepository.java index 250dc80..f06786b 100644 --- a/src/main/java/core_service/modules/library/repo/GameMergeRepository.java +++ b/src/main/java/core_service/modules/library/repo/GameMergeRepository.java @@ -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); } diff --git a/src/main/java/core_service/modules/rewards/api/RewardController.java b/src/main/java/core_service/modules/rewards/api/RewardController.java new file mode 100644 index 0000000..819e970 --- /dev/null +++ b/src/main/java/core_service/modules/rewards/api/RewardController.java @@ -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 getWallet(@AuthenticationPrincipal UserPrincipal principal) { + return ResponseEntity.ok(WalletDto.of(rewardService.wallet(principal.getIdAsLong(), principal.role()))); + } +} diff --git a/src/main/java/core_service/modules/rewards/api/dto/WalletDto.java b/src/main/java/core_service/modules/rewards/api/dto/WalletDto.java new file mode 100644 index 0000000..5b4c712 --- /dev/null +++ b/src/main/java/core_service/modules/rewards/api/dto/WalletDto.java @@ -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 catalog, Set owned, Map equipped, + List 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()); + } +} diff --git a/src/main/java/core_service/modules/rewards/domain/FinishFacts.java b/src/main/java/core_service/modules/rewards/domain/FinishFacts.java new file mode 100644 index 0000000..0c4460d --- /dev/null +++ b/src/main/java/core_service/modules/rewards/domain/FinishFacts.java @@ -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) {} diff --git a/src/main/java/core_service/modules/rewards/domain/HistoryEntry.java b/src/main/java/core_service/modules/rewards/domain/HistoryEntry.java new file mode 100644 index 0000000..d3f360a --- /dev/null +++ b/src/main/java/core_service/modules/rewards/domain/HistoryEntry.java @@ -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) {} diff --git a/src/main/java/core_service/modules/rewards/domain/LookSlot.java b/src/main/java/core_service/modules/rewards/domain/LookSlot.java new file mode 100644 index 0000000..7d23539 --- /dev/null +++ b/src/main/java/core_service/modules/rewards/domain/LookSlot.java @@ -0,0 +1,5 @@ +package core_service.modules.rewards.domain; + +public enum LookSlot { + AVATAR_BORDER, BADGE, ACCENT, BANNER_FRAME +} diff --git a/src/main/java/core_service/modules/rewards/domain/Looks.java b/src/main/java/core_service/modules/rewards/domain/Looks.java new file mode 100644 index 0000000..4f014e3 --- /dev/null +++ b/src/main/java/core_service/modules/rewards/domain/Looks.java @@ -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 owned(Set 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 wearable(Map equipped, Set owned) { + Map result = new EnumMap<>(LookSlot.class); + equipped.forEach((slot, code) -> { + if (owned.contains(code) && ShopItem.codes().contains(code)) { + result.put(slot, code); + } + }); + return result; + } +} diff --git a/src/main/java/core_service/modules/rewards/domain/RewardEntry.java b/src/main/java/core_service/modules/rewards/domain/RewardEntry.java new file mode 100644 index 0000000..48e0d90 --- /dev/null +++ b/src/main/java/core_service/modules/rewards/domain/RewardEntry.java @@ -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) {} diff --git a/src/main/java/core_service/modules/rewards/domain/RewardKind.java b/src/main/java/core_service/modules/rewards/domain/RewardKind.java new file mode 100644 index 0000000..57766d4 --- /dev/null +++ b/src/main/java/core_service/modules/rewards/domain/RewardKind.java @@ -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 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 FINISH_KINDS = List.of(FINISHED, FINISHED_MANUAL); +} diff --git a/src/main/java/core_service/modules/rewards/domain/RewardRules.java b/src/main/java/core_service/modules/rewards/domain/RewardRules.java new file mode 100644 index 0000000..eb85ecd --- /dev/null +++ b/src/main/java/core_service/modules/rewards/domain/RewardRules.java @@ -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 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 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 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)); + } +} diff --git a/src/main/java/core_service/modules/rewards/domain/ShopItem.java b/src/main/java/core_service/modules/rewards/domain/ShopItem.java new file mode 100644 index 0000000..880b94c --- /dev/null +++ b/src/main/java/core_service/modules/rewards/domain/ShopItem.java @@ -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 codes() { + return Arrays.stream(values()).map(Enum::name).collect(Collectors.toSet()); + } +} diff --git a/src/main/java/core_service/modules/rewards/domain/StartFacts.java b/src/main/java/core_service/modules/rewards/domain/StartFacts.java new file mode 100644 index 0000000..e6f2422 --- /dev/null +++ b/src/main/java/core_service/modules/rewards/domain/StartFacts.java @@ -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) {} diff --git a/src/main/java/core_service/modules/rewards/repo/RewardFactsRepository.java b/src/main/java/core_service/modules/rewards/repo/RewardFactsRepository.java new file mode 100644 index 0000000..c418ec7 --- /dev/null +++ b/src/main/java/core_service/modules/rewards/repo/RewardFactsRepository.java @@ -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 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 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 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 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 pileFirstAndLast(long userId, YearMonth month) { + List 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(); + } +} diff --git a/src/main/java/core_service/modules/rewards/repo/RewardLedgerRepository.java b/src/main/java/core_service/modules/rewards/repo/RewardLedgerRepository.java new file mode 100644 index 0000000..bce6ce4 --- /dev/null +++ b/src/main/java/core_service/modules/rewards/repo/RewardLedgerRepository.java @@ -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 names(Collection 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 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 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 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 eventsOfKinds(long userId, Collection 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 equipped(long userId) { + Map 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())); + } +} diff --git a/src/main/java/core_service/modules/rewards/service/RewardService.java b/src/main/java/core_service/modules/rewards/service/RewardService.java new file mode 100644 index 0000000..5b4f3a6 --- /dev/null +++ b/src/main/java/core_service/modules/rewards/service/RewardService.java @@ -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 owned, Map equipped, List 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 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 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 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(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2053bb9..f49f8f8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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} diff --git a/src/main/resources/db/migration/V26__Reward_Ledger.sql b/src/main/resources/db/migration/V26__Reward_Ledger.sql new file mode 100644 index 0000000..d169dbe --- /dev/null +++ b/src/main/resources/db/migration/V26__Reward_Ledger.sql @@ -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; diff --git a/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java b/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java index 04f165c..cb3b710 100644 --- a/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java +++ b/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java @@ -17,7 +17,7 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest { private static final List 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); } } diff --git a/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java b/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java index d278ee2..3032d9a 100644 --- a/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java +++ b/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java @@ -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()); diff --git a/src/test/java/core_service/modules/rewards/api/RewardControllerTest.java b/src/test/java/core_service/modules/rewards/api/RewardControllerTest.java new file mode 100644 index 0000000..4e7c1e9 --- /dev/null +++ b/src/test/java/core_service/modules/rewards/api/RewardControllerTest.java @@ -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; + } +} diff --git a/src/test/java/core_service/modules/rewards/domain/LooksTest.java b/src/test/java/core_service/modules/rewards/domain/LooksTest.java new file mode 100644 index 0000000..7c2be0e --- /dev/null +++ b/src/test/java/core_service/modules/rewards/domain/LooksTest.java @@ -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 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")); + } +} diff --git a/src/test/java/core_service/modules/rewards/domain/RewardRulesTest.java b/src/test/java/core_service/modules/rewards/domain/RewardRulesTest.java new file mode 100644 index 0000000..f0c6eea --- /dev/null +++ b/src/test/java/core_service/modules/rewards/domain/RewardRulesTest.java @@ -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 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"); + } +} diff --git a/src/test/java/core_service/modules/rewards/repo/RewardFactsRepositoryTest.java b/src/test/java/core_service/modules/rewards/repo/RewardFactsRepositoryTest.java new file mode 100644 index 0000000..c5ac359 --- /dev/null +++ b/src/test/java/core_service/modules/rewards/repo/RewardFactsRepositoryTest.java @@ -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 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); + } +} diff --git a/src/test/java/core_service/modules/rewards/repo/RewardLedgerRepositoryTest.java b/src/test/java/core_service/modules/rewards/repo/RewardLedgerRepositoryTest.java new file mode 100644 index 0000000..3e51686 --- /dev/null +++ b/src/test/java/core_service/modules/rewards/repo/RewardLedgerRepositoryTest.java @@ -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 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 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); + } +} diff --git a/src/test/java/core_service/modules/rewards/service/RewardServiceMonthTest.java b/src/test/java/core_service/modules/rewards/service/RewardServiceMonthTest.java new file mode 100644 index 0000000..8cd6faa --- /dev/null +++ b/src/test/java/core_service/modules/rewards/service/RewardServiceMonthTest.java @@ -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); + } +} diff --git a/src/test/java/core_service/shared/test/AbstractIntegrationTest.java b/src/test/java/core_service/shared/test/AbstractIntegrationTest.java index 4efe47c..9e928c8 100644 --- a/src/test/java/core_service/shared/test/AbstractIntegrationTest.java +++ b/src/test/java/core_service/shared/test/AbstractIntegrationTest.java @@ -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")