diff --git a/readme.md b/readme.md index 9050257..c1413b8 100644 --- a/readme.md +++ b/readme.md @@ -103,6 +103,7 @@ flowchart LR Facts["counted_user_game + playtime days + status log + time to beat + user_game_question"] --> Rule["OpenQuestion.of"] 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 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, @@ -132,11 +133,11 @@ 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` 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}` 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 + 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 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 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 f383e18..644c1c8 100644 --- a/src/main/java/core_service/modules/rewards/api/RewardController.java +++ b/src/main/java/core_service/modules/rewards/api/RewardController.java @@ -1,6 +1,7 @@ package core_service.modules.rewards.api; import core_service.modules.rewards.api.dto.EquipRequest; +import core_service.modules.rewards.api.dto.GamePointsDto; import core_service.modules.rewards.api.dto.PurchaseRequest; import core_service.modules.rewards.api.dto.RewardGamesDto; import core_service.modules.rewards.api.dto.WalletDto; @@ -41,6 +42,17 @@ public class RewardController { return ResponseEntity.ok(WalletDto.of(rewardService.wallet(principal.getIdAsLong(), principal.role()))); } + /** + * Settles first, like the wallet. + */ + @GetMapping("/games/{appid}") + public ResponseEntity getGamePoints(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long appid) { + return rewardService.gamePoints(principal.getIdAsLong(), appid) + .>map(points -> ResponseEntity.ok(GamePointsDto.of(points))) + .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(new ErrorResponse("GAME_NOT_FOUND", "The game is not in your library"))); + } + @GetMapping("/progress/{track}/games") public ResponseEntity getProgressGames(@AuthenticationPrincipal UserPrincipal principal, @PathVariable Track track, diff --git a/src/main/java/core_service/modules/rewards/api/dto/GamePointsDto.java b/src/main/java/core_service/modules/rewards/api/dto/GamePointsDto.java new file mode 100644 index 0000000..e41d693 --- /dev/null +++ b/src/main/java/core_service/modules/rewards/api/dto/GamePointsDto.java @@ -0,0 +1,32 @@ +package core_service.modules.rewards.api.dto; + +import core_service.modules.rewards.domain.NoPointsReason; +import core_service.modules.rewards.domain.PaidRow; +import core_service.modules.rewards.domain.RewardKind; +import core_service.modules.rewards.service.RewardService; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +/** + * @param paid the game rows of the game and the other editions of its set + * @param missing why a kind is not paid; STARTED, FINISHED (both finish kinds) and COMPLETED + */ +public record GamePointsDto(long balance, List paid, Map missing) { + + /** + * @param appid the edition the row was paid for + * @param bookedNow written by this call + */ + public record PaidDto(RewardKind kind, long appid, int points, Instant paidAt, boolean bookedNow) { + + static PaidDto of(PaidRow row) { + return new PaidDto(row.kind(), row.appid(), row.points(), row.paidAt(), row.bookedNow()); + } + } + + public static GamePointsDto of(RewardService.GamePoints points) { + return new GamePointsDto(points.balance(), points.paid().stream().map(PaidDto::of).toList(), points.missing()); + } +} diff --git a/src/main/java/core_service/modules/rewards/domain/GameRewardFacts.java b/src/main/java/core_service/modules/rewards/domain/GameRewardFacts.java new file mode 100644 index 0000000..cdc6ab9 --- /dev/null +++ b/src/main/java/core_service/modules/rewards/domain/GameRewardFacts.java @@ -0,0 +1,12 @@ +package core_service.modules.rewards.domain; + +/** + * What the library says about one game of a user, to explain a missing payment. + * + * @param finished status FINISHED or COMPLETED + * @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 + */ +public record GameRewardFacts(boolean manual, boolean finished, boolean completed, boolean hidden, boolean playstation, + boolean psnLinked, boolean startLogged, boolean finishLogged) {} diff --git a/src/main/java/core_service/modules/rewards/domain/NoPointsReason.java b/src/main/java/core_service/modules/rewards/domain/NoPointsReason.java new file mode 100644 index 0000000..5b8b141 --- /dev/null +++ b/src/main/java/core_service/modules/rewards/domain/NoPointsReason.java @@ -0,0 +1,35 @@ +package core_service.modules.rewards.domain; + +/** + * Why a kind of game points is not paid. The Frontend maps a code to a text and holds no rule. + */ +public enum NoPointsReason { + /** The game never went from BACKLOG to PLAYING. The Frontend shows no line. */ + NOT_STARTED, + /** The game is not FINISHED or COMPLETED. The Frontend shows no line. */ + NOT_FINISHED, + /** The game is not COMPLETED. The Frontend shows no line. */ + NOT_COMPLETED, + /** A hidden game pays nothing. */ + 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 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. */ + NEEDS_PROVEN_FINISH, + /** COMPLETED needs all achievements. */ + NOT_ALL_ACHIEVEMENTS +} diff --git a/src/main/java/core_service/modules/rewards/domain/PaidRow.java b/src/main/java/core_service/modules/rewards/domain/PaidRow.java new file mode 100644 index 0000000..faaac7b --- /dev/null +++ b/src/main/java/core_service/modules/rewards/domain/PaidRow.java @@ -0,0 +1,9 @@ +package core_service.modules.rewards.domain; + +import java.time.Instant; + +/** + * @param paidAt when the row was written + * @param bookedNow the row was written in the current transaction + */ +public record PaidRow(RewardKind kind, long appid, int points, Instant paidAt, boolean bookedNow) {} 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 7662963..a9bdc27 100644 --- a/src/main/java/core_service/modules/rewards/domain/RewardRules.java +++ b/src/main/java/core_service/modules/rewards/domain/RewardRules.java @@ -56,12 +56,22 @@ public final class RewardRules { if (f.manual()) { return Optional.of(new RewardEntry(RewardKind.FINISHED_MANUAL, ref, MANUAL_FINISH, f.finishedAt())); } - if (f.playtimeMinutes() <= 0 || !hasProof(f)) { + if (whyNoFinish(f).isPresent()) { return Optional.empty(); } return Optional.of(new RewardEntry(RewardKind.FINISHED, ref, finishPoints(f.normallyMinutes()), f.finishedAt())); } + /** + * Why a Steam finish pays nothing; empty when it pays. + */ + public static Optional whyNoFinish(FinishFacts f) { + if (f.playtimeMinutes() <= 0) { + return Optional.of(NoPointsReason.NO_PLAYTIME); + } + return hasProof(f) ? Optional.empty() : Optional.of(NoPointsReason.NO_PROOF); + } + private static boolean hasProof(FinishFacts f) { return f.endingUnlocked() || f.playedAround() || (f.normallyMinutes() != null && f.playedSinceMinutes() >= PROOF_SHARE * f.normallyMinutes()); @@ -81,6 +91,72 @@ public final class RewardRules { return Optional.of(new RewardEntry(RewardKind.COMPLETED, String.valueOf(f.appid()), COMPLETED_BONUS, f.finishedAt())); } + /** + * @return why nothing is paid for the game at all, null when nothing blocks it + */ + public static NoPointsReason blocked(GameRewardFacts g) { + if (g.hidden()) { + return NoPointsReason.HIDDEN; + } + if (g.playstation()) { + return NoPointsReason.PLAYSTATION; + } + return g.psnLinked() ? NoPointsReason.PLAYSTATION_LINKED : null; + } + + /** + * @param s the start candidate of the game, null when it is none + * @return why no STARTED row is paid, null when nothing stops it + */ + public static NoPointsReason noStartReason(GameRewardFacts g, StartFacts s) { + if (!g.startLogged()) { + return NoPointsReason.NOT_STARTED; + } + if (g.manual()) { + return NoPointsReason.MANUAL_GAME; + } + return s != null && !s.playedAfter() ? NoPointsReason.NO_PLAY_AFTER_START : null; + } + + /** + * @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 + */ + public static NoPointsReason noFinishReason(GameRewardFacts g, FinishFacts f, boolean weekFull) { + if (!g.finished()) { + return NoPointsReason.NOT_FINISHED; + } + if (!g.finishLogged()) { + return NoPointsReason.BEFORE_HISTORY; + } + if (f == null) { + return null; + } + if (f.manual()) { + return weekFull ? NoPointsReason.WEEKLY_LIMIT : null; + } + return whyNoFinish(f).orElse(null); + } + + /** + * @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 + * @return why no COMPLETED row is paid, null when nothing stops it + */ + public static NoPointsReason noCompletedReason(GameRewardFacts g, FinishFacts c, boolean provenFinishPaid) { + if (!g.completed()) { + return NoPointsReason.NOT_COMPLETED; + } + if (g.manual()) { + return NoPointsReason.MANUAL_GAME; + } + if (!provenFinishPaid) { + return NoPointsReason.NEEDS_PROVEN_FINISH; + } + return c != null && !c.allAchievements() ? NoPointsReason.NOT_ALL_ACHIEVEMENTS : null; + } + public static String monthRef(YearMonth month) { return month.toString(); } 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 2ea32ad..004ca02 100644 --- a/src/main/java/core_service/modules/rewards/repo/RewardFactsRepository.java +++ b/src/main/java/core_service/modules/rewards/repo/RewardFactsRepository.java @@ -1,6 +1,8 @@ package core_service.modules.rewards.repo; import core_service.modules.rewards.domain.FinishFacts; +import core_service.modules.rewards.domain.GameRewardFacts; +import core_service.modules.rewards.domain.PaidRow; import core_service.modules.rewards.domain.RewardKind; import core_service.modules.rewards.domain.RewardRules; import core_service.modules.rewards.domain.StartFacts; @@ -77,12 +79,20 @@ public class RewardFactsRepository { * linked set is a candidate; the service books one per set. */ public List startCandidates(long userId, ZoneId zone) { + return startCandidates(userId, zone, null); + } + + /** + * @param appid only this game, null for all + */ + public List startCandidates(long userId, ZoneId zone, Long appid) { 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' + 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' @@ -92,7 +102,7 @@ public class RewardFactsRepository { where not %1$s order by c.started_at, c.appid """.formatted(paidInSet("c", "'STARTED'")), - new MapSqlParameterSource("userId", userId).addValue("zone", zone.getId()), + 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"))); } @@ -105,6 +115,13 @@ public class RewardFactsRepository { * edition of a linked set is a candidate; the service books one per set. */ public List finishCandidates(long userId, ZoneId zone) { + return finishCandidates(userId, zone, null); + } + + /** + * @param appid only this game, null for all + */ + public List finishCandidates(long userId, ZoneId zone, Long appid) { return jdbc.query(""" select c.appid, ug.source = 'MANUAL' as manual, ug.status = 'COMPLETED' as completed, ug.playtime_minutes, t.normally_minutes, @@ -120,13 +137,12 @@ public class RewardFactsRepository { 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') + 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') - 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 @@ -135,7 +151,7 @@ public class RewardFactsRepository { order by c.finished_at, c.appid """.formatted(paidInSet("c", "'FINISHED', 'FINISHED_MANUAL'")), new MapSqlParameterSource("userId", userId).addValue("zone", zone.getId()) - .addValue("aroundDays", RewardRules.PLAYED_AROUND_DAYS), + .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), @@ -148,6 +164,13 @@ public class RewardFactsRepository { * Every edition of a linked set is a candidate; the service books one per set. */ public List completedCandidates(long userId) { + return completedCandidates(userId, null); + } + + /** + * @param appid only this game, null for all + */ + public List completedCandidates(long userId, Long appid) { 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) @@ -166,13 +189,59 @@ public class RewardFactsRepository { 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), + """.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())); } + /** + * The flags that explain a missing payment of one game; empty when the user has no such game. + */ + public Optional game(long userId, long appid) { + return jdbc.query(""" + 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 + 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"))) + .stream().findFirst(); + } + + /** + * 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). + */ + public List paidRows(long userId, long appid) { + return jdbc.query(""" + select l.kind, l.ref, l.points, l.created_at, l.created_at = now() as booked_now + from reward_ledger l + where l.user_id = :userId and l.kind in (:kinds) + and (l.ref = cast(:appid as text) + or l.ref in (select o.appid::text from user_game_set o + join user_game_set me on me.user_id = o.user_id and me.set_id = o.set_id + where me.user_id = :userId and me.appid = :appid)) + order by l.created_at, l.id + """, new MapSqlParameterSource("userId", userId).addValue("appid", appid) + .addValue("kinds", RewardKind.GAME_KINDS.stream().map(Enum::name).toList()), + (rs, i) -> new PaidRow(RewardKind.valueOf(rs.getString("kind")), Long.parseLong(rs.getString("ref")), + rs.getInt("points"), rs.getTimestamp("created_at").toInstant(), rs.getBoolean("booked_now"))); + } + /** * Playtime minutes per month of the Steam playtime days (day is a Berlin date); the days of a PlayStation row, or of * a game a PlayStation title is linked to, are left out. Hidden games count too: hiding 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 e8d5975..2ca64d3 100644 --- a/src/main/java/core_service/modules/rewards/service/RewardService.java +++ b/src/main/java/core_service/modules/rewards/service/RewardService.java @@ -1,14 +1,18 @@ package core_service.modules.rewards.service; import core_service.modules.rewards.domain.FinishFacts; +import core_service.modules.rewards.domain.GameRewardFacts; 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.NoPointsReason; +import core_service.modules.rewards.domain.PaidRow; import core_service.modules.rewards.domain.RewardEntry; import core_service.modules.rewards.domain.RewardGame; import core_service.modules.rewards.domain.RewardKind; import core_service.modules.rewards.domain.RewardRules; import core_service.modules.rewards.domain.ShopItem; +import core_service.modules.rewards.domain.StartFacts; import core_service.modules.rewards.domain.Track; import core_service.modules.rewards.domain.Unlocks; import core_service.modules.rewards.repo.RewardFactsRepository; @@ -211,6 +215,71 @@ public class RewardService { } } + /** + * @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 + */ + public record GamePoints(long balance, List paid, Map missing) {} + + /** + * Settles first, like the wallet; {@link PaidRow#bookedNow()} relies on settle and read sharing this one + * transaction. + * + * @return empty when the user has no such game; nothing is settled then + */ + @Transactional + public Optional gamePoints(long userId, long appid) { + Optional found = facts.game(userId, appid); + if (found.isEmpty()) { + return Optional.empty(); + } + settleLocked(userId); + List paid = facts.paidRows(userId, appid); + return Optional.of(new GamePoints(ledger.balance(userId), paid, missing(userId, appid, found.get(), paid))); + } + + private Map missing(long userId, long appid, GameRewardFacts game, List paid) { + Map missing = new EnumMap<>(RewardKind.class); + NoPointsReason blocked = RewardRules.blocked(game); + if (blocked != null) { + if (game.startLogged() && noRow(paid, List.of(RewardKind.STARTED))) { + missing.put(RewardKind.STARTED, blocked); + } + if (noRow(paid, RewardKind.FINISH_KINDS)) { + missing.put(RewardKind.FINISHED, blocked); + } + if (game.completed() && noRow(paid, List.of(RewardKind.COMPLETED))) { + missing.put(RewardKind.COMPLETED, blocked); + } + return missing; + } + if (noRow(paid, List.of(RewardKind.STARTED))) { + StartFacts start = facts.startCandidates(userId, zone, appid).stream().findFirst().orElse(null); + putReason(missing, RewardKind.STARTED, RewardRules.noStartReason(game, start)); + } + 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)); + } + 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)))); + } + return missing; + } + + private static boolean noRow(List paid, List kinds) { + return paid.stream().noneMatch(row -> kinds.contains(row.kind())); + } + + private static void putReason(Map missing, RewardKind kind, NoPointsReason reason) { + if (reason != null) { + missing.put(kind, reason); + } + } + private Map settleLocked(long userId) { if (!facts.isActive(userId)) { return Map.of(); @@ -266,20 +335,21 @@ public class RewardService { 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; - } + if (entry.kind() == RewardKind.FINISHED_MANUAL && weekFull(userId, entry.eventAt())) { + continue; } paid += bookOnePerSet(userId, entry); } return paid; } + // 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), + startOf(monday.plusWeeks(1))) >= RewardRules.MANUAL_PER_WEEK; + } + private int settleCompleted(long userId) { return facts.completedCandidates(userId).stream() .flatMap(f -> RewardRules.completed(f).stream()).mapToInt(e -> bookOnePerSet(userId, e)).sum(); 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 6d0c88a..3862c98 100644 --- a/src/test/java/core_service/modules/rewards/api/RewardControllerTest.java +++ b/src/test/java/core_service/modules/rewards/api/RewardControllerTest.java @@ -34,6 +34,8 @@ class RewardControllerTest extends AbstractIntegrationTest { private static final long USER = 1L; private static final long ADMIN = 2L; private static final long GAME = 4_100_001L; + private static final long EDITION = 4_100_002L; + private static final long OTHER_USER = 99L; @Autowired private MockMvc mockMvc; @@ -49,7 +51,11 @@ class RewardControllerTest extends AbstractIntegrationTest { jdbc.update("delete from reward_ledger where user_id in (?, ?)", USER, ADMIN); jdbc.update("delete from reward_equipped where user_id in (?, ?)", USER, ADMIN); jdbc.update("delete from user_game_playtime_day where user_id = ?", USER); - jdbc.update("delete from user_game where user_id = ?", USER); + jdbc.update("delete from user_game where user_id in (?, ?)", USER, OTHER_USER); + jdbc.update("delete from user_psn_title where user_id = ?", USER); + jdbc.update("delete from game_link where appid in (?, ?)", GAME, EDITION); + jdbc.update("delete from game_achievement_schema_meta where appid = ?", GAME); + jdbc.update("delete from reward_ledger where user_id = ?", OTHER_USER); } private void game(long appid, String source, String status, int playtime) { @@ -117,6 +123,232 @@ class RewardControllerTest extends AbstractIntegrationTest { assertThat(rows("FINISHED_MANUAL")).isEqualTo(3); } + @Test + void gamePoints_ProvenSteamFinish_PaysOnTheCallAndSaysBookedNow() 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/games/" + GAME)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.balance").value(30)) + .andExpect(jsonPath("$.paid.length()").value(1)) + .andExpect(jsonPath("$.paid[0].kind").value("FINISHED")) + .andExpect(jsonPath("$.paid[0].points").value(30)) + .andExpect(jsonPath("$.paid[0].bookedNow").value(true)); + } + + private void playedToday(long appid) { + jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, current_date, ?, 30)", + USER, appid); + } + + private ResultActions gamePoints(long appid) throws Exception { + return mockMvc.perform(get("/api/rewards/games/" + appid)); + } + + @Test + void gamePoints_SecondCall_SameRowNotBookedNow() throws Exception { + game(GAME, "STEAM", "BACKLOG", 120); + playedToday(GAME); + setStatus(GAME, "FINISHED"); + gamePoints(GAME).andExpect(jsonPath("$.paid[0].bookedNow").value(true)); + + gamePoints(GAME) + .andExpect(jsonPath("$.paid.length()").value(1)) + .andExpect(jsonPath("$.paid[0].bookedNow").value(false)) + .andExpect(jsonPath("$.balance").value(30)); + } + + @Test + void gamePoints_SteamFinishWithoutProof_MissingFinishIsNoProof() throws Exception { + game(GAME, "STEAM", "BACKLOG", 120); + setStatus(GAME, "FINISHED"); + + gamePoints(GAME) + .andExpect(jsonPath("$.paid").isEmpty()) + .andExpect(jsonPath("$.missing.FINISHED").value("NO_PROOF")); + } + + @Test + void gamePoints_FourthManualFinishInAWeek_MissingFinishIsWeeklyLimit() throws Exception { + for (long appid = -4_100_010L; appid > -4_100_014L; appid--) { + game(appid, "MANUAL", "BACKLOG", 0); + setStatus(appid, "FINISHED"); + } + + gamePoints(-4_100_013L) + .andExpect(jsonPath("$.paid").isEmpty()) + .andExpect(jsonPath("$.missing.FINISHED").value("WEEKLY_LIMIT")); + gamePoints(-4_100_010L).andExpect(jsonPath("$.paid[0].kind").value("FINISHED_MANUAL")); + } + + @Test + void gamePoints_OtherEditionPaid_ListsItsRow() throws Exception { + game(GAME, "STEAM", "BACKLOG", 120); + game(EDITION, "STEAM", "BACKLOG", 0); + jdbc.update("insert into game_link (appid, set_id) values (?, 4100), (?, 4100)", GAME, EDITION); + playedToday(GAME); + jdbc.update("update user_game set status = 'FINISHED' where user_id = ?", USER); + + gamePoints(EDITION) + .andExpect(jsonPath("$.paid.length()").value(1)) + .andExpect(jsonPath("$.paid[0].kind").value("FINISHED")) + .andExpect(jsonPath("$.paid[0].appid").value(GAME)) + .andExpect(jsonPath("$.missing.FINISHED").doesNotExist()); + } + + private void allAchievements() { + 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)" + + " on conflict (user_id, appid) do update set achievements_unlocked = 10", USER, GAME); + } + + @Test + void gamePoints_StartedFinishedCompleted_ListsAllThreeRowsWithDates() throws Exception { + game(GAME, "STEAM", "BACKLOG", 120); + playedToday(GAME); + allAchievements(); + setStatus(GAME, "PLAYING"); + setStatus(GAME, "FINISHED"); + setStatus(GAME, "COMPLETED"); + + gamePoints(GAME) + .andExpect(jsonPath("$.paid.length()").value(3)) + .andExpect(jsonPath("$.paid[?(@.kind == 'STARTED')].points").value(5)) + .andExpect(jsonPath("$.paid[?(@.kind == 'FINISHED')].points").value(30)) + .andExpect(jsonPath("$.paid[?(@.kind == 'COMPLETED')].points").value(40)) + .andExpect(jsonPath("$.paid[0].paidAt").isNotEmpty()) + .andExpect(jsonPath("$.missing").isEmpty()) + .andExpect(jsonPath("$.balance").value(75)); + } + + @Test + void gamePoints_CompletedWithoutAllAchievements_MissingCompletedIsNotAllAchievements() throws Exception { + game(GAME, "STEAM", "BACKLOG", 120); + playedToday(GAME); + setStatus(GAME, "FINISHED"); + setStatus(GAME, "COMPLETED"); + + gamePoints(GAME) + .andExpect(jsonPath("$.paid.length()").value(1)) + .andExpect(jsonPath("$.paid[0].kind").value("FINISHED")) + .andExpect(jsonPath("$.missing.COMPLETED").value("NOT_ALL_ACHIEVEMENTS")); + } + + @Test + void gamePoints_HiddenGame_OneHiddenReasonOnly() throws Exception { + game(GAME, "STEAM", "BACKLOG", 120); + playedToday(GAME); + setStatus(GAME, "FINISHED"); + jdbc.update("update user_game set hidden_at = now() where user_id = ?", USER); + + gamePoints(GAME) + .andExpect(jsonPath("$.paid").isEmpty()) + .andExpect(jsonPath("$.missing.length()").value(1)) + .andExpect(jsonPath("$.missing.FINISHED").value("HIDDEN")); + } + + @Test + void gamePoints_HiddenAfterPaidStart_MissingFinishIsHidden() throws Exception { + game(GAME, "STEAM", "BACKLOG", 120); + playedToday(GAME); + setStatus(GAME, "PLAYING"); + gamePoints(GAME).andExpect(jsonPath("$.paid[0].kind").value("STARTED")); + jdbc.update("update user_game set hidden_at = now() where user_id = ?", USER); + setStatus(GAME, "FINISHED"); + + gamePoints(GAME).andExpect(jsonPath("$.missing.FINISHED").value("HIDDEN")); + } + + @Test + void gamePoints_HiddenAfterPaidFinish_MissingCompletedIsHidden() throws Exception { + game(GAME, "STEAM", "BACKLOG", 120); + playedToday(GAME); + setStatus(GAME, "FINISHED"); + gamePoints(GAME).andExpect(jsonPath("$.paid[0].kind").value("FINISHED")); + jdbc.update("update user_game set hidden_at = now() where user_id = ?", USER); + allAchievements(); + setStatus(GAME, "COMPLETED"); + + gamePoints(GAME).andExpect(jsonPath("$.missing.COMPLETED").value("HIDDEN")); + } + + @Test + void gamePoints_HiddenStartedGame_MissingStartIsHidden() throws Exception { + game(GAME, "STEAM", "BACKLOG", 120); + playedToday(GAME); + jdbc.update("update user_game set hidden_at = now() where user_id = ?", USER); + setStatus(GAME, "PLAYING"); + + gamePoints(GAME).andExpect(jsonPath("$.missing.STARTED").value("HIDDEN")); + } + + @Test + void gamePoints_PsnRow_SaysPlaystation() throws Exception { + game(GAME, "PSN", "BACKLOG", 120); + setStatus(GAME, "FINISHED"); + + gamePoints(GAME).andExpect(jsonPath("$.missing.FINISHED").value("PLAYSTATION")); + } + + @Test + void gamePoints_LinkedPsnTitle_SaysPlaystationLinked() 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); + + gamePoints(GAME).andExpect(jsonPath("$.missing.FINISHED").value("PLAYSTATION_LINKED")); + } + + @Test + void gamePoints_FinishBeforeTheStatusLog_SaysBeforeHistory() throws Exception { + game(GAME, "STEAM", "FINISHED", 120); + jdbc.update("delete from user_game_status_change where user_id = ?", USER); + + gamePoints(GAME).andExpect(jsonPath("$.missing.FINISHED").value("BEFORE_HISTORY")); + } + + @Test + void gamePoints_FinishedPlayingFinished_PaysOnce() throws Exception { + game(GAME, "STEAM", "BACKLOG", 120); + playedToday(GAME); + setStatus(GAME, "FINISHED"); + gamePoints(GAME).andExpect(jsonPath("$.balance").value(30)); + + setStatus(GAME, "PLAYING"); + setStatus(GAME, "FINISHED"); + + gamePoints(GAME).andExpect(jsonPath("$.balance").value(30)); + assertThat(rows("FINISHED")).isEqualTo(1); + } + + @Test + void gamePoints_GameNotInLibrary_Is404() throws Exception { + gamePoints(GAME) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("GAME_NOT_FOUND")); + } + + @Test + void gamePoints_AnotherUsersGame_Is404AndBooksNothingForThem() 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); + jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, current_date, ?, 30)", + OTHER_USER, GAME); + jdbc.update("update user_game set status = 'FINISHED' where user_id = ?", OTHER_USER); + + gamePoints(GAME).andExpect(status().isNotFound()); + + assertThat(jdbc.queryForObject("select count(*) from reward_ledger where user_id = ?", Integer.class, OTHER_USER)) + .isZero(); + } + @Test void wallet_ManualGameInTheHistoryHasItsName() throws Exception { game(-4_100_020L, "MANUAL", "BACKLOG", 0); 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 a1409f6..40fe5ca 100644 --- a/src/test/java/core_service/modules/rewards/domain/RewardRulesTest.java +++ b/src/test/java/core_service/modules/rewards/domain/RewardRulesTest.java @@ -24,6 +24,86 @@ class RewardRulesTest extends AbstractUnitTest { return RewardRules.finish(facts).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); + } + + 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); + + assertThat(RewardRules.blocked(all)).isEqualTo(NoPointsReason.HIDDEN); + assertThat(RewardRules.blocked(new GameRewardFacts(false, true, false, false, true, true, true, true))) + .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); + GameRewardFacts g = game(true, true, false, true); + + assertThat(RewardRules.noFinishReason(g, manual, true)).isEqualTo(NoPointsReason.WEEKLY_LIMIT); + assertThat(RewardRules.noFinishReason(g, manual, false)).isNull(); + } + + @Test + void noFinishReason_SteamWithoutProof_IsNoProof() { + assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(60, 300, false, false), false)) + .isEqualTo(NoPointsReason.NO_PROOF); + } + + @Test + void noFinishReason_SteamWithoutPlaytime_IsNoPlaytime() { + assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(0, 300, true, true), false)) + .isEqualTo(NoPointsReason.NO_PLAYTIME); + } + + @Test + void noFinishReason_NoStatusLog_IsBeforeHistory() { + assertThat(RewardRules.noFinishReason(game(false, true, false, false), null, false)) + .isEqualTo(NoPointsReason.BEFORE_HISTORY); + assertThat(RewardRules.noFinishReason(game(false, false, false, false), null, false)) + .isEqualTo(NoPointsReason.NOT_FINISHED); + } + + @Test + void noStartReason_ManualGame_IsManualGame() { + assertThat(RewardRules.noStartReason(game(true, false, false, false), null)).isEqualTo(NoPointsReason.MANUAL_GAME); + } + + @Test + void noStartReason_NoPlayAfterStart() { + StartFacts idle = new StartFacts(220L, false, AT, false); + + assertThat(RewardRules.noStartReason(game(false, false, false, false), idle)) + .isEqualTo(NoPointsReason.NO_PLAY_AFTER_START); + assertThat(RewardRules.noStartReason(new GameRewardFacts(false, false, false, false, false, false, false, false), + null)).isEqualTo(NoPointsReason.NOT_STARTED); + } + + @Test + void noCompletedReason_WithoutProvenFinish_IsNeedsProvenFinish() { + GameRewardFacts g = game(false, true, true, true); + + assertThat(RewardRules.noCompletedReason(g, null, false)).isEqualTo(NoPointsReason.NEEDS_PROVEN_FINISH); + assertThat(RewardRules.noCompletedReason(STEAM_FINISHED, null, true)).isEqualTo(NoPointsReason.NOT_COMPLETED); + assertThat(RewardRules.noCompletedReason(game(true, true, true, true), null, true)) + .isEqualTo(NoPointsReason.MANUAL_GAME); + } + + @Test + void noCompletedReason_MissingAchievements() { + FinishFacts open = new FinishFacts(220L, false, true, 60, 0, null, false, false, false, AT); + + assertThat(RewardRules.noCompletedReason(game(false, true, true, true), open, true)) + .isEqualTo(NoPointsReason.NOT_ALL_ACHIEVEMENTS); + } + @Test void finish_PaysByTheLengthOfTheGame() { assertThat(points(steam(60, null, true, false))).isEqualTo(30); 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 5aff5ee..a845e24 100644 --- a/src/test/java/core_service/modules/rewards/repo/RewardFactsRepositoryTest.java +++ b/src/test/java/core_service/modules/rewards/repo/RewardFactsRepositoryTest.java @@ -1,6 +1,7 @@ package core_service.modules.rewards.repo; import core_service.modules.rewards.domain.FinishFacts; +import core_service.modules.rewards.domain.GameRewardFacts; import core_service.modules.rewards.domain.RewardEntry; import core_service.modules.rewards.domain.RewardKind; import core_service.modules.rewards.domain.StartFacts; @@ -64,6 +65,68 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest { USER, day, GAME); } + @Test + void game_FlagsHiddenPsnLinkedManualAndStatusLog() { + game("BACKLOG", 120); + setStatus("PLAYING"); + setStatus("FINISHED"); + jdbc.update("update user_game set hidden_at = now() where user_id = ?", USER); + jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', ?)", USER, GAME); + + assertThat(repository.game(USER, GAME)).hasValue( + new GameRewardFacts(false, true, false, true, false, true, true, true)); + } + + @Test + void game_RemovedGame_IsEmpty() { + game("BACKLOG", 120); + jdbc.update("update user_game set removed_at = now() where user_id = ?", USER); + + assertThat(repository.game(USER, GAME)).isEmpty(); + assertThat(repository.game(USER, EDITION)).isEmpty(); + } + + @Test + void startFinishCompletedCandidates_WithAppid_OnlyThatGame() { + game("BACKLOG", 120); + jdbc.update("insert into steam_game (appid, name) values (?, 'Other') on conflict do nothing", EDITION); + jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes) values (?, ?, 'BACKLOG', 120)", + USER, EDITION); + jdbc.update("update user_game set status = 'PLAYING' where user_id = ?", USER); + jdbc.update("update user_game set status = 'COMPLETED' where user_id = ?", USER); + + assertThat(repository.startCandidates(USER, BERLIN)).hasSize(2); + assertThat(repository.startCandidates(USER, BERLIN, EDITION)).extracting(StartFacts::appid) + .containsExactly(EDITION); + assertThat(repository.finishCandidates(USER, BERLIN)).hasSize(2); + assertThat(repository.finishCandidates(USER, BERLIN, GAME)).extracting(FinishFacts::appid) + .containsExactly(GAME); + + ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(GAME), 30, Instant.now())); + ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(EDITION), 30, Instant.now())); + + assertThat(repository.completedCandidates(USER)).hasSize(2); + assertThat(repository.completedCandidates(USER, GAME)).extracting(FinishFacts::appid).containsExactly(GAME); + } + + @Test + void paidRows_ListsTheRowOfAnotherEditionOfTheSet() { + game("BACKLOG", 120); + jdbc.update("insert into steam_game (appid, name) values (?, 'Edition') on conflict do nothing", EDITION); + jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes) values (?, ?, 'BACKLOG', 0)", + USER, EDITION); + jdbc.update("insert into game_link (appid, set_id) values (?, 1), (?, 1)", GAME, EDITION); + ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(GAME), 30, Instant.now())); + ledger.add(USER, new RewardEntry(RewardKind.MONTH, "2026-09", 40, Instant.now())); + + assertThat(repository.paidRows(USER, EDITION)).singleElement().satisfies(row -> { + assertThat(row.kind()).isEqualTo(RewardKind.FINISHED); + assertThat(row.appid()).isEqualTo(GAME); + assertThat(row.points()).isEqualTo(30); + }); + assertThat(repository.paidRows(7777L, GAME)).isEmpty(); + } + @Test void finishCandidates_AppearsAfterTheChangeWithItsFacts() { game("BACKLOG", 120);