feat(rewards): pay early finish points and top-up on proof
Build, Test & Push Core Service / quality-check (push) Failing after 1m49s
Build, Test & Push Core Service / docker (push) Skipped

A linked PlayStation title no longer blocks. Its playtime never
proves a finish. An unproven Steam finish pays 10 early points,
the rest comes with proof. Back pay only from app.rewards.early-since.
This commit is contained in:
lobo committed 2026-10-09 16:00:06 +02:00
1 parent 7c4b475321
commit b24fd91e88
17 files changed
+920 -102

No files matched your search

+8 -3
View File
@@ -104,6 +104,7 @@ flowchart LR
Rule --> Card["question card"] Rule --> Card["question card"]
Answer["answer"] --> Backlog["BacklogService"] --> UserGame["user_game"] --> Trigger["V14 trigger: status log"] --> Settle["RewardService.settle"] Answer["answer"] --> Backlog["BacklogService"] --> UserGame["user_game"] --> Trigger["V14 trigger: status log"] --> Settle["RewardService.settle"]
Answer -->|"Frontend: GET /api/rewards/games/{appid}"| Settle Answer -->|"Frontend: GET /api/rewards/games/{appid}"| Settle
Sync["library sync"] --> LibraryActivityEvent --> Settle
Answer -->|no| Snooze["user_game_question"] 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, - **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 (old and new status, time, whether the user chose it), whatever code path made it. It starts with V14; older
changes are not known. changes are not known.
- **Pile Points** (`modules/rewards`): users earn points for playing games off the pile, in an append-only ledger - **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 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 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 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
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 `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), 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 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 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 | | 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/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 | | `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 | | `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 | | `PUT` | `/api/rewards/equipped/{slot}` (`{item}`, null = take off; `200` wallet, `409 NOT_OWNED`, `400 WRONG_SLOT`) | JWT |
@@ -30,8 +30,9 @@ public class GameMergeRepository {
private static final List<String> CATALOG_TABLES = List.of("game_mood", "game_speedrun", "game_time_to_beat", private static final List<String> 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"); "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. // 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', 'COMPLETED')"; 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. // 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 private static final String LEDGER_MOVED = "l.ref = cast(:from as text) and l.kind in " + GAME_KINDS
@@ -25,6 +25,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Optional; import java.util.Optional;
@RestController @RestController
@@ -32,6 +33,8 @@ import java.util.Optional;
@RequiredArgsConstructor @RequiredArgsConstructor
public class RewardController { public class RewardController {
private static final int MAX_EXPECTED = 200;
private final RewardService rewardService; private final RewardService rewardService;
/** /**
@@ -53,6 +56,18 @@ public class RewardController {
.body(new ErrorResponse("GAME_NOT_FOUND", "The game is not in your library"))); .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<Long> 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") @GetMapping("/progress/{track}/games")
public ResponseEntity<?> getProgressGames(@AuthenticationPrincipal UserPrincipal principal, public ResponseEntity<?> getProgressGames(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable Track track, @PathVariable Track track,
@@ -11,6 +11,8 @@ import java.time.Instant;
* @param playedAround a playtime day within 30 days before the finish or any time after * @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 allAchievements all achievements of a game with some are unlocked
* @param finishedAt the first status change to FINISHED or COMPLETED * @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, 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) {}
@@ -7,6 +7,7 @@ package core_service.modules.rewards.domain;
* @param completed status COMPLETED * @param completed status COMPLETED
* @param startLogged a BACKLOG to PLAYING change is in the status log * @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 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, 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) {}
@@ -14,21 +14,23 @@ public enum NoPointsReason {
HIDDEN, HIDDEN,
/** A PlayStation game pays nothing. */ /** A PlayStation game pays nothing. */
PLAYSTATION, 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). */ /** The game was finished before the status history began (starter points cover it). */
BEFORE_HISTORY, BEFORE_HISTORY,
/** A game added by hand has no playtime to prove a start or a completion. */ /** A game added by hand has no playtime to prove a start or a completion. */
MANUAL_GAME, MANUAL_GAME,
/** A start needs one playtime day on or after the start. */ /** A start needs one playtime day on or after the start. */
NO_PLAY_AFTER_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. */ /** A Steam finish needs playtime. */
NO_PLAYTIME, NO_PLAYTIME,
/** A Steam finish needs proof of fresh play. */ /** A Steam finish needs proof of fresh play. */
NO_PROOF, NO_PROOF,
/** Three manual finishes are paid in that week already. */ /** Three manual finishes are paid in that week already. */
WEEKLY_LIMIT, 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, NEEDS_PROVEN_FINISH,
/** COMPLETED needs all achievements. */ /** COMPLETED needs all achievements. */
NOT_ALL_ACHIEVEMENTS NOT_ALL_ACHIEVEMENTS
@@ -3,7 +3,16 @@ package core_service.modules.rewards.domain;
import java.util.List; import java.util.List;
public enum RewardKind { 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 * An unlock-only item reached by progress: ref = item code, points 0. Not in {@link #GAME_KINDS}, so a merge never
* moves it. * moves it.
@@ -13,10 +22,16 @@ public enum RewardKind {
/** /**
* The kinds whose ref is a game (an appid). * The kinds whose ref is a game (an appid).
*/ */
public static final List<RewardKind> GAME_KINDS = List.of(STARTED, FINISHED, FINISHED_MANUAL, COMPLETED); public static final List<RewardKind> 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. * The kinds that count as "finished a game" for the weekly streak.
*/ */
public static final List<RewardKind> FINISH_KINDS = List.of(FINISHED, FINISHED_MANUAL); public static final List<RewardKind> 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<RewardKind> CAPPED_KINDS = List.of(FINISHED_MANUAL, FINISHED_EARLY);
} }
@@ -1,6 +1,7 @@
package core_service.modules.rewards.domain; package core_service.modules.rewards.domain;
import java.time.DateTimeException; import java.time.DateTimeException;
import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.YearMonth; import java.time.YearMonth;
import java.time.temporal.ChronoField; import java.time.temporal.ChronoField;
@@ -20,6 +21,7 @@ public final class RewardRules {
public static final int MID_HOURS = 10; public static final int MID_HOURS = 10;
public static final int LONG_HOURS = 30; public static final int LONG_HOURS = 30;
public static final int COMPLETED_BONUS = 40; 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_FINISH = 15;
public static final int MANUAL_PER_WEEK = 3; public static final int MANUAL_PER_WEEK = 3;
public static final int MONTH_BONUS = 40; 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 * A game added by hand pays {@link #MANUAL_FINISH}. A Steam game pays by its length once it has playtime plus one
* length and needs playtime plus one proof: the ending achievement, playtime around the finish, or playtime of * proof: the ending achievement, playtime around the finish, or playtime of at least {@link #PROOF_SHARE} of the
* at least {@link #PROOF_SHARE} of the normal time. * 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<RewardEntry> finish(FinishFacts f) { public static Optional<RewardEntry> finish(FinishFacts f, Instant earlySince) {
String ref = String.valueOf(f.appid()); String ref = String.valueOf(f.appid());
if (f.manual()) { if (f.manual()) {
return Optional.of(new RewardEntry(RewardKind.FINISHED_MANUAL, ref, MANUAL_FINISH, f.finishedAt())); return Optional.of(new RewardEntry(RewardKind.FINISHED_MANUAL, ref, MANUAL_FINISH, f.finishedAt()));
} }
if (whyNoFinish(f).isPresent()) { boolean proven = whyNoFinish(f).isEmpty();
return Optional.empty(); 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()) { if (g.hidden()) {
return NoPointsReason.HIDDEN; return NoPointsReason.HIDDEN;
} }
if (g.playstation()) { return g.playstation() ? NoPointsReason.PLAYSTATION : null;
return NoPointsReason.PLAYSTATION;
}
return g.psnLinked() ? NoPointsReason.PLAYSTATION_LINKED : null;
} }
/** /**
@@ -115,15 +142,18 @@ public final class RewardRules {
if (g.manual()) { if (g.manual()) {
return NoPointsReason.MANUAL_GAME; 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 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 * @param weekFull the 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 * @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()) { if (!g.finished()) {
return NoPointsReason.NOT_FINISHED; return NoPointsReason.NOT_FINISHED;
} }
@@ -136,12 +166,32 @@ public final class RewardRules {
if (f.manual()) { if (f.manual()) {
return weekFull ? NoPointsReason.WEEKLY_LIMIT : null; return weekFull ? NoPointsReason.WEEKLY_LIMIT : null;
} }
return whyNoFinish(f).orElse(null); Optional<NoPointsReason> 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 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 * @return why no COMPLETED row is paid, null when nothing stops it
*/ */
public static NoPointsReason noCompletedReason(GameRewardFacts g, FinishFacts c, boolean provenFinishPaid) { public static NoPointsReason noCompletedReason(GameRewardFacts g, FinishFacts c, boolean provenFinishPaid) {
@@ -15,6 +15,7 @@ import org.springframework.stereotype.Repository;
import java.sql.Date; import java.sql.Date;
import java.time.YearMonth; import java.time.YearMonth;
import java.time.ZoneId; import java.time.ZoneId;
import java.util.Collection;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; 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 * 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 * library module owns the entities and a module may not depend on another. PlayStation rows give no
* rows (their playtime is PlayStation's number, and a title can be linked to a game later) give no facts. * facts. A PlayStation title linked to a game does not block it, but its playtime days never prove a finish.
*/ */
@Repository @Repository
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -34,6 +35,11 @@ public class RewardFactsRepository {
private static final String NOT_DELETED_OR_BLOCKED = 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)"; "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; private final NamedParameterJdbcTemplate jdbc;
/** /**
@@ -89,19 +95,17 @@ public class RewardFactsRepository {
return jdbc.query(""" return jdbc.query("""
select c.appid, ug.source = 'MANUAL' as manual, c.started_at, 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 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 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' where user_id = :userId and from_status = 'BACKLOG' and to_status = 'PLAYING'
and (cast(:appid as bigint) is null or appid = :appid) and (cast(:appid as bigint) is null or appid = :appid)
group by user_id, appid) c 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 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 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 left join user_game_set st on st.user_id = c.user_id and st.appid = c.appid
where not %1$s where not %1$s
order by c.started_at, c.appid 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), new MapSqlParameterSource("userId", userId).addValue("zone", zone.getId()).addValue("appid", appid),
(rs, i) -> new StartFacts(rs.getLong("appid"), rs.getBoolean("manual"), (rs, i) -> new StartFacts(rs.getLong("appid"), rs.getBoolean("manual"),
rs.getTimestamp("started_at").toInstant(), rs.getBoolean("played_after"))); rs.getTimestamp("started_at").toInstant(), rs.getBoolean("played_after")));
@@ -129,34 +133,35 @@ public class RewardFactsRepository {
as ending_unlocked, as ending_unlocked,
(select coalesce(sum(d.minutes), 0) from user_game_playtime_day d (select coalesce(sum(d.minutes), 0) from user_game_playtime_day d
where d.user_id = c.user_id and d.appid = c.appid 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 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) coalesce(s.achievements_total > 0 and m.achievements_unlocked >= s.achievements_total, false)
as all_achievements, 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 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') where user_id = :userId and to_status in ('FINISHED', 'COMPLETED')
and (cast(:appid as bigint) is null or appid = :appid) and (cast(:appid as bigint) is null or appid = :appid)
group by user_id, appid) c 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 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 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 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 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 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 left join user_game_set st on st.user_id = c.user_id and st.appid = c.appid
where not %1$s where not %1$s
order by c.finished_at, c.appid 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()) new MapSqlParameterSource("userId", userId).addValue("zone", zone.getId())
.addValue("aroundDays", RewardRules.PLAYED_AROUND_DAYS).addValue("appid", appid), .addValue("aroundDays", RewardRules.PLAYED_AROUND_DAYS).addValue("appid", appid),
(rs, i) -> new FinishFacts(rs.getLong("appid"), rs.getBoolean("manual"), rs.getBoolean("completed"), (rs, i) -> new FinishFacts(rs.getLong("appid"), rs.getBoolean("manual"), rs.getBoolean("completed"),
rs.getInt("playtime_minutes"), rs.getInt("played_since"), rs.getInt("playtime_minutes"), rs.getInt("played_since"),
rs.getObject("normally_minutes", Integer.class), rs.getObject("normally_minutes", Integer.class),
rs.getBoolean("ending_unlocked"), rs.getBoolean("played_around"), 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 from user_game ug
left join user_game_set st on st.user_id = ug.user_id and st.appid = ug.appid 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 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 and (l.ref = ug.appid::text
or l.ref in (select o.appid::text from user_game_set o 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 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 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 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 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 (cast(:appid as bigint) is null or ug.appid = :appid)
and not %1$s and not %1$s
order by f.event_at, ug.appid order by f.event_at, ug.appid
""".formatted(paidInSet("ug", "'COMPLETED'")), """.formatted(paidInSet("ug", "'COMPLETED'")),
new MapSqlParameterSource("userId", userId).addValue("appid", appid), new MapSqlParameterSource("userId", userId).addValue("appid", appid),
(rs, i) -> new FinishFacts(rs.getLong("appid"), false, true, rs.getInt("playtime_minutes"), 0, null, (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, 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.status = 'COMPLETED' as completed, ug.hidden_at is not null as hidden,
ug.source = 'PSN' as playstation, 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 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') and c.appid = ug.appid and c.from_status = 'BACKLOG' and c.to_status = 'PLAYING')
as start_logged, as start_logged,
exists (select 1 from user_game_status_change c where c.user_id = ug.user_id 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 from user_game ug
where ug.user_id = :userId and ug.appid = :appid and ug.removed_at is null where ug.user_id = :userId and ug.appid = :appid and ug.removed_at is null
""", new MapSqlParameterSource("userId", userId).addValue("appid", appid), """, new MapSqlParameterSource("userId", userId).addValue("appid", appid),
(rs, i) -> new GameRewardFacts(rs.getBoolean("manual"), rs.getBoolean("finished"), (rs, i) -> new GameRewardFacts(rs.getBoolean("manual"), rs.getBoolean("finished"),
rs.getBoolean("completed"), rs.getBoolean("hidden"), rs.getBoolean("playstation"), 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(); .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<Long, Integer> expectedFinish(long userId, Collection<Long> appids) {
if (appids.isEmpty()) {
return Map.of();
}
Map<Long, Integer> 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 * 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). * transaction (the database clock of a transaction is its start).
@@ -17,6 +17,7 @@ import core_service.modules.rewards.domain.Track;
import core_service.modules.rewards.domain.Unlocks; import core_service.modules.rewards.domain.Unlocks;
import core_service.modules.rewards.repo.RewardFactsRepository; import core_service.modules.rewards.repo.RewardFactsRepository;
import core_service.modules.rewards.repo.RewardLedgerRepository; import core_service.modules.rewards.repo.RewardLedgerRepository;
import core_service.shared.event.LibraryActivityEvent;
import core_service.shared.security.Public; import core_service.shared.security.Public;
import core_service.shared.security.RequiresRole; import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role; 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.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionalEventListener;
import org.springframework.transaction.support.TransactionTemplate; import org.springframework.transaction.support.TransactionTemplate;
import java.time.DateTimeException; import java.time.DateTimeException;
@@ -64,6 +67,9 @@ public class RewardService {
@Value("${app.library.zone:Europe/Berlin}") @Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone; 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 owned the codes the user may wear: bought or unlocked, or all for a platform admin
* @param progress the value of each track, see {@link Unlocks} * @param 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<Long, Integer> expectedFinish(long userId, List<Long> appids) {
return facts.expectedFinish(userId, appids);
}
/** /**
* @param paid the game rows of the game and its set * @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 * @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)) { if (noRow(paid, RewardKind.FINISH_KINDS)) {
missing.put(RewardKind.FINISHED, blocked); missing.put(RewardKind.FINISHED, blocked);
} }
if (topUpOpen(paid)) {
missing.put(RewardKind.FINISHED_TOPUP, blocked);
}
if (game.completed() && noRow(paid, List.of(RewardKind.COMPLETED))) { if (game.completed() && noRow(paid, List.of(RewardKind.COMPLETED))) {
missing.put(RewardKind.COMPLETED, blocked); missing.put(RewardKind.COMPLETED, blocked);
} }
@@ -259,17 +292,27 @@ public class RewardService {
} }
if (noRow(paid, RewardKind.FINISH_KINDS)) { if (noRow(paid, RewardKind.FINISH_KINDS)) {
FinishFacts finish = facts.finishCandidates(userId, zone, appid).stream().findFirst().orElse(null); FinishFacts finish = facts.finishCandidates(userId, zone, appid).stream().findFirst().orElse(null);
boolean weekFull = finish != null && finish.manual() && weekFull(userId, finish.finishedAt()); boolean weekFull = finish != null && RewardRules.capped(finish, earlySince())
putReason(missing, RewardKind.FINISHED, RewardRules.noFinishReason(game, finish, weekFull)); && 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))) { if (noRow(paid, List.of(RewardKind.COMPLETED))) {
FinishFacts completed = facts.completedCandidates(userId, appid).stream().findFirst().orElse(null); FinishFacts completed = facts.completedCandidates(userId, appid).stream().findFirst().orElse(null);
putReason(missing, RewardKind.COMPLETED, RewardRules.noCompletedReason(game, completed, 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; return missing;
} }
// An early row is paid and the rest of the finish is not.
private static boolean topUpOpen(List<PaidRow> paid) {
return !noRow(paid, List.of(RewardKind.FINISHED_EARLY)) && noRow(paid, List.of(RewardKind.FINISHED_TOPUP));
}
private static boolean noRow(List<PaidRow> paid, List<RewardKind> kinds) { private static boolean noRow(List<PaidRow> paid, List<RewardKind> kinds) {
return paid.stream().noneMatch(row -> kinds.contains(row.kind())); 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. // 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) { private int bookOnePerSet(long userId, RewardEntry entry) {
List<RewardKind> kinds = entry.kind() == RewardKind.FINISHED || entry.kind() == RewardKind.FINISHED_MANUAL List<RewardKind> kinds = switch (entry.kind()) {
? List.of(RewardKind.FINISHED, RewardKind.FINISHED_MANUAL) : List.of(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); 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) { private int settleFinishes(long userId) {
int paid = 0; int paid = 0;
for (FinishFacts f : facts.finishCandidates(userId, zone)) { 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) { if (entry == null) {
continue; continue;
} }
if (entry.kind() == RewardKind.FINISHED_MANUAL && weekFull(userId, entry.eventAt())) { if (RewardKind.CAPPED_KINDS.contains(entry.kind()) && weekFull(userId, entry.eventAt())) {
continue; continue;
} }
paid += bookOnePerSet(userId, entry); paid += bookOnePerSet(userId, entry);
@@ -346,7 +392,7 @@ public class RewardService {
// The rows booked earlier in this run are in the count already. // The rows booked earlier in this run are in the count already.
private boolean weekFull(long userId, Instant at) { private boolean weekFull(long userId, Instant at) {
LocalDate monday = weekStart(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; 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)); return at.atZone(zone).toLocalDate().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
} }
private Instant earlySince() {
return startOf(earlySince);
}
private Instant startOf(LocalDate day) { private Instant startOf(LocalDate day) {
return day.atStartOfDay(zone).toInstant(); return day.atStartOfDay(zone).toInstant();
} }
+3
View File
@@ -93,6 +93,9 @@ app:
rewards: rewards:
# Pile Points of every user, after the nightly sync (23:55) and the image repair (01:30). # Pile Points of every user, after the nightly sync (23:55) and the image repair (01:30).
settle-cron: "0 30 2 * * *" 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: follow:
# The hand changes of a followed user are one batch after this quiet time. # The hand changes of a followed user are one batch after this quiet time.
digest-ms: 600000 digest-ms: 600000
@@ -246,6 +246,20 @@ class GameMergeFlowTest extends AbstractIntegrationTest {
"STARTED " + MANUAL, "STARTED " + STEAM); "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 @Test
void merge_BothInBacklog_KeptGameTakesTheBetterPosition() throws Exception { void merge_BothInBacklog_KeptGameTakesTheBetterPosition() throws Exception {
manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG); manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG);
@@ -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();
}
}
@@ -101,14 +101,15 @@ class RewardControllerTest extends AbstractIntegrationTest {
} }
@Test @Test
void wallet_HandSetFinishedWithoutPlaytime_PaysNothingEvenWithTheEndingProof() throws Exception { void wallet_HandSetFinishedWithoutPlaytime_PaysTenEarlyEvenWithTheEndingProof() throws Exception {
game(GAME, "STEAM", "BACKLOG", 0); game(GAME, "STEAM", "BACKLOG", 0);
jdbc.update("update user_game set finished_at_estimate = now() where user_id = ?", USER); jdbc.update("update user_game set finished_at_estimate = now() where user_id = ?", USER);
setStatus(GAME, "FINISHED"); 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")).isZero();
assertThat(rows("FINISHED_EARLY")).isEqualTo(1);
} }
@Test @Test
@@ -162,13 +163,15 @@ class RewardControllerTest extends AbstractIntegrationTest {
} }
@Test @Test
void gamePoints_SteamFinishWithoutProof_MissingFinishIsNoProof() throws Exception { void gamePoints_SteamFinishWithoutProof_PaysTenEarly() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120); game(GAME, "STEAM", "BACKLOG", 120);
setStatus(GAME, "FINISHED"); setStatus(GAME, "FINISHED");
gamePoints(GAME) gamePoints(GAME)
.andExpect(jsonPath("$.paid").isEmpty()) .andExpect(jsonPath("$.paid.length()").value(1))
.andExpect(jsonPath("$.missing.FINISHED").value("NO_PROOF")); .andExpect(jsonPath("$.paid[0].kind").value("FINISHED_EARLY"))
.andExpect(jsonPath("$.paid[0].points").value(10))
.andExpect(jsonPath("$.missing.FINISHED_TOPUP").value("NO_PROOF"));
} }
@Test @Test
@@ -295,14 +298,44 @@ class RewardControllerTest extends AbstractIntegrationTest {
} }
@Test @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); 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', ?)", jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', ?)",
USER, GAME); 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 @Test
@@ -327,6 +360,193 @@ class RewardControllerTest extends AbstractIntegrationTest {
assertThat(rows("FINISHED")).isEqualTo(1); 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 @Test
void gamePoints_GameNotInLibrary_Is404() throws Exception { void gamePoints_GameNotInLibrary_Is404() throws Exception {
gamePoints(GAME) gamePoints(GAME)
@@ -15,59 +15,94 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
class RewardRulesTest extends AbstractUnitTest { class RewardRulesTest extends AbstractUnitTest {
private static final Instant AT = Instant.parse("2026-10-01T10:00:00Z"); 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) { 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) { 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) { 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); private static final GameRewardFacts STEAM_FINISHED = game(false, true, false, true);
@Test @Test
void blocked_HiddenBeforePlaystation() { 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(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); .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(); assertThat(RewardRules.blocked(STEAM_FINISHED)).isNull();
} }
@Test @Test
void noFinishReason_ManualOverTheCap_IsWeeklyLimit() { 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); GameRewardFacts g = game(true, true, false, true);
assertThat(RewardRules.noFinishReason(g, manual, true)).isEqualTo(NoPointsReason.WEEKLY_LIMIT); assertThat(RewardRules.noFinishReason(g, manual, true, EARLY_SINCE)).isEqualTo(NoPointsReason.WEEKLY_LIMIT);
assertThat(RewardRules.noFinishReason(g, manual, false)).isNull(); assertThat(RewardRules.noFinishReason(g, manual, false, EARLY_SINCE)).isNull();
} }
@Test @Test
void noFinishReason_SteamWithoutProof_IsNoProof() { void noFinishReason_SteamWithoutProof_IsNullBecauseEarlyPays() {
assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(60, 300, false, false), false)) 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); .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 @Test
void noFinishReason_SteamWithoutPlaytime_IsNoPlaytime() { void noFinishReason_UnprovenSteamOverTheCap_IsWeeklyLimitNoProof() {
assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(0, 300, true, true), false)) assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(60, 300, false, false), true, EARLY_SINCE))
.isEqualTo(NoPointsReason.NO_PLAYTIME); .isEqualTo(NoPointsReason.WEEKLY_LIMIT_NO_PROOF);
assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(60, 300, true, false), true, EARLY_SINCE)).isNull();
} }
@Test @Test
void noFinishReason_NoStatusLog_IsBeforeHistory() { 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); .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); .isEqualTo(NoPointsReason.NOT_FINISHED);
} }
@@ -98,7 +133,7 @@ class RewardRulesTest extends AbstractUnitTest {
@Test @Test
void noCompletedReason_MissingAchievements() { 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)) assertThat(RewardRules.noCompletedReason(game(false, true, true, true), open, true))
.isEqualTo(NoPointsReason.NOT_ALL_ACHIEVEMENTS); .isEqualTo(NoPointsReason.NOT_ALL_ACHIEVEMENTS);
@@ -114,46 +149,109 @@ class RewardRulesTest extends AbstractUnitTest {
} }
@Test @Test
void finish_WithoutPlaytime_PaysNothingEvenWithTheEndingProof() { void finish_WithoutPlaytime_PaysTenEarlyEvenWithTheEndingProof() {
assertThat(RewardRules.finish(steam(0, 600, true, true))).isEmpty(); assertThat(RewardRules.finish(steam(0, 600, true, true), EARLY_SINCE))
.contains(new RewardEntry(RewardKind.FINISHED_EARLY, "220", 10, AT));
} }
@Test @Test
void finish_NeedsOneProof() { void finish_WithoutProof_PaysTenEarly() {
assertThat(points(steam(60, 300, false, false))).isZero(); assertThat(points(steam(60, 300, false, false))).isEqualTo(10);
assertThat(points(steam(60, 300, true, false))).isEqualTo(30); assertThat(points(steam(60, 300, true, false))).isEqualTo(30);
assertThat(points(steam(60, 300, false, true))).isEqualTo(30); assertThat(points(steam(60, 300, false, true))).isEqualTo(30);
} }
@Test @Test
void finish_HalfOfTheNormalTimeIsAProof() { 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, 300, false, false))).isEqualTo(30);
assertThat(points(steam(150, null, false, false))).isZero(); assertThat(points(steam(150, null, false, false))).isEqualTo(10);
} }
@Test @Test
void finish_ShareProofCountsOnlyPlaytimeSinceTheFinish() { void finish_ShareProofCountsOnlyPlaytimeSinceTheFinish_PaysTenEarly() {
FinishFacts oldPlay = new FinishFacts(220L, false, false, 500, 100, 300, false, false, false, AT); 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 @Test
void finish_ManualGame_PaysTheSmallPriceWithoutProof() { 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 @Test
void completed_NeedsAllAchievementsAndASteamGame() { 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(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, 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))).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))).isEmpty(); assertThat(RewardRules.completed(new FinishFacts(220L, true, true, 60, 60, null, true, true, true, AT, false))).isEmpty();
} }
@Test @Test
@@ -66,7 +66,7 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
} }
@Test @Test
void game_FlagsHiddenPsnLinkedManualAndStatusLog() { void game_FlagsHiddenManualAndStatusLog() {
game("BACKLOG", 120); game("BACKLOG", 120);
setStatus("PLAYING"); setStatus("PLAYING");
setStatus("FINISHED"); setStatus("FINISHED");
@@ -485,4 +485,102 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.completedCandidates(USER)).extracting(FinishFacts::appid).contains(GAME); 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();
}
} }
@@ -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();
}
}