feat(rewards): add game points endpoint with reason codes

Frontend shows why a game earned no points after a status save.
Settle runs first. Rules and amounts are not changed.
This commit is contained in:
lobo committed 2026-10-09 15:25:04 +02:00
1 parent 4ed8a341af
commit 7c4b475321
12 files changed
+708 -17

No files matched your search

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