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