diff --git a/readme.md b/readme.md index c1413b8..c5d96b7 100644 --- a/readme.md +++ b/readme.md @@ -104,6 +104,7 @@ flowchart LR Rule --> Card["question card"] Answer["answer"] --> Backlog["BacklogService"] --> UserGame["user_game"] --> Trigger["V14 trigger: status log"] --> Settle["RewardService.settle"] Answer -->|"Frontend: GET /api/rewards/games/{appid}"| Settle + Sync["library sync"] --> LibraryActivityEvent --> Settle Answer -->|no| Snooze["user_game_question"] ``` - **Hide games**: a user can hide a game, Steam or added by hand (e.g. a playtest or a tool). It leaves every list and number: library, @@ -133,11 +134,14 @@ flowchart LR (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`, on `GET /api/rewards/games/{appid}` and + (`reward_ledger`, V26; the balance is the sum). No code path pays: a settle (on `GET /api/rewards/wallet`, on `GET /api/rewards/games/{appid}`, after every sync (`LibraryActivityEvent`, own transaction, a failure only logs) 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. `GET /api/rewards/games/{appid}` (game page open, after every status save) settles, then answers the paid rows of the game and its set (`bookedNow` = written by this call) and one `NoPointsReason` per missing kind (`STARTED`, `FINISHED`, `COMPLETED`); the codes live in `RewardRules`, the Frontend only maps them to texts. The settle takes a per-user advisory lock. V26 also pays a one-time starter grant + proof of fresh play (ending achievement or playtime around the finish, or half of the IGDB time played since the finish). Without proof a Steam finish pays 10 early points (`FINISHED_EARLY`), only for finishes on or after + `app.rewards.early-since` (older unproven finishes pay only with proof); when proof arrives later, `FINISHED_TOPUP` pays the rest up to the full amount, once per game or set. PlayStation days never prove: a game linked to a PlayStation title pays by its own rules, only + PlayStation rows pay nothing. The month bonus needs a finished game in that month. Games added by hand pay 15 for + a finish. Manual finishes and early finishes share the cap of 3 per ISO week. `COMPLETED` needs a `FINISHED` or `FINISHED_TOPUP` row. + `GET /api/rewards/expected?appids=` (read-only, at most 200) answers the full finish value per game for the Questions cards. `GET /api/rewards/games/{appid}` (game page open, after every status save) settles, then answers the paid rows of the game and its set (`bookedNow` = written by this call) and one `NoPointsReason` per missing kind (`STARTED`, `FINISHED`, `COMPLETED`); the codes live in `RewardRules`, the Frontend only maps them to texts. The settle takes a per-user advisory lock. V26 also pays a one-time starter grant for finishes from before the status log. Shop: a whitelist of 57 looks (`ShopItem`; 45 buyable and 12 unlock-only) in eight slots, including animated completed effects, page backgrounds (CSS, SVG and two canvas engines in the Frontend), banner backgrounds (V27 widens `reward_equipped.slot`) and three buyable skins (full themes, slot `SKIN`). The unlock-only items (price 0, never sold): the settle books an `UNLOCK` row (ref = item code, 0 points) once a @@ -218,6 +222,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, unlock `progress` per track) | JWT | +| `GET` | `/api/rewards/expected?appids=` (`{appid: points}`, the full finish value, 0 when paid; read-only, no settle; `400 TOO_MANY` over 200 appids) | JWT | | `POST` | `/api/rewards/purchases` (`{item}`; `200` wallet, `409 ALREADY_OWNED` / `NOT_ENOUGH_POINTS` / `NOT_FOR_SALE`) | JWT | | `GET` | `/api/rewards/progress/{track}/games?page=`, `/api/rewards/history/{kind}/{ref}/games?page=` (the games behind a track or a `MONTH` / `STREAK` bonus; `400 NOT_A_GAME_LIST`) | JWT | | `PUT` | `/api/rewards/equipped/{slot}` (`{item}`, null = take off; `200` wallet, `409 NOT_OWNED`, `400 WRONG_SLOT`) | JWT | 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 0d272c5..1883733 100644 --- a/src/main/java/core_service/modules/library/repo/GameMergeRepository.java +++ b/src/main/java/core_service/modules/library/repo/GameMergeRepository.java @@ -30,8 +30,9 @@ public class GameMergeRepository { private static final List CATALOG_TABLES = List.of("game_mood", "game_speedrun", "game_time_to_beat", "game_correction", "broken_image", "game_edition", "game_release", "game_credit", "game_credit_check", "steam_game"); - // The paid game kinds; a merge moves their ref, an undo moves it back. - private static final String GAME_KINDS = "('STARTED', 'FINISHED', 'FINISHED_MANUAL', 'COMPLETED')"; + // The paid game kinds; a merge moves their ref, an undo moves it back. Mirrors RewardKind.GAME_KINDS. + private static final String GAME_KINDS = + "('STARTED', 'FINISHED', 'FINISHED_MANUAL', 'FINISHED_EARLY', 'FINISHED_TOPUP', 'COMPLETED')"; // The ledger rows of a game kind the merge moves: on from, unless the user holds the kind on to. private static final String LEDGER_MOVED = "l.ref = cast(:from as text) and l.kind in " + GAME_KINDS diff --git a/src/main/java/core_service/modules/rewards/api/RewardController.java b/src/main/java/core_service/modules/rewards/api/RewardController.java index 644c1c8..dce6a1b 100644 --- a/src/main/java/core_service/modules/rewards/api/RewardController.java +++ b/src/main/java/core_service/modules/rewards/api/RewardController.java @@ -25,6 +25,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.List; import java.util.Optional; @RestController @@ -32,6 +33,8 @@ import java.util.Optional; @RequiredArgsConstructor public class RewardController { + private static final int MAX_EXPECTED = 200; + private final RewardService rewardService; /** @@ -53,6 +56,18 @@ public class RewardController { .body(new ErrorResponse("GAME_NOT_FOUND", "The game is not in your library"))); } + /** + * Read-only, no settle: the full finish value per listed game, 0 when it is paid already. + */ + @GetMapping("/expected") + public ResponseEntity getExpected(@AuthenticationPrincipal UserPrincipal principal, + @RequestParam List appids) { + if (appids.size() > MAX_EXPECTED) { + return ResponseEntity.badRequest().body(new ErrorResponse("TOO_MANY", "At most " + MAX_EXPECTED + " games")); + } + return ResponseEntity.ok(rewardService.expectedFinish(principal.getIdAsLong(), appids)); + } + @GetMapping("/progress/{track}/games") public ResponseEntity getProgressGames(@AuthenticationPrincipal UserPrincipal principal, @PathVariable Track track, diff --git a/src/main/java/core_service/modules/rewards/domain/FinishFacts.java b/src/main/java/core_service/modules/rewards/domain/FinishFacts.java index 0c4460d..9e8a4ee 100644 --- a/src/main/java/core_service/modules/rewards/domain/FinishFacts.java +++ b/src/main/java/core_service/modules/rewards/domain/FinishFacts.java @@ -11,6 +11,8 @@ import java.time.Instant; * @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 + * @param earlyPaid the game or its set has a FINISHED_EARLY row */ public record FinishFacts(long appid, boolean manual, boolean completed, int playtimeMinutes, int playedSinceMinutes, Integer normallyMinutes, - boolean endingUnlocked, boolean playedAround, boolean allAchievements, Instant finishedAt) {} + boolean endingUnlocked, boolean playedAround, boolean allAchievements, Instant finishedAt, + boolean earlyPaid) {} diff --git a/src/main/java/core_service/modules/rewards/domain/GameRewardFacts.java b/src/main/java/core_service/modules/rewards/domain/GameRewardFacts.java index cdc6ab9..c83a674 100644 --- a/src/main/java/core_service/modules/rewards/domain/GameRewardFacts.java +++ b/src/main/java/core_service/modules/rewards/domain/GameRewardFacts.java @@ -7,6 +7,7 @@ package core_service.modules.rewards.domain; * @param completed status COMPLETED * @param startLogged a BACKLOG to PLAYING change is in the status log * @param finishLogged a change to FINISHED or COMPLETED is in the status log + * @param linkedTitle a PlayStation title is linked to the game, so its playtime days prove nothing */ public record GameRewardFacts(boolean manual, boolean finished, boolean completed, boolean hidden, boolean playstation, - boolean psnLinked, boolean startLogged, boolean finishLogged) {} + boolean startLogged, boolean finishLogged, boolean linkedTitle) {} diff --git a/src/main/java/core_service/modules/rewards/domain/NoPointsReason.java b/src/main/java/core_service/modules/rewards/domain/NoPointsReason.java index 5b8b141..83eb49f 100644 --- a/src/main/java/core_service/modules/rewards/domain/NoPointsReason.java +++ b/src/main/java/core_service/modules/rewards/domain/NoPointsReason.java @@ -14,21 +14,23 @@ public enum NoPointsReason { HIDDEN, /** A PlayStation game pays nothing. */ PLAYSTATION, - /** The game is linked to a PlayStation title and pays nothing for now. */ - PLAYSTATION_LINKED, /** The game was finished before the status history began (starter points cover it). */ BEFORE_HISTORY, /** A game added by hand has no playtime to prove a start or a completion. */ MANUAL_GAME, /** A start needs one playtime day on or after the start. */ NO_PLAY_AFTER_START, + /** A PlayStation title is linked to the game: its play mixes in, so only the Steam ending achievement proves. */ + PLAY_NOT_COUNTED, /** A Steam finish needs playtime. */ NO_PLAYTIME, /** A Steam finish needs proof of fresh play. */ NO_PROOF, /** Three manual finishes are paid in that week already. */ WEEKLY_LIMIT, - /** COMPLETED needs a paid FINISHED row first. */ + /** The weekly cap is used up, so no early points; proof still pays the full finish. */ + WEEKLY_LIMIT_NO_PROOF, + /** COMPLETED needs a FINISHED or FINISHED_TOPUP row first. */ NEEDS_PROVEN_FINISH, /** COMPLETED needs all achievements. */ NOT_ALL_ACHIEVEMENTS diff --git a/src/main/java/core_service/modules/rewards/domain/RewardKind.java b/src/main/java/core_service/modules/rewards/domain/RewardKind.java index a937450..ab62ce4 100644 --- a/src/main/java/core_service/modules/rewards/domain/RewardKind.java +++ b/src/main/java/core_service/modules/rewards/domain/RewardKind.java @@ -3,7 +3,16 @@ package core_service.modules.rewards.domain; import java.util.List; public enum RewardKind { - STARTED, FINISHED, FINISHED_MANUAL, COMPLETED, MONTH, STREAK, STARTER, BUY, + STARTED, FINISHED, FINISHED_MANUAL, + /** + * 10 points for a Steam finish without proof yet; ref = appid. + */ + FINISHED_EARLY, + /** + * The rest up to the full finish once proof arrives; ref = appid. + */ + FINISHED_TOPUP, + COMPLETED, MONTH, STREAK, STARTER, BUY, /** * An unlock-only item reached by progress: ref = item code, points 0. Not in {@link #GAME_KINDS}, so a merge never * moves it. @@ -13,10 +22,16 @@ public enum RewardKind { /** * The kinds whose ref is a game (an appid). */ - public static final List GAME_KINDS = List.of(STARTED, FINISHED, FINISHED_MANUAL, COMPLETED); + public static final List GAME_KINDS = List.of(STARTED, FINISHED, FINISHED_MANUAL, FINISHED_EARLY, FINISHED_TOPUP, + COMPLETED); /** * The kinds that count as "finished a game" for the weekly streak. */ - public static final List FINISH_KINDS = List.of(FINISHED, FINISHED_MANUAL); + public static final List FINISH_KINDS = List.of(FINISHED, FINISHED_MANUAL, FINISHED_EARLY); + + /** + * The finish kinds that share the weekly cap: a manual finish and an unproven Steam finish. + */ + public static final List CAPPED_KINDS = List.of(FINISHED_MANUAL, FINISHED_EARLY); } diff --git a/src/main/java/core_service/modules/rewards/domain/RewardRules.java b/src/main/java/core_service/modules/rewards/domain/RewardRules.java index a9bdc27..b052f79 100644 --- a/src/main/java/core_service/modules/rewards/domain/RewardRules.java +++ b/src/main/java/core_service/modules/rewards/domain/RewardRules.java @@ -1,6 +1,7 @@ package core_service.modules.rewards.domain; import java.time.DateTimeException; +import java.time.Instant; import java.time.LocalDate; import java.time.YearMonth; import java.time.temporal.ChronoField; @@ -20,6 +21,7 @@ public final class RewardRules { 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 EARLY_FINISH = 10; public static final int MANUAL_FINISH = 15; public static final int MANUAL_PER_WEEK = 3; public static final int MONTH_BONUS = 40; @@ -47,19 +49,47 @@ public final class RewardRules { } /** - * 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. + * A game added by hand pays {@link #MANUAL_FINISH}. A Steam game pays by its length once it has playtime plus one + * proof: the ending achievement, playtime around the finish, or playtime of at least {@link #PROOF_SHARE} of the + * normal time. Without proof it pays {@link #EARLY_FINISH} now, and once proof arrives the rest as a top-up. The + * weekly cap of {@link RewardKind#CAPPED_KINDS} is the caller's job. The top-up assumes the early row paid + * {@link #EARLY_FINISH}. A finish before earlySince gets no early row and pays only with proof. */ - public static Optional finish(FinishFacts f) { + public static Optional finish(FinishFacts f, Instant earlySince) { String ref = String.valueOf(f.appid()); if (f.manual()) { return Optional.of(new RewardEntry(RewardKind.FINISHED_MANUAL, ref, MANUAL_FINISH, f.finishedAt())); } - if (whyNoFinish(f).isPresent()) { - return Optional.empty(); + boolean proven = whyNoFinish(f).isEmpty(); + int full = finishPoints(f.normallyMinutes()); + if (f.earlyPaid()) { + return proven ? Optional.of(new RewardEntry(RewardKind.FINISHED_TOPUP, ref, full - EARLY_FINISH, f.finishedAt())) + : Optional.empty(); } - return Optional.of(new RewardEntry(RewardKind.FINISHED, ref, finishPoints(f.normallyMinutes()), f.finishedAt())); + if (proven) { + return Optional.of(new RewardEntry(RewardKind.FINISHED, ref, full, f.finishedAt())); + } + return earlyAllowed(f, earlySince) + ? Optional.of(new RewardEntry(RewardKind.FINISHED_EARLY, ref, EARLY_FINISH, f.finishedAt())) + : Optional.empty(); + } + + private static boolean earlyAllowed(FinishFacts f, Instant earlySince) { + return !f.finishedAt().isBefore(earlySince); + } + + /** + * True when the finish would use the weekly cap: a manual finish, or an unproven Steam finish that may pay early. + */ + public static boolean capped(FinishFacts f, Instant earlySince) { + return f.manual() || (whyNoFinish(f).isPresent() && earlyAllowed(f, earlySince)); + } + + /** + * The full finish value of a game: what its finish pays in all, early row and top-up together. + */ + public static int expectedFinish(boolean manual, Integer normallyMinutes) { + return manual ? MANUAL_FINISH : finishPoints(normallyMinutes); } /** @@ -98,10 +128,7 @@ public final class RewardRules { if (g.hidden()) { return NoPointsReason.HIDDEN; } - if (g.playstation()) { - return NoPointsReason.PLAYSTATION; - } - return g.psnLinked() ? NoPointsReason.PLAYSTATION_LINKED : null; + return g.playstation() ? NoPointsReason.PLAYSTATION : null; } /** @@ -115,15 +142,18 @@ public final class RewardRules { if (g.manual()) { return NoPointsReason.MANUAL_GAME; } - return s != null && !s.playedAfter() ? NoPointsReason.NO_PLAY_AFTER_START : null; + if (s == null || s.playedAfter()) { + return null; + } + return g.linkedTitle() ? NoPointsReason.PLAY_NOT_COUNTED : NoPointsReason.NO_PLAY_AFTER_START; } /** * @param f the finish candidate of the game, null when it is none - * @param weekFull the manual weekly cap is used up in the week of the finish - * @return why no FINISHED or FINISHED_MANUAL row is paid, null when nothing stops it + * @param weekFull the weekly cap is used up in the week of the finish + * @return why no finish row is paid, null when nothing stops it */ - public static NoPointsReason noFinishReason(GameRewardFacts g, FinishFacts f, boolean weekFull) { + public static NoPointsReason noFinishReason(GameRewardFacts g, FinishFacts f, boolean weekFull, Instant earlySince) { if (!g.finished()) { return NoPointsReason.NOT_FINISHED; } @@ -136,12 +166,32 @@ public final class RewardRules { if (f.manual()) { return weekFull ? NoPointsReason.WEEKLY_LIMIT : null; } - return whyNoFinish(f).orElse(null); + Optional unproven = whyNoFinish(f); + if (unproven.isEmpty()) { + return null; + } + if (!earlyAllowed(f, earlySince)) { + return playCounts(g, unproven.get()); + } + return weekFull ? NoPointsReason.WEEKLY_LIMIT_NO_PROOF : null; + } + + /** + * @param f the finish candidate of the game, null when it is none + * @return why the rest of an early finish is not paid yet, null when nothing stops it + */ + public static NoPointsReason noTopUpReason(GameRewardFacts g, FinishFacts f) { + return f == null ? null : whyNoFinish(f).map(reason -> playCounts(g, reason)).orElse(null); + } + + // Play cannot fix a missing proof when a linked PlayStation title mixes its minutes in. + private static NoPointsReason playCounts(GameRewardFacts g, NoPointsReason reason) { + return reason == NoPointsReason.NO_PROOF && g.linkedTitle() ? NoPointsReason.PLAY_NOT_COUNTED : reason; } /** * @param c the completed candidate of the game, null when it is none - * @param provenFinishPaid a FINISHED row is paid for the game or its set + * @param provenFinishPaid a FINISHED or FINISHED_TOPUP row is paid for the game or its set * @return why no COMPLETED row is paid, null when nothing stops it */ public static NoPointsReason noCompletedReason(GameRewardFacts g, FinishFacts c, boolean provenFinishPaid) { diff --git a/src/main/java/core_service/modules/rewards/repo/RewardFactsRepository.java b/src/main/java/core_service/modules/rewards/repo/RewardFactsRepository.java index 004ca02..4875896 100644 --- a/src/main/java/core_service/modules/rewards/repo/RewardFactsRepository.java +++ b/src/main/java/core_service/modules/rewards/repo/RewardFactsRepository.java @@ -15,6 +15,7 @@ import org.springframework.stereotype.Repository; import java.sql.Date; import java.time.YearMonth; import java.time.ZoneId; +import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -24,8 +25,8 @@ import java.util.stream.Collectors; /** * 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. Rewards keep Steam-only proof: PlayStation - * rows (their playtime is PlayStation's number, and a title can be linked to a game later) give no facts. + * library module owns the entities and a module may not depend on another. PlayStation rows give no + * facts. A PlayStation title linked to a game does not block it, but its playtime days never prove a finish. */ @Repository @RequiredArgsConstructor @@ -34,6 +35,11 @@ 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)"; + // The playtime days of a game with a linked PlayStation title can be PlayStation's: they never prove anything. + private static final String NO_PSN_DAYS = """ + and not exists (select 1 from user_psn_title t where t.user_id = d.user_id and t.appid = d.appid + and t.state = 'LINKED')"""; + private final NamedParameterJdbcTemplate jdbc; /** @@ -89,19 +95,17 @@ public class RewardFactsRepository { 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 + and d.day >= (c.started_at at time zone :zone)::date %2$s) 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' and (cast(:appid as bigint) is null or appid = :appid) 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 and ug.source <> 'PSN' - and not exists (select 1 from user_psn_title t where t.user_id = ug.user_id and t.appid = ug.appid - and t.state = 'LINKED') left join user_game_set st on st.user_id = c.user_id and st.appid = c.appid where not %1$s order by c.started_at, c.appid - """.formatted(paidInSet("c", "'STARTED'")), + """.formatted(paidInSet("c", "'STARTED'"), NO_PSN_DAYS), new MapSqlParameterSource("userId", userId).addValue("zone", zone.getId()).addValue("appid", appid), (rs, i) -> new StartFacts(rs.getLong("appid"), rs.getBoolean("manual"), rs.getTimestamp("started_at").toInstant(), rs.getBoolean("played_after"))); @@ -129,34 +133,35 @@ public class RewardFactsRepository { 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, + and d.day >= (c.finished_at at time zone :zone)::date %2$s) 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, + and d.day >= (c.finished_at at time zone :zone)::date - :aroundDays %2$s) + as played_around, coalesce(s.achievements_total > 0 and m.achievements_unlocked >= s.achievements_total, false) as all_achievements, - c.finished_at + c.finished_at, %3$s as early_paid 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') and (cast(:appid as bigint) is null or appid = :appid) 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 and ug.source <> 'PSN' - and not exists (select 1 from user_psn_title t where t.user_id = ug.user_id and t.appid = ug.appid - and t.state = 'LINKED') 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 left join user_game_set st on st.user_id = c.user_id and st.appid = c.appid where not %1$s order by c.finished_at, c.appid - """.formatted(paidInSet("c", "'FINISHED', 'FINISHED_MANUAL'")), + """.formatted(paidInSet("c", "'FINISHED', 'FINISHED_MANUAL', 'FINISHED_TOPUP'"), NO_PSN_DAYS, + paidInSet("c", "'FINISHED_EARLY'")), new MapSqlParameterSource("userId", userId).addValue("zone", zone.getId()) .addValue("aroundDays", RewardRules.PLAYED_AROUND_DAYS).addValue("appid", appid), (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())); + rs.getBoolean("all_achievements"), rs.getTimestamp("finished_at").toInstant(), + rs.getBoolean("early_paid"))); } /** @@ -178,7 +183,7 @@ public class RewardFactsRepository { from user_game ug left join user_game_set st on st.user_id = ug.user_id and st.appid = ug.appid join lateral (select min(l.event_at) as event_at from reward_ledger l - where l.user_id = ug.user_id and l.kind = 'FINISHED' + where l.user_id = ug.user_id and l.kind in ('FINISHED', 'FINISHED_TOPUP') and (l.ref = ug.appid::text or l.ref in (select o.appid::text from user_game_set o where o.user_id = st.user_id and o.set_id = st.set_id))) f @@ -187,15 +192,14 @@ public class RewardFactsRepository { 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 ug.source <> 'PSN' - and not exists (select 1 from user_psn_title t where t.user_id = ug.user_id and t.appid = ug.appid - and t.state = 'LINKED') and (cast(:appid as bigint) is null or ug.appid = :appid) and not %1$s order by f.event_at, ug.appid """.formatted(paidInSet("ug", "'COMPLETED'")), new MapSqlParameterSource("userId", userId).addValue("appid", appid), (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())); + false, false, rs.getBoolean("all_achievements"), rs.getTimestamp("event_at").toInstant(), + false)); } /** @@ -206,22 +210,46 @@ public class RewardFactsRepository { select ug.source = 'MANUAL' as manual, ug.status in ('FINISHED', 'COMPLETED') as finished, ug.status = 'COMPLETED' as completed, ug.hidden_at is not null as hidden, ug.source = 'PSN' as playstation, - exists (select 1 from user_psn_title t where t.user_id = ug.user_id and t.appid = ug.appid - and t.state = 'LINKED') as psn_linked, exists (select 1 from user_game_status_change c where c.user_id = ug.user_id and c.appid = ug.appid and c.from_status = 'BACKLOG' and c.to_status = 'PLAYING') as start_logged, 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')) as finish_logged + and c.appid = ug.appid and c.to_status in ('FINISHED', 'COMPLETED')) as finish_logged, + exists (select 1 from user_psn_title t where t.user_id = ug.user_id and t.appid = ug.appid + and t.state = 'LINKED') as linked_title from user_game ug where ug.user_id = :userId and ug.appid = :appid and ug.removed_at is null """, new MapSqlParameterSource("userId", userId).addValue("appid", appid), (rs, i) -> new GameRewardFacts(rs.getBoolean("manual"), rs.getBoolean("finished"), rs.getBoolean("completed"), rs.getBoolean("hidden"), rs.getBoolean("playstation"), - rs.getBoolean("psn_linked"), rs.getBoolean("start_logged"), rs.getBoolean("finish_logged"))) + rs.getBoolean("start_logged"), rs.getBoolean("finish_logged"), rs.getBoolean("linked_title"))) .stream().findFirst(); } + /** + * The full finish value of each listed game of the user that can pay (listed, not hidden, not PlayStation): 0 when + * the game or its set has a finish row. Other games are left out. + */ + public Map expectedFinish(long userId, Collection appids) { + if (appids.isEmpty()) { + return Map.of(); + } + Map result = new HashMap<>(); + jdbc.query(""" + select ug.appid, ug.source = 'MANUAL' as manual, t.normally_minutes, %s as paid + from user_game ug + left join game_time_to_beat t on t.appid = ug.appid + left join user_game_set st on st.user_id = ug.user_id and st.appid = ug.appid + where ug.user_id = :userId and ug.appid in (:appids) and ug.removed_at is null + and ug.hidden_at is null and ug.source <> 'PSN' + """.formatted(paidInSet("ug", "'FINISHED', 'FINISHED_MANUAL', 'FINISHED_EARLY'")), + new MapSqlParameterSource("userId", userId).addValue("appids", appids), + (RowCallbackHandler) rs -> result.put(rs.getLong("appid"), rs.getBoolean("paid") ? 0 + : RewardRules.expectedFinish(rs.getBoolean("manual"), + rs.getObject("normally_minutes", Integer.class)))); + return result; + } + /** * The game rows of the ledger for the game and the other editions of its set. bookedNow: written in the current * transaction (the database clock of a transaction is its start). diff --git a/src/main/java/core_service/modules/rewards/service/RewardService.java b/src/main/java/core_service/modules/rewards/service/RewardService.java index 2ca64d3..727644f 100644 --- a/src/main/java/core_service/modules/rewards/service/RewardService.java +++ b/src/main/java/core_service/modules/rewards/service/RewardService.java @@ -17,6 +17,7 @@ import core_service.modules.rewards.domain.Track; import core_service.modules.rewards.domain.Unlocks; import core_service.modules.rewards.repo.RewardFactsRepository; import core_service.modules.rewards.repo.RewardLedgerRepository; +import core_service.shared.event.LibraryActivityEvent; import core_service.shared.security.Public; import core_service.shared.security.RequiresRole; import core_service.shared.security.Role; @@ -25,7 +26,9 @@ 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.Propagation; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionalEventListener; import org.springframework.transaction.support.TransactionTemplate; import java.time.DateTimeException; @@ -64,6 +67,9 @@ public class RewardService { @Value("${app.library.zone:Europe/Berlin}") private ZoneId zone; + @Value("${app.rewards.early-since}") + private LocalDate earlySince; + /** * @param owned the codes the user may wear: bought or unlocked, or all for a platform admin * @param progress the value of each track, see {@link Unlocks} @@ -215,6 +221,30 @@ public class RewardService { } } + /** + * Settles the user after a sync, so points do not wait for the next app start. The settle runs in its own + * transaction and a failure only logs: the sync that published the event is already done. + */ + @TransactionalEventListener(fallbackExecution = true) + public void onLibraryActivity(LibraryActivityEvent event) { + TransactionTemplate own = new TransactionTemplate(transactionTemplate.getTransactionManager()); + own.setPropagationBehavior(Propagation.REQUIRES_NEW.value()); + try { + own.executeWithoutResult(status -> settleLocked(event.userId())); + } catch (Exception e) { + log.error("Reward settle of user {} after a sync failed", event.userId(), e); + } + } + + /** + * The full finish value of each listed game of the user, read without a settle; see + * {@link RewardFactsRepository#expectedFinish}. + */ + @Transactional(readOnly = true) + public Map expectedFinish(long userId, List appids) { + return facts.expectedFinish(userId, appids); + } + /** * @param paid the game rows of the game and its set * @param missing why a kind is not paid; FINISHED stands for both finish kinds @@ -248,6 +278,9 @@ public class RewardService { if (noRow(paid, RewardKind.FINISH_KINDS)) { missing.put(RewardKind.FINISHED, blocked); } + if (topUpOpen(paid)) { + missing.put(RewardKind.FINISHED_TOPUP, blocked); + } if (game.completed() && noRow(paid, List.of(RewardKind.COMPLETED))) { missing.put(RewardKind.COMPLETED, blocked); } @@ -259,17 +292,27 @@ public class RewardService { } if (noRow(paid, RewardKind.FINISH_KINDS)) { FinishFacts finish = facts.finishCandidates(userId, zone, appid).stream().findFirst().orElse(null); - boolean weekFull = finish != null && finish.manual() && weekFull(userId, finish.finishedAt()); - putReason(missing, RewardKind.FINISHED, RewardRules.noFinishReason(game, finish, weekFull)); + boolean weekFull = finish != null && RewardRules.capped(finish, earlySince()) + && weekFull(userId, finish.finishedAt()); + putReason(missing, RewardKind.FINISHED, RewardRules.noFinishReason(game, finish, weekFull, earlySince())); + } + if (topUpOpen(paid)) { + FinishFacts finish = facts.finishCandidates(userId, zone, appid).stream().findFirst().orElse(null); + putReason(missing, RewardKind.FINISHED_TOPUP, RewardRules.noTopUpReason(game, finish)); } if (noRow(paid, List.of(RewardKind.COMPLETED))) { FinishFacts completed = facts.completedCandidates(userId, appid).stream().findFirst().orElse(null); putReason(missing, RewardKind.COMPLETED, RewardRules.noCompletedReason(game, completed, - !noRow(paid, List.of(RewardKind.FINISHED)))); + !noRow(paid, List.of(RewardKind.FINISHED, RewardKind.FINISHED_TOPUP)))); } return missing; } + // An early row is paid and the rest of the finish is not. + private static boolean topUpOpen(List paid) { + return !noRow(paid, List.of(RewardKind.FINISHED_EARLY)) && noRow(paid, List.of(RewardKind.FINISHED_TOPUP)); + } + private static boolean noRow(List paid, List kinds) { return paid.stream().noneMatch(row -> kinds.contains(row.kind())); } @@ -318,8 +361,11 @@ public class RewardService { // A linked set pays once: every edition is a candidate, the first that passed the rules books, the others see its row. private int bookOnePerSet(long userId, RewardEntry entry) { - List kinds = entry.kind() == RewardKind.FINISHED || entry.kind() == RewardKind.FINISHED_MANUAL - ? List.of(RewardKind.FINISHED, RewardKind.FINISHED_MANUAL) : List.of(entry.kind()); + List kinds = switch (entry.kind()) { + case FINISHED, FINISHED_MANUAL, FINISHED_EARLY -> RewardKind.FINISH_KINDS; + case FINISHED_TOPUP -> List.of(RewardKind.FINISHED, RewardKind.FINISHED_MANUAL, RewardKind.FINISHED_TOPUP); + default -> List.of(entry.kind()); + }; return facts.paidInSet(userId, Long.parseLong(entry.ref()), kinds) ? 0 : book(userId, entry); } @@ -331,11 +377,11 @@ public class RewardService { private int settleFinishes(long userId) { int paid = 0; for (FinishFacts f : facts.finishCandidates(userId, zone)) { - RewardEntry entry = RewardRules.finish(f).orElse(null); + RewardEntry entry = RewardRules.finish(f, earlySince()).orElse(null); if (entry == null) { continue; } - if (entry.kind() == RewardKind.FINISHED_MANUAL && weekFull(userId, entry.eventAt())) { + if (RewardKind.CAPPED_KINDS.contains(entry.kind()) && weekFull(userId, entry.eventAt())) { continue; } paid += bookOnePerSet(userId, entry); @@ -346,7 +392,7 @@ public class RewardService { // The rows booked earlier in this run are in the count already. private boolean weekFull(long userId, Instant at) { LocalDate monday = weekStart(at); - return ledger.countKindInRange(userId, List.of(RewardKind.FINISHED_MANUAL), startOf(monday), + return ledger.countKindInRange(userId, RewardKind.CAPPED_KINDS, startOf(monday), startOf(monday.plusWeeks(1))) >= RewardRules.MANUAL_PER_WEEK; } @@ -395,6 +441,10 @@ public class RewardService { return at.atZone(zone).toLocalDate().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); } + private Instant earlySince() { + return startOf(earlySince); + } + 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 bc0d840..67c1e59 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -93,6 +93,9 @@ app: rewards: # Pile Points of every user, after the nightly sync (23:55) and the image repair (01:30). settle-cron: "0 30 2 * * *" + # Unproven Steam finishes from this day on pay 10 early points (#37); older ones pay only with proof. + # Fixed once at the slice 2 deploy: deploy date - 30 days. CHECK THE DATE BEFORE DEPLOY. + early-since: "2026-09-10" follow: # The hand changes of a followed user are one batch after this quiet time. digest-ms: 600000 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 7d73494..691cd54 100644 --- a/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java +++ b/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java @@ -246,6 +246,20 @@ class GameMergeFlowTest extends AbstractIntegrationTest { "STARTED " + MANUAL, "STARTED " + STEAM); } + @Test + void merge_MovesEarlyAndTopUpRows() throws Exception { + manualRow(AUDITOR_ID, MANUAL, GameStatus.FINISHED); + for (String kind : List.of("FINISHED_EARLY", "FINISHED_TOPUP")) { + jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, ?, ?, 5, now())", + AUDITOR_ID, kind, String.valueOf(MANUAL)); + } + + 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_EARLY " + STEAM, "FINISHED_TOPUP " + STEAM); + } + @Test void merge_BothInBacklog_KeptGameTakesTheBetterPosition() throws Exception { manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG); diff --git a/src/test/java/core_service/modules/rewards/api/RewardControllerEarlySinceTest.java b/src/test/java/core_service/modules/rewards/api/RewardControllerEarlySinceTest.java new file mode 100644 index 0000000..f815a8d --- /dev/null +++ b/src/test/java/core_service/modules/rewards/api/RewardControllerEarlySinceTest.java @@ -0,0 +1,107 @@ +package core_service.modules.rewards.api; + +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.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +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; + +/** + * The back pay cutoff: unproven finishes before app.rewards.early-since get no early points. The dev profile + * authenticates every request as user 1. + */ +@AutoConfigureMockMvc +@TestPropertySource(properties = "app.rewards.early-since=2026-09-10") +class RewardControllerEarlySinceTest extends AbstractIntegrationTest { + + private static final long USER = 1L; + private static final long GAME = 4_100_101L; + private static final long OLD = 4_100_102L; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JdbcTemplate jdbc; + + @BeforeEach + void clean() { + jdbc.update("delete from reward_ledger where user_id = ?", USER); + jdbc.update("delete from user_game_playtime_day where user_id = ?", USER); + jdbc.update("delete from user_game where user_id = ?", USER); + } + + private void finished(long appid, String source, String at) { + 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 (?, ?, 'BACKLOG', 120, ?)", + USER, appid, source); + jdbc.update("update user_game set status = 'FINISHED' where user_id = ? and appid = ?", USER, appid); + jdbc.update("update user_game_status_change set changed_at = ?::timestamptz where user_id = ? and appid = ?", + at, USER, appid); + } + + private ResultActions gamePoints(long appid) throws Exception { + return mockMvc.perform(get("/api/rewards/games/" + 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; + } + + @Test + void gamePoints_FinishBeforeTheCutoff_NoEarlyRowAndSaysNoProof() throws Exception { + finished(GAME, "STEAM", "2026-09-09T20:00:00Z"); + + gamePoints(GAME) + .andExpect(jsonPath("$.paid").isEmpty()) + .andExpect(jsonPath("$.missing.FINISHED").value("NO_PROOF")); + } + + @Test + void gamePoints_FinishOnTheCutoffDay_PaysTenEarly() throws Exception { + finished(GAME, "STEAM", "2026-09-10T08:00:00Z"); + + gamePoints(GAME) + .andExpect(jsonPath("$.paid[0].kind").value("FINISHED_EARLY")) + .andExpect(jsonPath("$.paid[0].points").value(10)); + } + + @Test + void gamePoints_FinishBeforeTheCutoffThenProof_PaysFullFinishedNoTopUp() throws Exception { + finished(GAME, "STEAM", "2026-09-01T10:00:00Z"); + gamePoints(GAME).andExpect(jsonPath("$.paid").isEmpty()); + + jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, current_date, ?, 30)", + USER, GAME); + + gamePoints(GAME) + .andExpect(jsonPath("$.paid.length()").value(1)) + .andExpect(jsonPath("$.paid[0].kind").value("FINISHED")) + .andExpect(jsonPath("$.paid[0].points").value(30)); + assertThat(rows("FINISHED_TOPUP")).isZero(); + } + + @Test + void gamePoints_OldUnprovenFinishDoesNotUseTheWeeklyCap() throws Exception { + // Monday and Tuesday of one ISO week, both before the cutoff. + finished(OLD, "STEAM", "2026-09-07T10:00:00Z"); + for (long appid = -4_100_110L; appid > -4_100_113L; appid--) { + finished(appid, "MANUAL", "2026-09-08T10:00:00Z"); + } + + mockMvc.perform(get("/api/rewards/wallet")); + + assertThat(rows("FINISHED_MANUAL")).isEqualTo(3); + assertThat(rows("FINISHED_EARLY")).isZero(); + } +} diff --git a/src/test/java/core_service/modules/rewards/api/RewardControllerTest.java b/src/test/java/core_service/modules/rewards/api/RewardControllerTest.java index 3862c98..b740094 100644 --- a/src/test/java/core_service/modules/rewards/api/RewardControllerTest.java +++ b/src/test/java/core_service/modules/rewards/api/RewardControllerTest.java @@ -101,14 +101,15 @@ class RewardControllerTest extends AbstractIntegrationTest { } @Test - void wallet_HandSetFinishedWithoutPlaytime_PaysNothingEvenWithTheEndingProof() throws Exception { + void wallet_HandSetFinishedWithoutPlaytime_PaysTenEarlyEvenWithTheEndingProof() 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)); + mockMvc.perform(get("/api/rewards/wallet")).andExpect(jsonPath("$.balance").value(10)); assertThat(rows("FINISHED")).isZero(); + assertThat(rows("FINISHED_EARLY")).isEqualTo(1); } @Test @@ -162,13 +163,15 @@ class RewardControllerTest extends AbstractIntegrationTest { } @Test - void gamePoints_SteamFinishWithoutProof_MissingFinishIsNoProof() throws Exception { + void gamePoints_SteamFinishWithoutProof_PaysTenEarly() throws Exception { game(GAME, "STEAM", "BACKLOG", 120); setStatus(GAME, "FINISHED"); gamePoints(GAME) - .andExpect(jsonPath("$.paid").isEmpty()) - .andExpect(jsonPath("$.missing.FINISHED").value("NO_PROOF")); + .andExpect(jsonPath("$.paid.length()").value(1)) + .andExpect(jsonPath("$.paid[0].kind").value("FINISHED_EARLY")) + .andExpect(jsonPath("$.paid[0].points").value(10)) + .andExpect(jsonPath("$.missing.FINISHED_TOPUP").value("NO_PROOF")); } @Test @@ -295,14 +298,44 @@ class RewardControllerTest extends AbstractIntegrationTest { } @Test - void gamePoints_LinkedPsnTitle_SaysPlaystationLinked() throws Exception { + void gamePoints_ManualGameWithLinkedPsnTitle_PaysFifteen() throws Exception { + game(-4_100_030L, "MANUAL", "BACKLOG", 0); + setStatus(-4_100_030L, "FINISHED"); + jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', ?)", + USER, -4_100_030L); + + gamePoints(-4_100_030L) + .andExpect(jsonPath("$.paid[0].kind").value("FINISHED_MANUAL")) + .andExpect(jsonPath("$.paid[0].points").value(15)) + .andExpect(jsonPath("$.missing.FINISHED").doesNotExist()); + } + + @Test + void gamePoints_SteamRowWithLinkedTitleAndEndingAchievement_PaysFull() throws Exception { game(GAME, "STEAM", "BACKLOG", 120); - playedToday(GAME); - setStatus(GAME, "FINISHED"); jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', ?)", USER, GAME); + playedToday(GAME); + setStatus(GAME, "FINISHED"); + jdbc.update("update user_game set finished_at_estimate = now() where user_id = ?", USER); - gamePoints(GAME).andExpect(jsonPath("$.missing.FINISHED").value("PLAYSTATION_LINKED")); + gamePoints(GAME) + .andExpect(jsonPath("$.paid.length()").value(1)) + .andExpect(jsonPath("$.paid[0].kind").value("FINISHED")) + .andExpect(jsonPath("$.paid[0].points").value(30)); + } + + @Test + void gamePoints_LinkedTitleDaysNeverProve_PaysTenEarly() throws Exception { + game(GAME, "STEAM", "BACKLOG", 120); + jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', ?)", + USER, GAME); + playedToday(GAME); + setStatus(GAME, "FINISHED"); + + gamePoints(GAME) + .andExpect(jsonPath("$.paid[0].kind").value("FINISHED_EARLY")) + .andExpect(jsonPath("$.missing.FINISHED_TOPUP").value("PLAY_NOT_COUNTED")); } @Test @@ -327,6 +360,193 @@ class RewardControllerTest extends AbstractIntegrationTest { assertThat(rows("FINISHED")).isEqualTo(1); } + @Test + void gamePoints_LinkedTitlePlayedAfterEarly_SaysPlayNotCounted() throws Exception { + game(GAME, "STEAM", "BACKLOG", 120); + jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', ?)", + USER, GAME); + setStatus(GAME, "FINISHED"); + gamePoints(GAME).andExpect(jsonPath("$.paid[0].kind").value("FINISHED_EARLY")); + + playedToday(GAME); + + gamePoints(GAME) + .andExpect(jsonPath("$.paid.length()").value(1)) + .andExpect(jsonPath("$.missing.FINISHED_TOPUP").value("PLAY_NOT_COUNTED")); + } + + @Test + void gamePoints_EarlyThenPlay_TopsUpToFullOnce() throws Exception { + game(GAME, "STEAM", "BACKLOG", 120); + setStatus(GAME, "FINISHED"); + gamePoints(GAME).andExpect(jsonPath("$.balance").value(10)); + + playedToday(GAME); + gamePoints(GAME) + .andExpect(jsonPath("$.paid[?(@.kind == 'FINISHED_TOPUP')].points").value(20)) + .andExpect(jsonPath("$.missing.FINISHED_TOPUP").doesNotExist()) + .andExpect(jsonPath("$.balance").value(30)); + gamePoints(GAME).andExpect(jsonPath("$.balance").value(30)); + + assertThat(rows("FINISHED_EARLY")).isEqualTo(1); + assertThat(rows("FINISHED_TOPUP")).isEqualTo(1); + assertThat(rows("FINISHED")).isZero(); + } + + @Test + void gamePoints_ZeroPlaytimeFinished_PaysTenNoTopUpWithoutSteamPlay() throws Exception { + game(GAME, "STEAM", "BACKLOG", 0); + setStatus(GAME, "FINISHED"); + + gamePoints(GAME) + .andExpect(jsonPath("$.paid[0].kind").value("FINISHED_EARLY")) + .andExpect(jsonPath("$.missing.FINISHED_TOPUP").value("NO_PLAYTIME")); + gamePoints(GAME).andExpect(jsonPath("$.balance").value(10)); + } + + @Test + void gamePoints_FourthCappedFinishInAWeek_ManualAndEarlyShareTheCap() throws Exception { + for (long appid = -4_100_010L; appid > -4_100_012L; appid--) { + game(appid, "MANUAL", "BACKLOG", 0); + setStatus(appid, "FINISHED"); + } + game(GAME, "STEAM", "BACKLOG", 120); + game(EDITION, "STEAM", "BACKLOG", 120); + setStatus(GAME, "FINISHED"); + jdbc.update("update user_game_status_change set changed_at = changed_at + interval '1 second' where appid = ?", GAME); + setStatus(EDITION, "FINISHED"); + jdbc.update("update user_game_status_change set changed_at = changed_at + interval '2 seconds' where appid = ?", EDITION); + + gamePoints(EDITION) + .andExpect(jsonPath("$.paid").isEmpty()) + .andExpect(jsonPath("$.missing.FINISHED").value("WEEKLY_LIMIT_NO_PROOF")); + + assertThat(rows("FINISHED_MANUAL") + rows("FINISHED_EARLY")).isEqualTo(3); + } + + @Test + void gamePoints_CappedEarlyThenProof_PaysTheFullFinish() throws Exception { + for (long appid = -4_100_010L; appid > -4_100_013L; appid--) { + game(appid, "MANUAL", "BACKLOG", 0); + setStatus(appid, "FINISHED"); + } + game(GAME, "STEAM", "BACKLOG", 120); + setStatus(GAME, "FINISHED"); + jdbc.update("update user_game_status_change set changed_at = changed_at + interval '1 second' where appid = ?", GAME); + gamePoints(GAME).andExpect(jsonPath("$.missing.FINISHED").value("WEEKLY_LIMIT_NO_PROOF")); + + playedToday(GAME); + + gamePoints(GAME) + .andExpect(jsonPath("$.paid[0].kind").value("FINISHED")) + .andExpect(jsonPath("$.paid[0].points").value(30)); + } + + @Test + void gamePoints_SetEarlyOnOneEditionProofOnTheOther_TopsUpOnce() throws Exception { + game(GAME, "STEAM", "BACKLOG", 120); + game(EDITION, "STEAM", "BACKLOG", 60); + jdbc.update("insert into game_link (appid, set_id) values (?, 4100), (?, 4100)", GAME, EDITION); + setStatus(GAME, "FINISHED"); + gamePoints(GAME).andExpect(jsonPath("$.paid[0].kind").value("FINISHED_EARLY")); + assertThat(rows("FINISHED_EARLY")).isEqualTo(1); + + playedToday(EDITION); + setStatus(EDITION, "FINISHED"); + gamePoints(EDITION); + gamePoints(GAME).andExpect(jsonPath("$.balance").value(30)); + + assertThat(rows("FINISHED_EARLY")).isEqualTo(1); + assertThat(rows("FINISHED_TOPUP")).isEqualTo(1); + assertThat(rows("FINISHED")).isZero(); + } + + @Test + void gamePoints_CompletedAfterEarly_NeedsProvenFinish_ThenPaysAfterTopUp() throws Exception { + game(GAME, "STEAM", "BACKLOG", 120); + allAchievements(); + setStatus(GAME, "FINISHED"); + setStatus(GAME, "COMPLETED"); + + gamePoints(GAME) + .andExpect(jsonPath("$.paid.length()").value(1)) + .andExpect(jsonPath("$.paid[0].kind").value("FINISHED_EARLY")) + .andExpect(jsonPath("$.missing.COMPLETED").value("NEEDS_PROVEN_FINISH")); + + playedToday(GAME); + + gamePoints(GAME) + .andExpect(jsonPath("$.paid[?(@.kind == 'FINISHED_TOPUP')]").isNotEmpty()) + .andExpect(jsonPath("$.paid[?(@.kind == 'COMPLETED')].points").value(40)) + .andExpect(jsonPath("$.balance").value(70)); + } + + @Test + void gamePoints_FinishedPlayingFinishedAfterEarly_PaysNoSecondEarly() throws Exception { + game(GAME, "STEAM", "BACKLOG", 120); + setStatus(GAME, "FINISHED"); + gamePoints(GAME).andExpect(jsonPath("$.balance").value(10)); + + setStatus(GAME, "PLAYING"); + setStatus(GAME, "FINISHED"); + + gamePoints(GAME).andExpect(jsonPath("$.balance").value(10)); + assertThat(rows("FINISHED_EARLY")).isEqualTo(1); + } + + @Test + void gamePoints_HiddenAfterEarly_MissingTopUpIsHidden() throws Exception { + game(GAME, "STEAM", "BACKLOG", 120); + setStatus(GAME, "FINISHED"); + gamePoints(GAME).andExpect(jsonPath("$.paid[0].kind").value("FINISHED_EARLY")); + jdbc.update("update user_game set hidden_at = now() where user_id = ?", USER); + + gamePoints(GAME).andExpect(jsonPath("$.missing.FINISHED_TOPUP").value("HIDDEN")); + } + + @Test + void expected_ReturnsFullValuePerGame_ZeroWhenPaid() throws Exception { + game(GAME, "STEAM", "BACKLOG", 120); + game(-4_100_040L, "MANUAL", "BACKLOG", 0); + jdbc.update("insert into game_time_to_beat (appid, normally_minutes, fetched_at) values (?, 1200, now()) " + + "on conflict (appid) do update set normally_minutes = 1200", GAME); + try { + mockMvc.perform(get("/api/rewards/expected").param("appids", GAME + "," + (-4_100_040L))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.['" + GAME + "']").value(50)) + .andExpect(jsonPath("$.['-4100040']").value(15)); + + setStatus(GAME, "FINISHED"); + gamePoints(GAME); + + mockMvc.perform(get("/api/rewards/expected").param("appids", String.valueOf(GAME))) + .andExpect(jsonPath("$.['" + GAME + "']").value(0)); + } finally { + jdbc.update("delete from game_time_to_beat where appid = ?", GAME); + } + } + + @Test + void expected_AnotherUsersAppid_IsLeftOut() throws Exception { + jdbc.update("insert into steam_game (appid, name) values (?, 'Game') on conflict do nothing", GAME); + jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes, source) values (?, ?, 'BACKLOG', 120, 'STEAM')", + OTHER_USER, GAME); + + mockMvc.perform(get("/api/rewards/expected").param("appids", String.valueOf(GAME))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isEmpty()); + } + + @Test + void expected_MoreThan200_Is400() throws Exception { + String ids = java.util.stream.LongStream.rangeClosed(1, 201).mapToObj(String::valueOf) + .collect(java.util.stream.Collectors.joining(",")); + + mockMvc.perform(get("/api/rewards/expected").param("appids", ids)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("TOO_MANY")); + } + @Test void gamePoints_GameNotInLibrary_Is404() throws Exception { gamePoints(GAME) diff --git a/src/test/java/core_service/modules/rewards/domain/RewardRulesTest.java b/src/test/java/core_service/modules/rewards/domain/RewardRulesTest.java index 40fe5ca..921fe7b 100644 --- a/src/test/java/core_service/modules/rewards/domain/RewardRulesTest.java +++ b/src/test/java/core_service/modules/rewards/domain/RewardRulesTest.java @@ -15,59 +15,94 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; class RewardRulesTest extends AbstractUnitTest { private static final Instant AT = Instant.parse("2026-10-01T10:00:00Z"); + private static final Instant EARLY_SINCE = Instant.parse("2026-09-10T00: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); + return new FinishFacts(220L, false, false, playtime, playtime, normallyMinutes, ending, around, false, AT, false); } private static int points(FinishFacts facts) { - return RewardRules.finish(facts).map(RewardEntry::points).orElse(0); + return RewardRules.finish(facts, EARLY_SINCE).map(RewardEntry::points).orElse(0); } private static GameRewardFacts game(boolean manual, boolean finished, boolean completed, boolean finishLogged) { - return new GameRewardFacts(manual, finished, completed, false, false, false, true, finishLogged); + return new GameRewardFacts(manual, finished, completed, false, false, true, finishLogged, false); } private static final GameRewardFacts STEAM_FINISHED = game(false, true, false, true); @Test void blocked_HiddenBeforePlaystation() { - GameRewardFacts all = new GameRewardFacts(false, true, false, true, true, true, true, true); + GameRewardFacts all = new GameRewardFacts(false, true, false, true, true, true, true, false); assertThat(RewardRules.blocked(all)).isEqualTo(NoPointsReason.HIDDEN); - assertThat(RewardRules.blocked(new GameRewardFacts(false, true, false, false, true, true, true, true))) + assertThat(RewardRules.blocked(new GameRewardFacts(false, true, false, false, true, true, true, false))) .isEqualTo(NoPointsReason.PLAYSTATION); - assertThat(RewardRules.blocked(new GameRewardFacts(false, true, false, false, false, true, true, true))) - .isEqualTo(NoPointsReason.PLAYSTATION_LINKED); assertThat(RewardRules.blocked(STEAM_FINISHED)).isNull(); } @Test void noFinishReason_ManualOverTheCap_IsWeeklyLimit() { - FinishFacts manual = new FinishFacts(-1L, true, false, 0, 0, null, false, false, false, AT); + FinishFacts manual = new FinishFacts(-1L, true, false, 0, 0, null, false, false, false, AT, false); GameRewardFacts g = game(true, true, false, true); - assertThat(RewardRules.noFinishReason(g, manual, true)).isEqualTo(NoPointsReason.WEEKLY_LIMIT); - assertThat(RewardRules.noFinishReason(g, manual, false)).isNull(); + assertThat(RewardRules.noFinishReason(g, manual, true, EARLY_SINCE)).isEqualTo(NoPointsReason.WEEKLY_LIMIT); + assertThat(RewardRules.noFinishReason(g, manual, false, EARLY_SINCE)).isNull(); } @Test - void noFinishReason_SteamWithoutProof_IsNoProof() { - assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(60, 300, false, false), false)) + void noFinishReason_SteamWithoutProof_IsNullBecauseEarlyPays() { + assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(60, 300, false, false), false, EARLY_SINCE)).isNull(); + assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(0, 300, true, true), false, EARLY_SINCE)).isNull(); + } + + @Test + void noTopUpReason_SteamWithoutProof_IsNoProof() { + assertThat(RewardRules.noTopUpReason(STEAM_FINISHED, steam(60, 300, false, false))).isEqualTo(NoPointsReason.NO_PROOF); + assertThat(RewardRules.noTopUpReason(STEAM_FINISHED, steam(60, 300, true, false))).isNull(); + assertThat(RewardRules.noTopUpReason(STEAM_FINISHED, null)).isNull(); + } + + @Test + void noTopUpReason_LinkedTitleWithoutProof_IsPlayNotCounted() { + GameRewardFacts linked = new GameRewardFacts(false, true, false, false, false, true, true, true); + + assertThat(RewardRules.noTopUpReason(linked, steam(60, 300, false, false))) + .isEqualTo(NoPointsReason.PLAY_NOT_COUNTED); + assertThat(RewardRules.noTopUpReason(linked, steam(0, 300, false, false))).isEqualTo(NoPointsReason.NO_PLAYTIME); + assertThat(RewardRules.noTopUpReason(linked, steam(60, 300, true, false))).isNull(); + assertThat(RewardRules.noStartReason(linked, new StartFacts(220L, false, AT, false))) + .isEqualTo(NoPointsReason.PLAY_NOT_COUNTED); + } + + @Test + void noTopUpReason_SteamWithoutPlaytime_IsNoPlaytime() { + assertThat(RewardRules.noTopUpReason(STEAM_FINISHED, steam(0, 300, true, true))).isEqualTo(NoPointsReason.NO_PLAYTIME); + } + + @Test + void noFinishReason_UnprovenBeforeTheCutoff_IsNoProof() { + Instant cutoff = AT.plusSeconds(1); + + assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(60, 300, false, false), false, cutoff)) .isEqualTo(NoPointsReason.NO_PROOF); + assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(0, 300, false, false), false, cutoff)) + .isEqualTo(NoPointsReason.NO_PLAYTIME); + assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(60, 300, true, false), false, cutoff)).isNull(); } @Test - void noFinishReason_SteamWithoutPlaytime_IsNoPlaytime() { - assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(0, 300, true, true), false)) - .isEqualTo(NoPointsReason.NO_PLAYTIME); + void noFinishReason_UnprovenSteamOverTheCap_IsWeeklyLimitNoProof() { + assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(60, 300, false, false), true, EARLY_SINCE)) + .isEqualTo(NoPointsReason.WEEKLY_LIMIT_NO_PROOF); + assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(60, 300, true, false), true, EARLY_SINCE)).isNull(); } @Test void noFinishReason_NoStatusLog_IsBeforeHistory() { - assertThat(RewardRules.noFinishReason(game(false, true, false, false), null, false)) + assertThat(RewardRules.noFinishReason(game(false, true, false, false), null, false, EARLY_SINCE)) .isEqualTo(NoPointsReason.BEFORE_HISTORY); - assertThat(RewardRules.noFinishReason(game(false, false, false, false), null, false)) + assertThat(RewardRules.noFinishReason(game(false, false, false, false), null, false, EARLY_SINCE)) .isEqualTo(NoPointsReason.NOT_FINISHED); } @@ -98,7 +133,7 @@ class RewardRulesTest extends AbstractUnitTest { @Test void noCompletedReason_MissingAchievements() { - FinishFacts open = new FinishFacts(220L, false, true, 60, 0, null, false, false, false, AT); + FinishFacts open = new FinishFacts(220L, false, true, 60, 0, null, false, false, false, AT, false); assertThat(RewardRules.noCompletedReason(game(false, true, true, true), open, true)) .isEqualTo(NoPointsReason.NOT_ALL_ACHIEVEMENTS); @@ -114,46 +149,109 @@ class RewardRulesTest extends AbstractUnitTest { } @Test - void finish_WithoutPlaytime_PaysNothingEvenWithTheEndingProof() { - assertThat(RewardRules.finish(steam(0, 600, true, true))).isEmpty(); + void finish_WithoutPlaytime_PaysTenEarlyEvenWithTheEndingProof() { + assertThat(RewardRules.finish(steam(0, 600, true, true), EARLY_SINCE)) + .contains(new RewardEntry(RewardKind.FINISHED_EARLY, "220", 10, AT)); } @Test - void finish_NeedsOneProof() { - assertThat(points(steam(60, 300, false, false))).isZero(); + void finish_WithoutProof_PaysTenEarly() { + assertThat(points(steam(60, 300, false, false))).isEqualTo(10); 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(149, 300, false, false))).isEqualTo(10); assertThat(points(steam(150, 300, false, false))).isEqualTo(30); - assertThat(points(steam(150, null, false, false))).isZero(); + assertThat(points(steam(150, null, false, false))).isEqualTo(10); } @Test - void finish_ShareProofCountsOnlyPlaytimeSinceTheFinish() { - FinishFacts oldPlay = new FinishFacts(220L, false, false, 500, 100, 300, false, false, false, AT); + void finish_ShareProofCountsOnlyPlaytimeSinceTheFinish_PaysTenEarly() { + FinishFacts oldPlay = new FinishFacts(220L, false, false, 500, 100, 300, false, false, false, AT, false); - assertThat(RewardRules.finish(oldPlay)).isEmpty(); + assertThat(RewardRules.finish(oldPlay, EARLY_SINCE)).contains(new RewardEntry(RewardKind.FINISHED_EARLY, "220", 10, AT)); } @Test void finish_ManualGame_PaysTheSmallPriceWithoutProof() { - FinishFacts manual = new FinishFacts(-5L, true, false, 0, 0, null, false, false, false, AT); + FinishFacts manual = new FinishFacts(-5L, true, false, 0, 0, null, false, false, false, AT, false); - assertThat(RewardRules.finish(manual)).contains(new RewardEntry(RewardKind.FINISHED_MANUAL, "-5", 15, AT)); + assertThat(RewardRules.finish(manual, EARLY_SINCE)).contains(new RewardEntry(RewardKind.FINISHED_MANUAL, "-5", 15, AT)); + } + + private static FinishFacts earlyPaid(FinishFacts f) { + return new FinishFacts(f.appid(), f.manual(), f.completed(), f.playtimeMinutes(), f.playedSinceMinutes(), + f.normallyMinutes(), f.endingUnlocked(), f.playedAround(), f.allAchievements(), f.finishedAt(), true); + } + + @Test + void finish_UnprovenBeforeTheCutoff_PaysNothing() { + assertThat(RewardRules.finish(steam(60, 300, false, false), AT.plusSeconds(1))).isEmpty(); + } + + @Test + void finish_UnprovenOnTheCutoffDay_PaysTenEarly() { + assertThat(RewardRules.finish(steam(60, 300, false, false), AT)).contains( + new RewardEntry(RewardKind.FINISHED_EARLY, "220", 10, AT)); + } + + @Test + void finish_ProvenBeforeTheCutoff_PaysFull() { + assertThat(RewardRules.finish(steam(60, 300, true, false), AT.plusSeconds(1))).contains( + new RewardEntry(RewardKind.FINISHED, "220", 30, AT)); + } + + @Test + void finish_ProvenAfterEarly_TopsUpToFull() { + FinishFacts f = earlyPaid(steam(60, 20 * 60, true, false)); + + assertThat(RewardRules.finish(f, EARLY_SINCE)).contains(new RewardEntry(RewardKind.FINISHED_TOPUP, "220", 40, AT)); + } + + @Test + void finish_UnprovenAfterEarly_PaysNothing() { + assertThat(RewardRules.finish(earlyPaid(steam(60, 300, false, false)), EARLY_SINCE)).isEmpty(); + } + + @Test + void finish_ZeroPlaytime_PaysTenEarly() { + assertThat(points(steam(0, 300, false, false))).isEqualTo(10); + assertThat(RewardRules.finish(earlyPaid(steam(0, 300, true, true)), EARLY_SINCE)).isEmpty(); + } + + @Test + void capped_UnprovenBeforeTheCutoff_IsNotCapped() { + assertThat(RewardRules.capped(steam(60, 300, false, false), AT.plusSeconds(1))).isFalse(); + assertThat(RewardRules.capped(steam(60, 300, false, false), AT)).isTrue(); + } + + @Test + void capped_ManualAndUnprovenSteam_NotProvenSteam() { + assertThat(RewardRules.capped(new FinishFacts(-1L, true, false, 0, 0, null, false, false, false, AT, false), + EARLY_SINCE)).isTrue(); + assertThat(RewardRules.capped(steam(60, 300, false, false), EARLY_SINCE)).isTrue(); + assertThat(RewardRules.capped(steam(60, 300, true, false), EARLY_SINCE)).isFalse(); + } + + @Test + void expectedFinish_ManualIsFifteen_SteamByLength() { + assertThat(RewardRules.expectedFinish(true, 3000)).isEqualTo(15); + assertThat(RewardRules.expectedFinish(false, null)).isEqualTo(30); + assertThat(RewardRules.expectedFinish(false, 20 * 60)).isEqualTo(50); + assertThat(RewardRules.expectedFinish(false, 40 * 60)).isEqualTo(80); } @Test void completed_NeedsAllAchievementsAndASteamGame() { - FinishFacts all = new FinishFacts(220L, false, true, 60, 60, null, true, true, true, AT); + FinishFacts all = new FinishFacts(220L, false, true, 60, 60, null, true, true, true, AT, false); 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(); + assertThat(RewardRules.completed(new FinishFacts(220L, false, true, 60, 60, null, true, true, false, AT, false))).isEmpty(); + assertThat(RewardRules.completed(new FinishFacts(220L, false, false, 60, 60, null, true, true, true, AT, false))).isEmpty(); + assertThat(RewardRules.completed(new FinishFacts(220L, true, true, 60, 60, null, true, true, true, AT, false))).isEmpty(); } @Test diff --git a/src/test/java/core_service/modules/rewards/repo/RewardFactsRepositoryTest.java b/src/test/java/core_service/modules/rewards/repo/RewardFactsRepositoryTest.java index a845e24..90e038d 100644 --- a/src/test/java/core_service/modules/rewards/repo/RewardFactsRepositoryTest.java +++ b/src/test/java/core_service/modules/rewards/repo/RewardFactsRepositoryTest.java @@ -66,7 +66,7 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest { } @Test - void game_FlagsHiddenPsnLinkedManualAndStatusLog() { + void game_FlagsHiddenManualAndStatusLog() { game("BACKLOG", 120); setStatus("PLAYING"); setStatus("FINISHED"); @@ -485,4 +485,102 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest { assertThat(repository.completedCandidates(USER)).extracting(FinishFacts::appid).contains(GAME); } + + private void linkedTitle() { + jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', ?)", USER, GAME); + } + + @Test + void finishCandidates_EarlyPaidStaysACandidateWithTheFlag() { + game("BACKLOG", 120); + setStatus("FINISHED"); + assertThat(repository.finishCandidates(USER, BERLIN)).singleElement() + .satisfies(f -> assertThat(f.earlyPaid()).isFalse()); + + paid(RewardKind.FINISHED_EARLY, GAME); + + assertThat(repository.finishCandidates(USER, BERLIN)).singleElement() + .satisfies(f -> assertThat(f.earlyPaid()).isTrue()); + } + + @Test + void finishCandidates_TopUpPaidIsNoCandidate() { + game("BACKLOG", 120); + setStatus("FINISHED"); + paid(RewardKind.FINISHED_EARLY, GAME); + paid(RewardKind.FINISHED_TOPUP, GAME); + + assertThat(repository.finishCandidates(USER, BERLIN)).isEmpty(); + } + + @Test + void finishCandidates_ManualRowWithLinkedTitle_IsACandidate() { + long manual = -1_220L; + jdbc.update("insert into steam_game (appid, name) values (?, 'Half-Life 2 (Switch)') on conflict do nothing", manual); + jdbc.update("insert into user_game (user_id, appid, status, source) values (?, ?, 'BACKLOG', 'MANUAL')", USER, manual); + jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', ?)", USER, manual); + jdbc.update("update user_game set status = 'FINISHED' where user_id = ?", USER); + + assertThat(repository.finishCandidates(USER, BERLIN)).extracting(FinishFacts::appid).containsExactly(manual); + } + + @Test + void finishCandidates_LinkedTitleDaysAreNoProof() { + game("BACKLOG", 120); + setStatus("FINISHED"); + changedAt("FINISHED", "2026-09-15T10:00:00Z"); + playedOn("2026-09-16"); + linkedTitle(); + + assertThat(repository.finishCandidates(USER, BERLIN)).singleElement().satisfies(f -> { + assertThat(f.playedAround()).isFalse(); + assertThat(f.playedSinceMinutes()).isZero(); + }); + } + + @Test + void startCandidates_LinkedTitleDaysAreNoPlayAfterStart() { + game("BACKLOG", 120); + setStatus("PLAYING"); + playedOn("2099-01-01"); + linkedTitle(); + + assertThat(repository.startCandidates(USER, BERLIN)).singleElement() + .satisfies(f -> assertThat(f.playedAfter()).isFalse()); + } + + @Test + void completedCandidates_OpenAfterTopUp_NotAfterEarly() { + game("BACKLOG", 120); + setStatus("COMPLETED"); + paid(RewardKind.FINISHED_EARLY, GAME); + assertThat(repository.completedCandidates(USER)).isEmpty(); + + paid(RewardKind.FINISHED_TOPUP, GAME); + + assertThat(repository.completedCandidates(USER)).extracting(FinishFacts::appid).containsExactly(GAME); + } + + @Test + void expectedFinish_PaidSetIsZero_HiddenAndPsnLeftOut() { + long manual = -1_220L; + long hidden = 222L; + long psn = 223L; + game("BACKLOG", 120); + for (long appid : new long[]{manual, hidden, psn}) { + jdbc.update("insert into steam_game (appid, name) values (?, 'G') on conflict do nothing", appid); + } + jdbc.update("insert into user_game (user_id, appid, status, source) values (?, ?, 'BACKLOG', 'MANUAL')", USER, manual); + jdbc.update("insert into user_game (user_id, appid, status, hidden_at) values (?, ?, 'BACKLOG', now())", USER, hidden); + jdbc.update("insert into user_game (user_id, appid, status, source, platform) values (?, ?, 'BACKLOG', 'PSN', 'PS5')", + USER, psn); + + assertThat(repository.expectedFinish(USER, List.of(GAME, manual, hidden, psn, 999L))) + .containsExactlyInAnyOrderEntriesOf(java.util.Map.of(GAME, 50, manual, 15)); + + paid(RewardKind.FINISHED_EARLY, GAME); + + assertThat(repository.expectedFinish(USER, List.of(GAME))).containsExactly(java.util.Map.entry(GAME, 0)); + assertThat(repository.expectedFinish(USER, List.of())).isEmpty(); + } } diff --git a/src/test/java/core_service/modules/rewards/service/RewardServiceSyncSettleTest.java b/src/test/java/core_service/modules/rewards/service/RewardServiceSyncSettleTest.java new file mode 100644 index 0000000..4ce1eac --- /dev/null +++ b/src/test/java/core_service/modules/rewards/service/RewardServiceSyncSettleTest.java @@ -0,0 +1,109 @@ +package core_service.modules.rewards.service; + +import core_service.shared.event.LibraryActivityEvent; +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.context.ApplicationEventPublisher; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.support.TransactionTemplate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +class RewardServiceSyncSettleTest extends AbstractIntegrationTest { + + private static final long USER = 15L; + private static final long GAME = 240L; + + @Autowired + private ApplicationEventPublisher publisher; + + @Autowired + private TransactionTemplate transactionTemplate; + + @Autowired + private JdbcTemplate jdbc; + + @BeforeEach + @AfterEach + void clean() { + jdbc.update("delete from reward_ledger where user_id = ?", USER); + jdbc.update("delete from user_game where user_id = ?", USER); + jdbc.update("delete from blocked_user where user_id = ?", USER); + } + + private void finishedGameWithoutProof() { + jdbc.update("insert into steam_game (appid, name) values (?, 'Synced') on conflict do nothing", GAME); + jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes) values (?, ?, 'BACKLOG', 90)", + USER, GAME); + jdbc.update("update user_game set status = 'FINISHED' where user_id = ?", USER); + } + + private int earlyRows() { + return jdbc.queryForObject("select count(*) from reward_ledger where user_id = ? and kind = 'FINISHED_EARLY'", + Integer.class, USER); + } + + @Test + void libraryActivity_OutsideATransaction_BooksTheRow() { + finishedGameWithoutProof(); + + publisher.publishEvent(new LibraryActivityEvent(USER)); + + assertThat(earlyRows()).isEqualTo(1); + } + + @Test + void libraryActivity_InsideATransaction_BooksTheRowAfterCommit() { + finishedGameWithoutProof(); + + transactionTemplate.executeWithoutResult(status -> { + publisher.publishEvent(new LibraryActivityEvent(USER)); + assertThat(earlyRows()).isZero(); + }); + + assertThat(earlyRows()).isEqualTo(1); + } + + @Test + void libraryActivity_ARollbackBooksNothing() { + finishedGameWithoutProof(); + + transactionTemplate.executeWithoutResult(status -> { + publisher.publishEvent(new LibraryActivityEvent(USER)); + status.setRollbackOnly(); + }); + + assertThat(earlyRows()).isZero(); + } + + @Test + void onLibraryActivity_SettleThatThrows_DoesNotReachThePublisher() { + finishedGameWithoutProof(); + jdbc.execute("create or replace function review_fail() returns trigger language plpgsql as " + + "$$ begin raise exception 'review: settle fails'; end $$"); + jdbc.execute("create trigger review_fail before insert on reward_ledger for each row when (new.user_id = " + + USER + ") execute function review_fail()"); + try { + assertThatCode(() -> publisher.publishEvent(new LibraryActivityEvent(USER))).doesNotThrowAnyException(); + + assertThat(earlyRows()).isZero(); + } finally { + jdbc.execute("drop trigger review_fail on reward_ledger"); + jdbc.execute("drop function review_fail()"); + } + } + + @Test + void libraryActivity_BlockedUser_BooksNothingAndThrowsNothing() { + finishedGameWithoutProof(); + jdbc.update("insert into blocked_user (user_id) values (?)", USER); + + assertThatCode(() -> publisher.publishEvent(new LibraryActivityEvent(USER))).doesNotThrowAnyException(); + + assertThat(earlyRows()).isZero(); + } +}