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

@@ -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();
}
+3
View File
@@ -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();
}
}