Public Access
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:
1 parent
4ed8a341af
commit
7c4b475321
12 files changed
+708
-17
No files matched your search
@@ -103,6 +103,7 @@ flowchart LR
|
||||
Facts["counted_user_game + playtime days + status log + time to beat + user_game_question"] --> Rule["OpenQuestion.of"]
|
||||
Rule --> Card["question card"]
|
||||
Answer["answer"] --> Backlog["BacklogService"] --> UserGame["user_game"] --> Trigger["V14 trigger: status log"] --> Settle["RewardService.settle"]
|
||||
Answer -->|"Frontend: GET /api/rewards/games/{appid}"| Settle
|
||||
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,
|
||||
@@ -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
|
||||
changes are not known.
|
||||
- **Pile Points** (`modules/rewards`): users earn points for playing games off the pile, in an append-only ledger
|
||||
(`reward_ledger`, V26; the balance is the sum). No code path pays: a settle (on `GET /api/rewards/wallet` 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
|
||||
snapshots, once per user, kind and game (`ref`). Rules and numbers: `RewardRules`; a Steam finish needs playtime and one
|
||||
proof of fresh play (ending achievement or playtime around the finish, or half of the IGDB time played since the finish; old history pays nothing). The month bonus needs a finished game in that month. Games added by hand pay 15 for
|
||||
a finish, at most 3 per ISO week. 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),
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user