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"]
|
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());
|
continue;
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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);
|
||||||
|
|||||||
Reference in new issue
Block a user