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
+707 -16

No files matched your search

+3 -2
View File
@@ -103,6 +103,7 @@ flowchart LR
Facts["counted_user_game + playtime days + status log + time to beat + user_game_question"] --> Rule["OpenQuestion.of"] Facts["counted_user_game + playtime days + status log + time to beat + user_game_question"] --> Rule["OpenQuestion.of"]
Rule --> Card["question card"] Rule --> Card["question card"]
Answer["answer"] --> Backlog["BacklogService"] --> UserGame["user_game"] --> Trigger["V14 trigger: status log"] --> Settle["RewardService.settle"] Answer["answer"] --> Backlog["BacklogService"] --> UserGame["user_game"] --> Trigger["V14 trigger: status log"] --> Settle["RewardService.settle"]
Answer -->|"Frontend: GET /api/rewards/games/{appid}"| Settle
Answer -->|no| Snooze["user_game_question"] Answer -->|no| Snooze["user_game_question"]
``` ```
- **Hide games**: a user can hide a game, Steam or added by hand (e.g. a playtest or a tool). It leaves every list and number: library, - **Hide games**: a user can hide a game, Steam or added by hand (e.g. a playtest or a tool). It leaves every list and number: library,
@@ -132,11 +133,11 @@ flowchart LR
(old and new status, time, whether the user chose it), whatever code path made it. It starts with V14; older (old and new status, time, whether the user chose it), whatever code path made it. It starts with V14; older
changes are not known. changes are not known.
- **Pile Points** (`modules/rewards`): users earn points for playing games off the pile, in an append-only ledger - **Pile Points** (`modules/rewards`): users earn points for playing games off the pile, in an append-only ledger
(`reward_ledger`, V26; the balance is the sum). No code path pays: a settle (on `GET /api/rewards/wallet` and (`reward_ledger`, V26; the balance is the sum). No code path pays: a settle (on `GET /api/rewards/wallet`, on `GET /api/rewards/games/{appid}` and
nightly at 02:30, `app.rewards.settle-cron`) derives rows from the status log, playtime days, achievements and pile nightly at 02:30, `app.rewards.settle-cron`) derives rows from the status log, playtime days, achievements and pile
snapshots, once per user, kind and game (`ref`). Rules and numbers: `RewardRules`; a Steam finish needs playtime and one snapshots, once per user, kind and game (`ref`). Rules and numbers: `RewardRules`; a Steam finish needs playtime and one
proof of fresh play (ending achievement or playtime around the finish, or half of the IGDB time played since the finish; old history pays nothing). The month bonus needs a finished game in that month. Games added by hand pay 15 for proof of fresh play (ending achievement or playtime around the finish, or half of the IGDB time played since the finish; old history pays nothing). The month bonus needs a finished game in that month. Games added by hand pay 15 for
a finish, at most 3 per ISO week. The settle takes a per-user advisory lock. V26 also pays a one-time starter grant a finish, at most 3 per ISO week. `GET /api/rewards/games/{appid}` (game page open, after every status save) settles, then answers the paid rows of the game and its set (`bookedNow` = written by this call) and one `NoPointsReason` per missing kind (`STARTED`, `FINISHED`, `COMPLETED`); the codes live in `RewardRules`, the Frontend only maps them to texts. The settle takes a per-user advisory lock. V26 also pays a one-time starter grant
for finishes from before the status log. Shop: a whitelist of 57 looks (`ShopItem`; 45 buyable and 12 unlock-only) in eight slots, including animated completed effects, page backgrounds (CSS, SVG and two canvas engines in the Frontend), for finishes from before the status log. Shop: a whitelist of 57 looks (`ShopItem`; 45 buyable and 12 unlock-only) in eight slots, including animated completed effects, page backgrounds (CSS, SVG and two canvas engines in the Frontend),
banner backgrounds (V27 widens `reward_equipped.slot`) and three buyable skins (full themes, slot `SKIN`). The unlock-only banner backgrounds (V27 widens `reward_equipped.slot`) and three buyable skins (full themes, slot `SKIN`). The unlock-only
items (price 0, never sold): the settle books an `UNLOCK` row (ref = item code, 0 points) once a items (price 0, never sold): the settle books an `UNLOCK` row (ref = item code, 0 points) once a
@@ -1,6 +1,7 @@
package core_service.modules.rewards.api; package core_service.modules.rewards.api;
import core_service.modules.rewards.api.dto.EquipRequest; 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.PurchaseRequest;
import core_service.modules.rewards.api.dto.RewardGamesDto; import core_service.modules.rewards.api.dto.RewardGamesDto;
import core_service.modules.rewards.api.dto.WalletDto; 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()))); 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") @GetMapping("/progress/{track}/games")
public ResponseEntity<?> getProgressGames(@AuthenticationPrincipal UserPrincipal principal, public ResponseEntity<?> getProgressGames(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable Track track, @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()) { if (f.manual()) {
return Optional.of(new RewardEntry(RewardKind.FINISHED_MANUAL, ref, MANUAL_FINISH, f.finishedAt())); return Optional.of(new RewardEntry(RewardKind.FINISHED_MANUAL, ref, MANUAL_FINISH, f.finishedAt()));
} }
if (f.playtimeMinutes() <= 0 || !hasProof(f)) { if (whyNoFinish(f).isPresent()) {
return Optional.empty(); return Optional.empty();
} }
return Optional.of(new RewardEntry(RewardKind.FINISHED, ref, finishPoints(f.normallyMinutes()), f.finishedAt())); 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) { private static boolean hasProof(FinishFacts f) {
return f.endingUnlocked() || f.playedAround() return f.endingUnlocked() || f.playedAround()
|| (f.normallyMinutes() != null && f.playedSinceMinutes() >= PROOF_SHARE * f.normallyMinutes()); || (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 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) { public static String monthRef(YearMonth month) {
return month.toString(); return month.toString();
} }
@@ -1,6 +1,8 @@
package core_service.modules.rewards.repo; package core_service.modules.rewards.repo;
import core_service.modules.rewards.domain.FinishFacts; 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.RewardKind;
import core_service.modules.rewards.domain.RewardRules; import core_service.modules.rewards.domain.RewardRules;
import core_service.modules.rewards.domain.StartFacts; 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. * linked set is a candidate; the service books one per set.
*/ */
public List<StartFacts> startCandidates(long userId, ZoneId zone) { 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(""" return jdbc.query("""
select c.appid, ug.source = 'MANUAL' as manual, c.started_at, select c.appid, ug.source = 'MANUAL' as manual, c.started_at,
exists (select 1 from user_game_playtime_day d where d.user_id = c.user_id and d.appid = c.appid exists (select 1 from user_game_playtime_day d where d.user_id = c.user_id and d.appid = c.appid
and d.day >= (c.started_at at time zone :zone)::date) as played_after and d.day >= (c.started_at at time zone :zone)::date) as played_after
from (select user_id, appid, min(changed_at) as started_at from user_game_status_change from (select user_id, appid, min(changed_at) as started_at from user_game_status_change
where user_id = :userId and from_status = 'BACKLOG' and to_status = 'PLAYING' where user_id = :userId and from_status = 'BACKLOG' and to_status = 'PLAYING'
and (cast(:appid as bigint) is null or appid = :appid)
group by user_id, appid) c group by user_id, appid) c
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid and ug.removed_at is null join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid and ug.removed_at is null
and ug.hidden_at is null and ug.source <> 'PSN' and ug.hidden_at is null and ug.source <> 'PSN'
@@ -92,7 +102,7 @@ public class RewardFactsRepository {
where not %1$s where not %1$s
order by c.started_at, c.appid order by c.started_at, c.appid
""".formatted(paidInSet("c", "'STARTED'")), """.formatted(paidInSet("c", "'STARTED'")),
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, i) -> new StartFacts(rs.getLong("appid"), rs.getBoolean("manual"),
rs.getTimestamp("started_at").toInstant(), rs.getBoolean("played_after"))); rs.getTimestamp("started_at").toInstant(), rs.getBoolean("played_after")));
} }
@@ -105,6 +115,13 @@ public class RewardFactsRepository {
* edition of a linked set is a candidate; the service books one per set. * edition of a linked set is a candidate; the service books one per set.
*/ */
public List<FinishFacts> finishCandidates(long userId, ZoneId zone) { 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(""" return jdbc.query("""
select c.appid, ug.source = 'MANUAL' as manual, ug.status = 'COMPLETED' as completed, select c.appid, ug.source = 'MANUAL' as manual, ug.status = 'COMPLETED' as completed,
ug.playtime_minutes, t.normally_minutes, ug.playtime_minutes, t.normally_minutes,
@@ -120,13 +137,12 @@ public class RewardFactsRepository {
c.finished_at c.finished_at
from (select user_id, appid, min(changed_at) as finished_at from user_game_status_change from (select user_id, appid, min(changed_at) as finished_at from user_game_status_change
where user_id = :userId and to_status in ('FINISHED', 'COMPLETED') where user_id = :userId and to_status in ('FINISHED', 'COMPLETED')
and (cast(:appid as bigint) is null or appid = :appid)
group by user_id, appid) c group by user_id, appid) c
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid and ug.removed_at is null join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid and ug.removed_at is null
and ug.status in ('FINISHED', 'COMPLETED') and ug.hidden_at is null and ug.source <> 'PSN' and ug.status in ('FINISHED', 'COMPLETED') and ug.hidden_at is null and ug.source <> 'PSN'
and not exists (select 1 from user_psn_title t where t.user_id = ug.user_id and t.appid = ug.appid and 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 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 game_time_to_beat t on t.appid = c.appid
left join user_game_achievement_meta m on m.user_id = c.user_id and m.appid = c.appid left join user_game_achievement_meta m on m.user_id = c.user_id and m.appid = c.appid
left join game_achievement_schema_meta s on s.appid = c.appid left join game_achievement_schema_meta s on s.appid = c.appid
@@ -135,7 +151,7 @@ public class RewardFactsRepository {
order by c.finished_at, c.appid order by c.finished_at, c.appid
""".formatted(paidInSet("c", "'FINISHED', 'FINISHED_MANUAL'")), """.formatted(paidInSet("c", "'FINISHED', 'FINISHED_MANUAL'")),
new MapSqlParameterSource("userId", userId).addValue("zone", zone.getId()) 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, i) -> new FinishFacts(rs.getLong("appid"), rs.getBoolean("manual"), rs.getBoolean("completed"),
rs.getInt("playtime_minutes"), rs.getInt("played_since"), rs.getInt("playtime_minutes"), rs.getInt("played_since"),
rs.getObject("normally_minutes", Integer.class), rs.getObject("normally_minutes", Integer.class),
@@ -148,6 +164,13 @@ public class RewardFactsRepository {
* Every edition of a linked set is a candidate; the service books one per set. * Every edition of a linked set is a candidate; the service books one per set.
*/ */
public List<FinishFacts> completedCandidates(long userId) { 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(""" return jdbc.query("""
select ug.appid, ug.playtime_minutes, f.event_at, select ug.appid, ug.playtime_minutes, f.event_at,
coalesce(s.achievements_total > 0 and m.achievements_unlocked >= s.achievements_total, false) coalesce(s.achievements_total > 0 and m.achievements_unlocked >= s.achievements_total, false)
@@ -166,13 +189,59 @@ public class RewardFactsRepository {
and ug.source <> 'PSN' and ug.source <> 'PSN'
and not exists (select 1 from user_psn_title t where t.user_id = ug.user_id and t.appid = ug.appid and 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 t.state = 'LINKED')
and (cast(:appid as bigint) is null or ug.appid = :appid)
and not %1$s and not %1$s
order by f.event_at, ug.appid order by f.event_at, ug.appid
""".formatted(paidInSet("ug", "'COMPLETED'")), 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, (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()));
} }
/**
* 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 * 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 * 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; package core_service.modules.rewards.service;
import core_service.modules.rewards.domain.FinishFacts; 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.HistoryEntry;
import core_service.modules.rewards.domain.LookSlot; import core_service.modules.rewards.domain.LookSlot;
import core_service.modules.rewards.domain.Looks; 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.RewardEntry;
import core_service.modules.rewards.domain.RewardGame; import core_service.modules.rewards.domain.RewardGame;
import core_service.modules.rewards.domain.RewardKind; import core_service.modules.rewards.domain.RewardKind;
import core_service.modules.rewards.domain.RewardRules; import core_service.modules.rewards.domain.RewardRules;
import core_service.modules.rewards.domain.ShopItem; 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.Track;
import core_service.modules.rewards.domain.Unlocks; import core_service.modules.rewards.domain.Unlocks;
import core_service.modules.rewards.repo.RewardFactsRepository; import core_service.modules.rewards.repo.RewardFactsRepository;
@@ -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) { private Map<Track, Integer> settleLocked(long userId) {
if (!facts.isActive(userId)) { if (!facts.isActive(userId)) {
return Map.of(); return Map.of();
@@ -266,20 +335,21 @@ public class RewardService {
if (entry == null) { if (entry == null) {
continue; continue;
} }
if (entry.kind() == RewardKind.FINISHED_MANUAL) { if (entry.kind() == RewardKind.FINISHED_MANUAL && weekFull(userId, entry.eventAt())) {
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; continue;
} }
}
paid += bookOnePerSet(userId, entry); paid += bookOnePerSet(userId, entry);
} }
return paid; 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) { private int settleCompleted(long userId) {
return facts.completedCandidates(userId).stream() return facts.completedCandidates(userId).stream()
.flatMap(f -> RewardRules.completed(f).stream()).mapToInt(e -> bookOnePerSet(userId, e)).sum(); .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 USER = 1L;
private static final long ADMIN = 2L; private static final long ADMIN = 2L;
private static final long GAME = 4_100_001L; private static final long GAME = 4_100_001L;
private static final long EDITION = 4_100_002L;
private static final long OTHER_USER = 99L;
@Autowired @Autowired
private MockMvc mockMvc; 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_ledger where user_id in (?, ?)", USER, ADMIN);
jdbc.update("delete from reward_equipped 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_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) { 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); 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 @Test
void wallet_ManualGameInTheHistoryHasItsName() throws Exception { void wallet_ManualGameInTheHistoryHasItsName() throws Exception {
game(-4_100_020L, "MANUAL", "BACKLOG", 0); game(-4_100_020L, "MANUAL", "BACKLOG", 0);
@@ -24,6 +24,86 @@ class RewardRulesTest extends AbstractUnitTest {
return RewardRules.finish(facts).map(RewardEntry::points).orElse(0); 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 @Test
void finish_PaysByTheLengthOfTheGame() { void finish_PaysByTheLengthOfTheGame() {
assertThat(points(steam(60, null, true, false))).isEqualTo(30); assertThat(points(steam(60, null, true, false))).isEqualTo(30);
@@ -1,6 +1,7 @@
package core_service.modules.rewards.repo; package core_service.modules.rewards.repo;
import core_service.modules.rewards.domain.FinishFacts; 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.RewardEntry;
import core_service.modules.rewards.domain.RewardKind; import core_service.modules.rewards.domain.RewardKind;
import core_service.modules.rewards.domain.StartFacts; import core_service.modules.rewards.domain.StartFacts;
@@ -64,6 +65,68 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
USER, day, GAME); 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 @Test
void finishCandidates_AppearsAfterTheChangeWithItsFacts() { void finishCandidates_AppearsAfterTheChangeWithItsFacts() {
game("BACKLOG", 120); game("BACKLOG", 120);