Public Access
fix(rewards): check last achievement unlock for manual COMPLETED
Refs #44. Check the last achievement unlock, not the first status change. Old 100% games no longer pass. New reason COMPLETION_TOO_OLD. Add earlySince to the games and wallet GET responses.
This commit is contained in:
1 parent
1a9a4e912c
commit
59d6b3394d
11 files changed
+188
-44
No files matched your search
@@ -143,9 +143,9 @@ flowchart LR
|
|||||||
proof of fresh play (ending achievement or playtime around the finish, or half of the IGDB time played since the finish). Without proof a Steam finish pays 10 early points (`FINISHED_EARLY`), only for finishes on or after
|
proof of fresh play (ending achievement or playtime around the finish, or half of the IGDB time played since the finish). Without proof a Steam finish pays 10 early points (`FINISHED_EARLY`), only for finishes on or after
|
||||||
`app.rewards.early-since` (older unproven finishes pay only with proof); when proof arrives later, `FINISHED_TOPUP` pays the rest up to the full amount, once per game or set. PlayStation days never prove: a game linked to a PlayStation title pays by its own rules, only
|
`app.rewards.early-since` (older unproven finishes pay only with proof); when proof arrives later, `FINISHED_TOPUP` pays the rest up to the full amount, once per game or set. PlayStation days never prove: a game linked to a PlayStation title pays by its own rules, only
|
||||||
PlayStation rows pay nothing. The month bonus needs a finished game in that month. Games added by hand pay 15 for
|
PlayStation rows pay nothing. The month bonus needs a finished game in that month. Games added by hand pay 15 for
|
||||||
a finish. Manual finishes and early finishes share the cap of 3 per ISO week. `COMPLETED` needs a `FINISHED`, `FINISHED_TOPUP` or `FINISHED_MANUAL` row on the game or its set, so a manual edition's finish opens it for the Steam edition, but only when the Steam game's own first finish
|
a finish. Manual finishes and early finishes share the cap of 3 per ISO week. `COMPLETED` needs a `FINISHED`, `FINISHED_TOPUP` or `FINISHED_MANUAL` row on the game or its set, so a manual edition's finish opens it for the Steam edition, but only when the Steam game's last achievement
|
||||||
(first `FINISHED`/`COMPLETED` status change) is on or after `app.rewards.early-since`; a manual game itself never gets it.
|
unlock (100 % reached) is on or after `app.rewards.early-since` (else `COMPLETION_TOO_OLD`); a manual game itself never gets it.
|
||||||
`GET /api/rewards/expected?appids=` (read-only, at most 200) answers the full finish value per game for the Questions cards. `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
|
`GET /api/rewards/expected?appids=` (read-only, at most 200) answers the full finish value per game for the Questions cards. `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; `earlySince` (ISO date, `app.rewards.early-since`) fills the date in the 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
|
||||||
@@ -225,7 +225,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
|
|||||||
|
|
||||||
| Method | Path | Auth |
|
| Method | Path | Auth |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `GET` | `/api/rewards/wallet` (balance, catalog, owned, equipped, last 50 ledger rows with the game name, unlock `progress` per track) | JWT |
|
| `GET` | `/api/rewards/wallet` (balance, catalog, owned, equipped, last 50 ledger rows with the game name, unlock `progress` per track, `earlySince` for the rules text) | JWT |
|
||||||
| `GET` | `/api/rewards/expected?appids=` (`{appid: points}`, the full finish value, 0 when paid; read-only, no settle; `400 TOO_MANY` over 200 appids) | JWT |
|
| `GET` | `/api/rewards/expected?appids=` (`{appid: points}`, the full finish value, 0 when paid; read-only, no settle; `400 TOO_MANY` over 200 appids) | JWT |
|
||||||
| `POST` | `/api/rewards/purchases` (`{item}`; `200` wallet, `409 ALREADY_OWNED` / `NOT_ENOUGH_POINTS` / `NOT_FOR_SALE`) | JWT |
|
| `POST` | `/api/rewards/purchases` (`{item}`; `200` wallet, `409 ALREADY_OWNED` / `NOT_ENOUGH_POINTS` / `NOT_FOR_SALE`) | JWT |
|
||||||
| `GET` | `/api/rewards/progress/{track}/games?page=`, `/api/rewards/history/{kind}/{ref}/games?page=` (the games behind a track or a `MONTH` / `STREAK` bonus; `400 NOT_A_GAME_LIST`) | JWT |
|
| `GET` | `/api/rewards/progress/{track}/games?page=`, `/api/rewards/history/{kind}/{ref}/games?page=` (the games behind a track or a `MONTH` / `STREAK` bonus; `400 NOT_A_GAME_LIST`) | JWT |
|
||||||
|
|||||||
@@ -6,14 +6,17 @@ import core_service.modules.rewards.domain.RewardKind;
|
|||||||
import core_service.modules.rewards.service.RewardService;
|
import core_service.modules.rewards.service.RewardService;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param paid the game rows of the game and the other editions of its set
|
* @param paid the game rows of the game and the other editions of its set
|
||||||
|
* @param earlySince app.rewards.early-since, for the text of COMPLETION_TOO_OLD
|
||||||
* @param missing why a kind is not paid; STARTED, FINISHED (both finish kinds) and COMPLETED
|
* @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) {
|
public record GamePointsDto(long balance, List<PaidDto> paid, Map<RewardKind, NoPointsReason> missing,
|
||||||
|
LocalDate earlySince) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param appid the edition the row was paid for
|
* @param appid the edition the row was paid for
|
||||||
@@ -27,6 +30,7 @@ public record GamePointsDto(long balance, List<PaidDto> paid, Map<RewardKind, No
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static GamePointsDto of(RewardService.GamePoints points) {
|
public static GamePointsDto of(RewardService.GamePoints points) {
|
||||||
return new GamePointsDto(points.balance(), points.paid().stream().map(PaidDto::of).toList(), points.missing());
|
return new GamePointsDto(points.balance(), points.paid().stream().map(PaidDto::of).toList(), points.missing(),
|
||||||
|
points.earlySince());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ import core_service.modules.rewards.domain.Track;
|
|||||||
import core_service.modules.rewards.service.RewardService;
|
import core_service.modules.rewards.service.RewardService;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -16,9 +17,10 @@ import java.util.Set;
|
|||||||
* @param owned the item codes the user may wear
|
* @param owned the item codes the user may wear
|
||||||
* @param equipped the worn item code per slot
|
* @param equipped the worn item code per slot
|
||||||
* @param progress the value of each unlock track
|
* @param progress the value of each unlock track
|
||||||
|
* @param earlySince app.rewards.early-since, for the rules text
|
||||||
*/
|
*/
|
||||||
public record WalletDto(long balance, List<ItemDto> catalog, Set<String> owned, Map<LookSlot, String> equipped,
|
public record WalletDto(long balance, List<ItemDto> catalog, Set<String> owned, Map<LookSlot, String> equipped,
|
||||||
List<EntryDto> history, Map<Track, Integer> progress) {
|
List<EntryDto> history, Map<Track, Integer> progress, LocalDate earlySince) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param track null when the item is buyable
|
* @param track null when the item is buyable
|
||||||
@@ -38,6 +40,6 @@ public record WalletDto(long balance, List<ItemDto> catalog, Set<String> owned,
|
|||||||
wallet.owned(), wallet.equipped(),
|
wallet.owned(), wallet.equipped(),
|
||||||
wallet.history().stream()
|
wallet.history().stream()
|
||||||
.map(h -> new EntryDto(h.kind(), h.ref(), h.gameName(), h.points(), h.eventAt())).toList(),
|
.map(h -> new EntryDto(h.kind(), h.ref(), h.gameName(), h.points(), h.eventAt())).toList(),
|
||||||
wallet.progress());
|
wallet.progress(), wallet.earlySince());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,8 @@ package core_service.modules.rewards.domain;
|
|||||||
* @param startLogged a BACKLOG to PLAYING change is in the status log
|
* @param startLogged a BACKLOG to PLAYING change is in the status log
|
||||||
* @param finishLogged a change to FINISHED or COMPLETED is in the status log
|
* @param finishLogged a change to FINISHED or COMPLETED is in the status log
|
||||||
* @param linkedTitle a PlayStation title is linked to the game, so its playtime days prove nothing
|
* @param linkedTitle a PlayStation title is linked to the game, so its playtime days prove nothing
|
||||||
|
* @param allAchievements the game has achievements and the user unlocked all of them
|
||||||
*/
|
*/
|
||||||
public record GameRewardFacts(boolean manual, boolean finished, boolean completed, boolean hidden, boolean playstation,
|
public record GameRewardFacts(boolean manual, boolean finished, boolean completed, boolean hidden, boolean playstation,
|
||||||
boolean startLogged, boolean finishLogged, boolean linkedTitle) {}
|
boolean startLogged, boolean finishLogged, boolean linkedTitle,
|
||||||
|
boolean allAchievements) {}
|
||||||
@@ -30,8 +30,13 @@ public enum NoPointsReason {
|
|||||||
WEEKLY_LIMIT,
|
WEEKLY_LIMIT,
|
||||||
/** The weekly cap is used up, so no early points; proof still pays the full finish. */
|
/** The weekly cap is used up, so no early points; proof still pays the full finish. */
|
||||||
WEEKLY_LIMIT_NO_PROOF,
|
WEEKLY_LIMIT_NO_PROOF,
|
||||||
/** COMPLETED needs a FINISHED, FINISHED_TOPUP or FINISHED_MANUAL row on the game or its set first. */
|
/**
|
||||||
|
* COMPLETED needs a FINISHED or FINISHED_TOPUP row on the game or its set first, or a FINISHED_MANUAL row on the
|
||||||
|
* set when the game reached 100 % on or after early-since.
|
||||||
|
*/
|
||||||
NEEDS_PROVEN_FINISH,
|
NEEDS_PROVEN_FINISH,
|
||||||
|
/** A FINISHED_MANUAL row is paid, but the game's last achievement unlock is before early-since. */
|
||||||
|
COMPLETION_TOO_OLD,
|
||||||
/** COMPLETED needs all achievements. */
|
/** COMPLETED needs all achievements. */
|
||||||
NOT_ALL_ACHIEVEMENTS
|
NOT_ALL_ACHIEVEMENTS
|
||||||
}
|
}
|
||||||
@@ -192,9 +192,11 @@ public final class RewardRules {
|
|||||||
/**
|
/**
|
||||||
* @param c the completed candidate of the game, null when it is none
|
* @param c the completed candidate of the game, null when it is none
|
||||||
* @param finishPaid a FINISHED or FINISHED_TOPUP row is paid for the game or its set, or a FINISHED_MANUAL row opens COMPLETED
|
* @param finishPaid a FINISHED or FINISHED_TOPUP row is paid for the game or its set, or a FINISHED_MANUAL row opens COMPLETED
|
||||||
|
* @param manualFinishPaid a FINISHED_MANUAL row is paid for the game or its set
|
||||||
* @return why no COMPLETED row is paid, null when nothing stops it
|
* @return why no COMPLETED row is paid, null when nothing stops it
|
||||||
*/
|
*/
|
||||||
public static NoPointsReason noCompletedReason(GameRewardFacts g, FinishFacts c, boolean finishPaid) {
|
public static NoPointsReason noCompletedReason(GameRewardFacts g, FinishFacts c, boolean finishPaid,
|
||||||
|
boolean manualFinishPaid) {
|
||||||
if (!g.completed()) {
|
if (!g.completed()) {
|
||||||
return NoPointsReason.NOT_COMPLETED;
|
return NoPointsReason.NOT_COMPLETED;
|
||||||
}
|
}
|
||||||
@@ -202,7 +204,10 @@ public final class RewardRules {
|
|||||||
return NoPointsReason.MANUAL_GAME;
|
return NoPointsReason.MANUAL_GAME;
|
||||||
}
|
}
|
||||||
if (!finishPaid) {
|
if (!finishPaid) {
|
||||||
return NoPointsReason.NEEDS_PROVEN_FINISH;
|
if (!manualFinishPaid) {
|
||||||
|
return NoPointsReason.NEEDS_PROVEN_FINISH;
|
||||||
|
}
|
||||||
|
return g.allAchievements() ? NoPointsReason.COMPLETION_TOO_OLD : NoPointsReason.NOT_ALL_ACHIEVEMENTS;
|
||||||
}
|
}
|
||||||
return c != null && !c.allAchievements() ? NoPointsReason.NOT_ALL_ACHIEVEMENTS : null;
|
return c != null && !c.allAchievements() ? NoPointsReason.NOT_ALL_ACHIEVEMENTS : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,7 +167,8 @@ public class RewardFactsRepository {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* COMPLETED games with a paid FINISHED, FINISHED_TOPUP or FINISHED_MANUAL row (on the key or any key of its set)
|
* COMPLETED games with a paid FINISHED, FINISHED_TOPUP or FINISHED_MANUAL row (on the key or any key of its set)
|
||||||
* and no COMPLETED row yet; finishedAt is the first of those rows. A manual game is a candidate with manual set, so
|
* and no COMPLETED row yet; finishedAt is the first of those rows. A FINISHED_MANUAL row counts only with a recent
|
||||||
|
* last achievement unlock, see {@code earlySince}. A manual game is a candidate with manual set, so
|
||||||
* the rules reject it. Every edition of a linked set is a candidate; the service books one per set.
|
* the rules reject it. Every edition of a linked set is a candidate; the service books one per set.
|
||||||
*/
|
*/
|
||||||
public List<FinishFacts> completedCandidates(long userId, Instant earlySince) {
|
public List<FinishFacts> completedCandidates(long userId, Instant earlySince) {
|
||||||
@@ -176,9 +177,9 @@ public class RewardFactsRepository {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param appid only this game, null for all
|
* @param appid only this game, null for all
|
||||||
* @param earlySince a FINISHED_MANUAL row opens COMPLETED only when the game itself first reached FINISHED or
|
* @param earlySince a FINISHED_MANUAL row opens COMPLETED only when the game's last achievement unlock is on or
|
||||||
* COMPLETED on or after this. A manual edition can be added already finished, so its fresh row
|
* after this (100 % reached recently). A manual edition can be added already finished, so its
|
||||||
* says nothing about an old Steam finish; no status change at all counts as old.
|
* fresh row says nothing about an old 100 %; no unlock time counts as old.
|
||||||
*/
|
*/
|
||||||
public List<FinishFacts> completedCandidates(long userId, Long appid, Instant earlySince) {
|
public List<FinishFacts> completedCandidates(long userId, Long appid, Instant earlySince) {
|
||||||
return jdbc.query("""
|
return jdbc.query("""
|
||||||
@@ -190,9 +191,9 @@ public class RewardFactsRepository {
|
|||||||
join lateral (select min(l.event_at) as event_at from reward_ledger l
|
join lateral (select min(l.event_at) as event_at from reward_ledger l
|
||||||
where l.user_id = ug.user_id
|
where l.user_id = ug.user_id
|
||||||
and (l.kind in ('FINISHED', 'FINISHED_TOPUP')
|
and (l.kind in ('FINISHED', 'FINISHED_TOPUP')
|
||||||
or (l.kind = 'FINISHED_MANUAL' and (select min(c.changed_at) from user_game_status_change c
|
or (l.kind = 'FINISHED_MANUAL' and (select max(a.unlocked_at) from user_game_achievement a
|
||||||
where c.user_id = ug.user_id and c.appid = ug.appid
|
where a.user_id = ug.user_id and a.appid = ug.appid
|
||||||
and c.to_status in ('FINISHED', 'COMPLETED'))
|
and a.unlocked)
|
||||||
>= :earlySince))
|
>= :earlySince))
|
||||||
and (l.ref = ug.appid::text
|
and (l.ref = ug.appid::text
|
||||||
or l.ref in (select o.appid::text from user_game_set o
|
or l.ref in (select o.appid::text from user_game_set o
|
||||||
@@ -227,13 +228,18 @@ public class RewardFactsRepository {
|
|||||||
exists (select 1 from user_game_status_change c where c.user_id = ug.user_id
|
exists (select 1 from user_game_status_change c where c.user_id = ug.user_id
|
||||||
and c.appid = ug.appid and c.to_status in ('FINISHED', 'COMPLETED')) as finish_logged,
|
and c.appid = ug.appid and c.to_status in ('FINISHED', 'COMPLETED')) as finish_logged,
|
||||||
exists (select 1 from user_psn_title t where t.user_id = ug.user_id and t.appid = ug.appid
|
exists (select 1 from user_psn_title t where t.user_id = ug.user_id and t.appid = ug.appid
|
||||||
and t.state = 'LINKED') as linked_title
|
and t.state = 'LINKED') as linked_title,
|
||||||
|
coalesce(s.achievements_total > 0 and m.achievements_unlocked >= s.achievements_total, false)
|
||||||
|
as all_achievements
|
||||||
from user_game ug
|
from user_game ug
|
||||||
|
left join user_game_achievement_meta m on m.user_id = ug.user_id and m.appid = ug.appid
|
||||||
|
left join game_achievement_schema_meta s on s.appid = ug.appid
|
||||||
where ug.user_id = :userId and ug.appid = :appid and ug.removed_at is null
|
where ug.user_id = :userId and ug.appid = :appid and ug.removed_at is null
|
||||||
""", new MapSqlParameterSource("userId", userId).addValue("appid", appid),
|
""", new MapSqlParameterSource("userId", userId).addValue("appid", appid),
|
||||||
(rs, i) -> new GameRewardFacts(rs.getBoolean("manual"), rs.getBoolean("finished"),
|
(rs, i) -> new GameRewardFacts(rs.getBoolean("manual"), rs.getBoolean("finished"),
|
||||||
rs.getBoolean("completed"), rs.getBoolean("hidden"), rs.getBoolean("playstation"),
|
rs.getBoolean("completed"), rs.getBoolean("hidden"), rs.getBoolean("playstation"),
|
||||||
rs.getBoolean("start_logged"), rs.getBoolean("finish_logged"), rs.getBoolean("linked_title")))
|
rs.getBoolean("start_logged"), rs.getBoolean("finish_logged"), rs.getBoolean("linked_title"),
|
||||||
|
rs.getBoolean("all_achievements")))
|
||||||
.stream().findFirst();
|
.stream().findFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,10 +73,11 @@ public class RewardService {
|
|||||||
/**
|
/**
|
||||||
* @param owned the codes the user may wear: bought or unlocked, or all for a platform admin
|
* @param owned the codes the user may wear: bought or unlocked, or all for a platform admin
|
||||||
* @param progress the value of each track, see {@link Unlocks}
|
* @param progress the value of each track, see {@link Unlocks}
|
||||||
|
* @param earlySince the first day a finish with a manual opener or no proof counts, see {@code app.rewards.early-since}
|
||||||
* @param equipped the worn looks, only those the user still owns
|
* @param equipped the worn looks, only those the user still owns
|
||||||
*/
|
*/
|
||||||
public record Wallet(long balance, Set<String> owned, Map<LookSlot, String> equipped, List<HistoryEntry> history,
|
public record Wallet(long balance, Set<String> owned, Map<LookSlot, String> equipped, List<HistoryEntry> history,
|
||||||
Map<Track, Integer> progress) {}
|
Map<Track, Integer> progress, LocalDate earlySince) {}
|
||||||
|
|
||||||
// ponytail: settles on every wallet read (the app reads it once per start); a settled_at column if that hurts.
|
// ponytail: settles on every wallet read (the app reads it once per start); a settled_at column if that hurts.
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -84,7 +85,7 @@ public class RewardService {
|
|||||||
Map<Track, Integer> progress = settleLocked(userId);
|
Map<Track, Integer> progress = settleLocked(userId);
|
||||||
Set<String> owned = Looks.owned(ledger.itemCodes(userId), role);
|
Set<String> owned = Looks.owned(ledger.itemCodes(userId), role);
|
||||||
return new Wallet(ledger.balance(userId), owned, Looks.wearable(ledger.equipped(userId), owned),
|
return new Wallet(ledger.balance(userId), owned, Looks.wearable(ledger.equipped(userId), owned),
|
||||||
ledger.history(userId, HISTORY_SIZE), progress);
|
ledger.history(userId, HISTORY_SIZE), progress, earlySince);
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum BuyResult { BOUGHT, ALREADY_OWNED, NOT_ENOUGH_POINTS, NOT_FOR_SALE }
|
public enum BuyResult { BOUGHT, ALREADY_OWNED, NOT_ENOUGH_POINTS, NOT_FOR_SALE }
|
||||||
@@ -249,7 +250,8 @@ public class RewardService {
|
|||||||
* @param paid the game rows of the game and its set
|
* @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
|
* @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) {}
|
public record GamePoints(long balance, List<PaidRow> paid, Map<RewardKind, NoPointsReason> missing,
|
||||||
|
LocalDate earlySince) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settles first, like the wallet; {@link PaidRow#bookedNow()} relies on settle and read sharing this one
|
* Settles first, like the wallet; {@link PaidRow#bookedNow()} relies on settle and read sharing this one
|
||||||
@@ -265,7 +267,8 @@ public class RewardService {
|
|||||||
}
|
}
|
||||||
settleLocked(userId);
|
settleLocked(userId);
|
||||||
List<PaidRow> paid = facts.paidRows(userId, appid);
|
List<PaidRow> paid = facts.paidRows(userId, appid);
|
||||||
return Optional.of(new GamePoints(ledger.balance(userId), paid, missing(userId, appid, found.get(), paid)));
|
return Optional.of(new GamePoints(ledger.balance(userId), paid, missing(userId, appid, found.get(), paid),
|
||||||
|
earlySince));
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<RewardKind, NoPointsReason> missing(long userId, long appid, GameRewardFacts game, List<PaidRow> paid) {
|
private Map<RewardKind, NoPointsReason> missing(long userId, long appid, GameRewardFacts game, List<PaidRow> paid) {
|
||||||
@@ -302,9 +305,11 @@ public class RewardService {
|
|||||||
}
|
}
|
||||||
if (noRow(paid, List.of(RewardKind.COMPLETED))) {
|
if (noRow(paid, List.of(RewardKind.COMPLETED))) {
|
||||||
FinishFacts completed = facts.completedCandidates(userId, appid, earlySince()).stream().findFirst().orElse(null);
|
FinishFacts completed = facts.completedCandidates(userId, appid, earlySince()).stream().findFirst().orElse(null);
|
||||||
// A candidate means an opener passed, a FINISHED_MANUAL row dated before early-since is none.
|
// A candidate means an opener passed; a FINISHED_MANUAL row with an old last unlock is none.
|
||||||
boolean finishPaid = completed != null || !noRow(paid, List.of(RewardKind.FINISHED, RewardKind.FINISHED_TOPUP));
|
boolean finishPaid = completed != null || !noRow(paid, List.of(RewardKind.FINISHED, RewardKind.FINISHED_TOPUP));
|
||||||
putReason(missing, RewardKind.COMPLETED, RewardRules.noCompletedReason(game, completed, finishPaid));
|
boolean manualFinishPaid = !noRow(paid, List.of(RewardKind.FINISHED_MANUAL));
|
||||||
|
putReason(missing, RewardKind.COMPLETED,
|
||||||
|
RewardRules.noCompletedReason(game, completed, finishPaid, manualFinishPaid));
|
||||||
}
|
}
|
||||||
return missing;
|
return missing;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import org.springframework.test.web.servlet.MockMvc;
|
|||||||
import org.springframework.test.web.servlet.ResultActions;
|
import org.springframework.test.web.servlet.ResultActions;
|
||||||
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
@@ -55,6 +56,7 @@ class RewardControllerTest extends AbstractIntegrationTest {
|
|||||||
jdbc.update("delete from user_psn_title where user_id = ?", 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_link where appid in (?, ?)", GAME, EDITION);
|
||||||
jdbc.update("delete from game_achievement_schema_meta where appid = ?", GAME);
|
jdbc.update("delete from game_achievement_schema_meta where appid = ?", GAME);
|
||||||
|
jdbc.update("delete from user_game_achievement where user_id = ?", USER);
|
||||||
jdbc.update("delete from reward_ledger where user_id = ?", OTHER_USER);
|
jdbc.update("delete from reward_ledger where user_id = ?", OTHER_USER);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +76,11 @@ class RewardControllerTest extends AbstractIntegrationTest {
|
|||||||
return count != null ? count : 0;
|
return count != null ? count : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wallet_AnswersTheEarlySinceDay() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/rewards/wallet")).andExpect(jsonPath("$.earlySince").value("2026-09-10"));
|
||||||
|
}
|
||||||
|
|
||||||
private void wallet() throws Exception {
|
private void wallet() throws Exception {
|
||||||
mockMvc.perform(get("/api/rewards/wallet")).andExpect(status().isOk());
|
mockMvc.perform(get("/api/rewards/wallet")).andExpect(status().isOk());
|
||||||
}
|
}
|
||||||
@@ -481,6 +488,14 @@ class RewardControllerTest extends AbstractIntegrationTest {
|
|||||||
.andExpect(jsonPath("$.balance").value(70));
|
.andExpect(jsonPath("$.balance").value(70));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An old first unlock and the given last one, so the test shows that the latest counts.
|
||||||
|
private void lastUnlock(long appid, String timestamp) {
|
||||||
|
jdbc.update("insert into user_game_achievement (user_id, appid, api_name, unlocked, unlocked_at) "
|
||||||
|
+ "values (?, ?, 'ACH_FIRST', true, '2025-01-01T00:00:00Z'::timestamptz)", USER, appid);
|
||||||
|
jdbc.update("insert into user_game_achievement (user_id, appid, api_name, unlocked, unlocked_at) "
|
||||||
|
+ "values (?, ?, 'ACH_LAST', true, ?::timestamptz)", USER, appid, timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
private void paidRow(String kind, long appid) {
|
private void paidRow(String kind, long appid) {
|
||||||
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, ?, ?, 15, now())",
|
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, ?, ?, 15, now())",
|
||||||
USER, kind, String.valueOf(appid));
|
USER, kind, String.valueOf(appid));
|
||||||
@@ -492,6 +507,7 @@ class RewardControllerTest extends AbstractIntegrationTest {
|
|||||||
setStatus(GAME, "COMPLETED");
|
setStatus(GAME, "COMPLETED");
|
||||||
allAchievements();
|
allAchievements();
|
||||||
jdbc.update("insert into game_link (appid, set_id) values (?, 4100), (?, 4100)", GAME, EDITION);
|
jdbc.update("insert into game_link (appid, set_id) values (?, 4100), (?, 4100)", GAME, EDITION);
|
||||||
|
lastUnlock(GAME, Instant.now().toString());
|
||||||
paidRow("FINISHED_MANUAL", EDITION);
|
paidRow("FINISHED_MANUAL", EDITION);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,12 +527,26 @@ class RewardControllerTest extends AbstractIntegrationTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void gamePoints_SetManualFinishPaid_SteamFinishedBeforeEarlySince_NoCompleted() throws Exception {
|
void gamePoints_SetManualFinishPaid_FirstFinishOldHundredPercentRecent_PaysCompletedOnce() throws Exception {
|
||||||
completedSetWithManualFinish();
|
completedSetWithManualFinish();
|
||||||
jdbc.update("update user_game_status_change set changed_at = '2026-01-01T00:00:00Z' where user_id = ? and appid = ?",
|
jdbc.update("update user_game_status_change set changed_at = '2026-01-01T00:00:00Z' where user_id = ? and appid = ?",
|
||||||
USER, GAME);
|
USER, GAME);
|
||||||
|
|
||||||
gamePoints(GAME).andExpect(jsonPath("$.missing.COMPLETED").value("NEEDS_PROVEN_FINISH"));
|
gamePoints(GAME)
|
||||||
|
.andExpect(jsonPath("$.paid[?(@.kind == 'COMPLETED')].points").value(40))
|
||||||
|
.andExpect(jsonPath("$.missing.COMPLETED").doesNotExist());
|
||||||
|
assertThat(rows("COMPLETED")).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void gamePoints_OldHundredPercentMarkedCompletedNow_ManualEditionPaid_NoCompleted() throws Exception {
|
||||||
|
completedSetWithManualFinish();
|
||||||
|
jdbc.update("delete from user_game_achievement where user_id = ?", USER);
|
||||||
|
lastUnlock(GAME, "2026-01-01T00:00:00Z");
|
||||||
|
|
||||||
|
gamePoints(GAME).andExpect(jsonPath("$.missing.COMPLETED").value("COMPLETION_TOO_OLD"))
|
||||||
|
.andExpect(jsonPath("$.earlySince").value("2026-09-10"));
|
||||||
|
gamePoints(EDITION);
|
||||||
assertThat(rows("COMPLETED")).isZero();
|
assertThat(rows("COMPLETED")).isZero();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,10 +556,11 @@ class RewardControllerTest extends AbstractIntegrationTest {
|
|||||||
jdbc.update("update user_game_status_change set changed_at = '2025-01-01T00:00:00Z' where user_id = ? and appid = ?",
|
jdbc.update("update user_game_status_change set changed_at = '2025-01-01T00:00:00Z' where user_id = ? and appid = ?",
|
||||||
USER, GAME);
|
USER, GAME);
|
||||||
allAchievements();
|
allAchievements();
|
||||||
|
lastUnlock(GAME, "2025-01-01T00:00:00Z");
|
||||||
game(EDITION, "MANUAL", "COMPLETED", 0);
|
game(EDITION, "MANUAL", "COMPLETED", 0);
|
||||||
jdbc.update("insert into game_link (appid, set_id) values (?, 4100), (?, 4100)", GAME, EDITION);
|
jdbc.update("insert into game_link (appid, set_id) values (?, 4100), (?, 4100)", GAME, EDITION);
|
||||||
|
|
||||||
gamePoints(GAME).andExpect(jsonPath("$.missing.COMPLETED").value("NEEDS_PROVEN_FINISH"));
|
gamePoints(GAME).andExpect(jsonPath("$.missing.COMPLETED").value("COMPLETION_TOO_OLD"));
|
||||||
assertThat(rows("COMPLETED")).isZero();
|
assertThat(rows("COMPLETED")).isZero();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,6 +585,27 @@ class RewardControllerTest extends AbstractIntegrationTest {
|
|||||||
assertThat(rows("COMPLETED")).isZero();
|
assertThat(rows("COMPLETED")).isZero();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void gamePoints_ManualFinishPaid_IncompleteAchievementsWithOldUnlocks_NotAllAchievements() throws Exception {
|
||||||
|
completedSetWithManualFinish();
|
||||||
|
jdbc.update("delete from user_game_achievement where user_id = ?", USER);
|
||||||
|
jdbc.update("update user_game_achievement_meta set achievements_unlocked = 5 where user_id = ? and appid = ?",
|
||||||
|
USER, GAME);
|
||||||
|
lastUnlock(GAME, "2026-01-01T00:00:00Z");
|
||||||
|
|
||||||
|
gamePoints(GAME).andExpect(jsonPath("$.missing.COMPLETED").value("NOT_ALL_ACHIEVEMENTS"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void gamePoints_ManualFinishPaid_GameWithoutAchievements_NotAllAchievements() throws Exception {
|
||||||
|
completedSetWithManualFinish();
|
||||||
|
jdbc.update("delete from user_game_achievement where user_id = ?", USER);
|
||||||
|
jdbc.update("delete from user_game_achievement_meta where user_id = ? and appid = ?", USER, GAME);
|
||||||
|
jdbc.update("delete from game_achievement_schema_meta where appid = ?", GAME);
|
||||||
|
|
||||||
|
gamePoints(GAME).andExpect(jsonPath("$.missing.COMPLETED").value("NOT_ALL_ACHIEVEMENTS"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void gamePoints_FinishedPlayingFinishedAfterEarly_PaysNoSecondEarly() throws Exception {
|
void gamePoints_FinishedPlayingFinishedAfterEarly_PaysNoSecondEarly() throws Exception {
|
||||||
game(GAME, "STEAM", "BACKLOG", 120);
|
game(GAME, "STEAM", "BACKLOG", 120);
|
||||||
|
|||||||
@@ -26,17 +26,17 @@ class RewardRulesTest extends AbstractUnitTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static GameRewardFacts game(boolean manual, boolean finished, boolean completed, boolean finishLogged) {
|
private static GameRewardFacts game(boolean manual, boolean finished, boolean completed, boolean finishLogged) {
|
||||||
return new GameRewardFacts(manual, finished, completed, false, false, true, finishLogged, false);
|
return new GameRewardFacts(manual, finished, completed, false, false, true, finishLogged, false, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final GameRewardFacts STEAM_FINISHED = game(false, true, false, true);
|
private static final GameRewardFacts STEAM_FINISHED = game(false, true, false, true);
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void blocked_HiddenBeforePlaystation() {
|
void blocked_HiddenBeforePlaystation() {
|
||||||
GameRewardFacts all = new GameRewardFacts(false, true, false, true, true, true, true, false);
|
GameRewardFacts all = new GameRewardFacts(false, true, false, true, true, true, true, false, false);
|
||||||
|
|
||||||
assertThat(RewardRules.blocked(all)).isEqualTo(NoPointsReason.HIDDEN);
|
assertThat(RewardRules.blocked(all)).isEqualTo(NoPointsReason.HIDDEN);
|
||||||
assertThat(RewardRules.blocked(new GameRewardFacts(false, true, false, false, true, true, true, false)))
|
assertThat(RewardRules.blocked(new GameRewardFacts(false, true, false, false, true, true, true, false, false)))
|
||||||
.isEqualTo(NoPointsReason.PLAYSTATION);
|
.isEqualTo(NoPointsReason.PLAYSTATION);
|
||||||
assertThat(RewardRules.blocked(STEAM_FINISHED)).isNull();
|
assertThat(RewardRules.blocked(STEAM_FINISHED)).isNull();
|
||||||
}
|
}
|
||||||
@@ -65,7 +65,7 @@ class RewardRulesTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void noTopUpReason_LinkedTitleWithoutProof_IsPlayNotCounted() {
|
void noTopUpReason_LinkedTitleWithoutProof_IsPlayNotCounted() {
|
||||||
GameRewardFacts linked = new GameRewardFacts(false, true, false, false, false, true, true, true);
|
GameRewardFacts linked = new GameRewardFacts(false, true, false, false, false, true, true, true, false);
|
||||||
|
|
||||||
assertThat(RewardRules.noTopUpReason(linked, steam(60, 300, false, false)))
|
assertThat(RewardRules.noTopUpReason(linked, steam(60, 300, false, false)))
|
||||||
.isEqualTo(NoPointsReason.PLAY_NOT_COUNTED);
|
.isEqualTo(NoPointsReason.PLAY_NOT_COUNTED);
|
||||||
@@ -117,7 +117,7 @@ class RewardRulesTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
assertThat(RewardRules.noStartReason(game(false, false, false, false), idle))
|
assertThat(RewardRules.noStartReason(game(false, false, false, false), idle))
|
||||||
.isEqualTo(NoPointsReason.NO_PLAY_AFTER_START);
|
.isEqualTo(NoPointsReason.NO_PLAY_AFTER_START);
|
||||||
assertThat(RewardRules.noStartReason(new GameRewardFacts(false, false, false, false, false, false, false, false),
|
assertThat(RewardRules.noStartReason(new GameRewardFacts(false, false, false, false, false, false, false, false, false),
|
||||||
null)).isEqualTo(NoPointsReason.NOT_STARTED);
|
null)).isEqualTo(NoPointsReason.NOT_STARTED);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,9 +125,12 @@ class RewardRulesTest extends AbstractUnitTest {
|
|||||||
void noCompletedReason_WithoutProvenFinish_IsNeedsProvenFinish() {
|
void noCompletedReason_WithoutProvenFinish_IsNeedsProvenFinish() {
|
||||||
GameRewardFacts g = game(false, true, true, true);
|
GameRewardFacts g = game(false, true, true, true);
|
||||||
|
|
||||||
assertThat(RewardRules.noCompletedReason(g, null, false)).isEqualTo(NoPointsReason.NEEDS_PROVEN_FINISH);
|
assertThat(RewardRules.noCompletedReason(g, null, false, false)).isEqualTo(NoPointsReason.NEEDS_PROVEN_FINISH);
|
||||||
assertThat(RewardRules.noCompletedReason(STEAM_FINISHED, null, true)).isEqualTo(NoPointsReason.NOT_COMPLETED);
|
assertThat(RewardRules.noCompletedReason(g, null, false, true)).isEqualTo(NoPointsReason.COMPLETION_TOO_OLD);
|
||||||
assertThat(RewardRules.noCompletedReason(game(true, true, true, true), null, true))
|
assertThat(RewardRules.noCompletedReason(new GameRewardFacts(false, true, true, false, false, true, true, false, false),
|
||||||
|
null, false, true)).isEqualTo(NoPointsReason.NOT_ALL_ACHIEVEMENTS);
|
||||||
|
assertThat(RewardRules.noCompletedReason(STEAM_FINISHED, null, true, false)).isEqualTo(NoPointsReason.NOT_COMPLETED);
|
||||||
|
assertThat(RewardRules.noCompletedReason(game(true, true, true, true), null, true, false))
|
||||||
.isEqualTo(NoPointsReason.MANUAL_GAME);
|
.isEqualTo(NoPointsReason.MANUAL_GAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +138,7 @@ class RewardRulesTest extends AbstractUnitTest {
|
|||||||
void noCompletedReason_MissingAchievements() {
|
void noCompletedReason_MissingAchievements() {
|
||||||
FinishFacts open = new FinishFacts(220L, false, true, 60, 0, null, false, false, false, AT, false);
|
FinishFacts open = new FinishFacts(220L, false, true, 60, 0, null, false, false, false, AT, false);
|
||||||
|
|
||||||
assertThat(RewardRules.noCompletedReason(game(false, true, true, true), open, true))
|
assertThat(RewardRules.noCompletedReason(game(false, true, true, true), open, true, false))
|
||||||
.isEqualTo(NoPointsReason.NOT_ALL_ACHIEVEMENTS);
|
.isEqualTo(NoPointsReason.NOT_ALL_ACHIEVEMENTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
|
|||||||
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', ?)", USER, GAME);
|
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', ?)", USER, GAME);
|
||||||
|
|
||||||
assertThat(repository.game(USER, GAME)).hasValue(
|
assertThat(repository.game(USER, GAME)).hasValue(
|
||||||
new GameRewardFacts(false, true, false, true, false, true, true, true));
|
new GameRewardFacts(false, true, false, true, false, true, true, true, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -493,11 +493,20 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
|
|||||||
jdbc.update("insert into user_game_achievement_meta (user_id, appid, achievements_unlocked) values (?, ?, 10)", USER, GAME);
|
jdbc.update("insert into user_game_achievement_meta (user_id, appid, achievements_unlocked) values (?, ?, 10)", USER, GAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An old first unlock and the given last one, so the test shows that the latest counts.
|
||||||
|
private void lastUnlock(String timestamp) {
|
||||||
|
jdbc.update("insert into user_game_achievement (user_id, appid, api_name, unlocked, unlocked_at) "
|
||||||
|
+ "values (?, ?, 'ACH_FIRST', true, '2025-01-01T00:00:00Z'::timestamptz)", USER, GAME);
|
||||||
|
jdbc.update("insert into user_game_achievement (user_id, appid, api_name, unlocked, unlocked_at) "
|
||||||
|
+ "values (?, ?, 'ACH_LAST', true, ?::timestamptz)", USER, GAME, timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void completedCandidates_ManualFinishPaidOnAnotherEditionOfTheSet() {
|
void completedCandidates_ManualFinishPaidOnAnotherEditionOfTheSet() {
|
||||||
setOfTwo("COMPLETED");
|
setOfTwo("COMPLETED");
|
||||||
jdbc.update("update user_game set status = 'COMPLETED' where user_id = ? and appid = ?", USER, EDITION);
|
jdbc.update("update user_game set status = 'COMPLETED' where user_id = ? and appid = ?", USER, EDITION);
|
||||||
allAchievements();
|
allAchievements();
|
||||||
|
lastUnlock("2026-09-15T00:00:00Z");
|
||||||
paid(RewardKind.FINISHED_MANUAL, EDITION);
|
paid(RewardKind.FINISHED_MANUAL, EDITION);
|
||||||
|
|
||||||
assertThat(repository.completedCandidates(USER, EARLY)).filteredOn(f -> f.appid() == GAME).singleElement()
|
assertThat(repository.completedCandidates(USER, EARLY)).filteredOn(f -> f.appid() == GAME).singleElement()
|
||||||
@@ -507,23 +516,62 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void completedCandidates_ManualFinishOpensNothingWhenTheSteamGameFinishedBeforeEarlySince() {
|
void completedCandidates_ManualFinishOpensNothingWhenTheLastUnlockIsBeforeEarlySince() {
|
||||||
setOfTwo("COMPLETED");
|
setOfTwo("COMPLETED");
|
||||||
allAchievements();
|
allAchievements();
|
||||||
paid(RewardKind.FINISHED_MANUAL, EDITION);
|
paid(RewardKind.FINISHED_MANUAL, EDITION);
|
||||||
changedAt("COMPLETED", "2026-01-01T00:00:00Z");
|
lastUnlock("2026-09-09T23:59:59Z");
|
||||||
|
|
||||||
assertThat(repository.completedCandidates(USER, GAME, EARLY)).isEmpty();
|
assertThat(repository.completedCandidates(USER, GAME, EARLY)).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void completedCandidates_ManualFinishOpensNothingWhenTheSteamGameHasNoStatusChange() {
|
void completedCandidates_ManualFinishOpensWhenTheLastUnlockIsExactlyEarlySince() {
|
||||||
|
setOfTwo("COMPLETED");
|
||||||
|
allAchievements();
|
||||||
|
paid(RewardKind.FINISHED_MANUAL, EDITION);
|
||||||
|
lastUnlock("2026-09-10T00:00:00Z");
|
||||||
|
|
||||||
|
assertThat(repository.completedCandidates(USER, GAME, EARLY)).extracting(FinishFacts::appid)
|
||||||
|
.containsExactly(GAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void completedCandidates_ManualFinishOpensNothingWithoutUnlockTimes() {
|
||||||
|
setOfTwo("COMPLETED");
|
||||||
|
allAchievements();
|
||||||
|
paid(RewardKind.FINISHED_MANUAL, EDITION);
|
||||||
|
|
||||||
|
assertThat(repository.completedCandidates(USER, GAME, EARLY)).isEmpty();
|
||||||
|
|
||||||
|
jdbc.update("insert into user_game_achievement (user_id, appid, api_name, unlocked, unlocked_at) "
|
||||||
|
+ "values (?, ?, 'ACH_X', true, null)", USER, GAME);
|
||||||
|
|
||||||
|
assertThat(repository.completedCandidates(USER, GAME, EARLY)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void completedCandidates_ManualFinishOpensWhenTheFirstFinishIsOldButTheLastUnlockRecent() {
|
||||||
|
setOfTwo("COMPLETED");
|
||||||
|
allAchievements();
|
||||||
|
paid(RewardKind.FINISHED_MANUAL, EDITION);
|
||||||
|
changedAt("COMPLETED", "2026-01-01T00:00:00Z");
|
||||||
|
lastUnlock("2026-09-15T00:00:00Z");
|
||||||
|
|
||||||
|
assertThat(repository.completedCandidates(USER, GAME, EARLY)).extracting(FinishFacts::appid)
|
||||||
|
.containsExactly(GAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void completedCandidates_ManualFinishOpensWithoutStatusChangeWhenTheLastUnlockIsRecent() {
|
||||||
setOfTwo("COMPLETED");
|
setOfTwo("COMPLETED");
|
||||||
allAchievements();
|
allAchievements();
|
||||||
paid(RewardKind.FINISHED_MANUAL, EDITION);
|
paid(RewardKind.FINISHED_MANUAL, EDITION);
|
||||||
jdbc.update("delete from user_game_status_change where user_id = ? and appid = ?", USER, GAME);
|
jdbc.update("delete from user_game_status_change where user_id = ? and appid = ?", USER, GAME);
|
||||||
|
lastUnlock("2026-09-15T00:00:00Z");
|
||||||
|
|
||||||
assertThat(repository.completedCandidates(USER, GAME, EARLY)).isEmpty();
|
assertThat(repository.completedCandidates(USER, GAME, EARLY)).extracting(FinishFacts::appid)
|
||||||
|
.containsExactly(GAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -531,11 +579,23 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
|
|||||||
game("BACKLOG", 120);
|
game("BACKLOG", 120);
|
||||||
setStatus("COMPLETED");
|
setStatus("COMPLETED");
|
||||||
allAchievements();
|
allAchievements();
|
||||||
|
lastUnlock("2026-09-15T00:00:00Z");
|
||||||
paid(RewardKind.FINISHED_MANUAL, GAME);
|
paid(RewardKind.FINISHED_MANUAL, GAME);
|
||||||
|
|
||||||
assertThat(repository.completedCandidates(USER, EARLY)).extracting(FinishFacts::appid).containsExactly(GAME);
|
assertThat(repository.completedCandidates(USER, EARLY)).extracting(FinishFacts::appid).containsExactly(GAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void completedCandidates_ManualFinishOnOwnSteamKeyWithOldLastUnlock_OpensNothing() {
|
||||||
|
game("BACKLOG", 120);
|
||||||
|
setStatus("COMPLETED");
|
||||||
|
allAchievements();
|
||||||
|
lastUnlock("2026-01-01T00:00:00Z");
|
||||||
|
paid(RewardKind.FINISHED_MANUAL, GAME);
|
||||||
|
|
||||||
|
assertThat(repository.completedCandidates(USER, EARLY)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
private void linkedTitle() {
|
private void linkedTitle() {
|
||||||
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', ?)", USER, GAME);
|
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', ?)", USER, GAME);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user