Public Access
feat(rewards): ledger, earning rules and nightly settle
New module rewards tracks Pile Points in an append-only ledger. A settle derives rows from the status log, playtime days and achievements, once per user, kind and game. The balance is the sum of all rows. Settle runs on every wallet read and nightly at 02:30. The unique key (user_id, kind, ref) makes every run idempotent. V26 also pays a one-time starter grant for finishes from before the status log.
This commit is contained in:
1 parent
732970697d
commit
837dad8a6f
28 files changed
+1470
-3
No files matched your search
@@ -57,6 +57,13 @@ To Be Played is split into three repositories:
|
|||||||
- **Status history**: a database trigger logs every status change of a library game in `user_game_status_change`
|
- **Status history**: a database trigger logs every status change of a library game in `user_game_status_change`
|
||||||
(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
|
||||||
|
(`reward_ledger`, V26; the balance is the sum). No code path pays: a settle (on `GET /api/rewards/wallet` and
|
||||||
|
nightly at 02:30, `app.rewards.settle-cron`) derives rows from the status log, playtime days, achievements and pile
|
||||||
|
snapshots, once per user, kind and game (`ref`). Rules and numbers: `RewardRules`; a Steam finish needs playtime and one
|
||||||
|
proof of fresh play (ending achievement or playtime around the finish, or half of the IGDB time played since the finish; old history pays nothing). The month bonus needs a finished game in that month. Games added by hand pay 15 for
|
||||||
|
a finish, at most 3 per ISO week. The settle takes a per-user advisory lock. V26 also pays a one-time starter grant
|
||||||
|
for finishes from before the status log.
|
||||||
- **Wishlist**: synced from Steam with store price and discount.
|
- **Wishlist**: synced from Steam with store price and discount.
|
||||||
- **Broken image repair**: the frontend reports a Steam cover or achievement icon that does not load. At 01:30
|
- **Broken image repair**: the frontend reports a Steam cover or achievement icon that does not load. At 01:30
|
||||||
(Europe/Berlin) a job checks up to 200 reports. A cover that loads again is left alone; else the store's hashed
|
(Europe/Berlin) a job checks up to 200 reports. A cover that loads again is left alone; else the store's hashed
|
||||||
@@ -114,6 +121,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) | JWT |
|
||||||
| `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT |
|
| `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT |
|
||||||
| `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT |
|
| `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT |
|
||||||
| `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR |
|
| `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR |
|
||||||
@@ -165,6 +173,7 @@ flowchart LR
|
|||||||
Stats["stats"]
|
Stats["stats"]
|
||||||
Version["version"]
|
Version["version"]
|
||||||
Account["account"]
|
Account["account"]
|
||||||
|
Rewards["rewards"]
|
||||||
Steam["steam_adapter"]
|
Steam["steam_adapter"]
|
||||||
Igdb["igdb_adapter"]
|
Igdb["igdb_adapter"]
|
||||||
Speedrun["speedrun_adapter"]
|
Speedrun["speedrun_adapter"]
|
||||||
@@ -173,6 +182,7 @@ flowchart LR
|
|||||||
LibService --> Speedrun
|
LibService --> Speedrun
|
||||||
end
|
end
|
||||||
Browser -->|"tbp_access cookie"| Security
|
Browser -->|"tbp_access cookie"| Security
|
||||||
|
Rewards --> Db
|
||||||
Security -->|"JWKS by kid"| Auth["ToBePlayed-Auth"]
|
Security -->|"JWKS by kid"| Auth["ToBePlayed-Auth"]
|
||||||
Steam --> SteamApi["Steam Web API"]
|
Steam --> SteamApi["Steam Web API"]
|
||||||
Steam -->|"HEAD"| SteamCdn["Steam CDN"]
|
Steam -->|"HEAD"| SteamCdn["Steam CDN"]
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public class AccountCleanupRepository {
|
|||||||
// Children before user_game (user_game_status_change would also go by its cascade), the tombstones last.
|
// Children before user_game (user_game_status_change would also go by its cascade), the tombstones last.
|
||||||
static final List<String> TABLES = List.of("user_game_status_change", "user_game_achievement",
|
static final List<String> TABLES = List.of("user_game_status_change", "user_game_achievement",
|
||||||
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
|
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
|
||||||
"user_steam_sync", "user_game", "game_import", "blocked_user", "deleted_user");
|
"user_steam_sync", "user_game", "game_import", "reward_ledger", "reward_equipped", "blocked_user", "deleted_user");
|
||||||
|
|
||||||
// Every user with data has a row in one of these; deleteRows then removes the child rows too.
|
// Every user with data has a row in one of these; deleteRows then removes the child rows too.
|
||||||
private static final List<String> ORPHAN_SOURCES = List.of("user_game", "user_steam_sync", "wishlist_game",
|
private static final List<String> ORPHAN_SOURCES = List.of("user_game", "user_steam_sync", "wishlist_game",
|
||||||
|
|||||||
@@ -87,6 +87,13 @@ public class GameMergeRepository {
|
|||||||
jdbc.update("delete from user_game f where f.appid = :from and exists "
|
jdbc.update("delete from user_game f where f.appid = :from and exists "
|
||||||
+ "(select 1 from user_game t where t.user_id = f.user_id and t.appid = :to)", keys);
|
+ "(select 1 from user_game t where t.user_id = f.user_id and t.appid = :to)", keys);
|
||||||
int moved = jdbc.update("update user_game set appid = :to where appid = :from", keys);
|
int moved = jdbc.update("update user_game set appid = :to where appid = :from", keys);
|
||||||
|
// Paid games stay paid under the kept key; a user who earned both keys keeps both rows.
|
||||||
|
jdbc.update("""
|
||||||
|
update reward_ledger l set ref = cast(:to as text)
|
||||||
|
where l.ref = cast(:from as text) and l.kind in ('STARTED', 'FINISHED', 'FINISHED_MANUAL', 'COMPLETED')
|
||||||
|
and not exists (select 1 from reward_ledger o where o.user_id = l.user_id and o.kind = l.kind
|
||||||
|
and o.ref = cast(:to as text))
|
||||||
|
""", keys);
|
||||||
for (String table : CATALOG_TABLES) {
|
for (String table : CATALOG_TABLES) {
|
||||||
jdbc.update("delete from " + table + " where appid = :from", keys);
|
jdbc.update("delete from " + table + " where appid = :from", keys);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package core_service.modules.rewards.api;
|
||||||
|
|
||||||
|
import core_service.modules.rewards.api.dto.WalletDto;
|
||||||
|
import core_service.modules.rewards.service.RewardService;
|
||||||
|
import core_service.shared.security.UserPrincipal;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/rewards")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RewardController {
|
||||||
|
|
||||||
|
private final RewardService rewardService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settles first, so the wallet is current.
|
||||||
|
*/
|
||||||
|
@GetMapping("/wallet")
|
||||||
|
public ResponseEntity<WalletDto> getWallet(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
|
return ResponseEntity.ok(WalletDto.of(rewardService.wallet(principal.getIdAsLong(), principal.role())));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package core_service.modules.rewards.api.dto;
|
||||||
|
|
||||||
|
import core_service.modules.rewards.domain.LookSlot;
|
||||||
|
import core_service.modules.rewards.domain.RewardKind;
|
||||||
|
import core_service.modules.rewards.domain.ShopItem;
|
||||||
|
import core_service.modules.rewards.service.RewardService;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param owned the item codes the user may wear
|
||||||
|
* @param equipped the worn item code per slot
|
||||||
|
*/
|
||||||
|
public record WalletDto(long balance, List<ItemDto> catalog, Set<String> owned, Map<LookSlot, String> equipped,
|
||||||
|
List<EntryDto> history) {
|
||||||
|
|
||||||
|
public record ItemDto(String code, LookSlot slot, int price) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param gameName null when the ref is no game
|
||||||
|
*/
|
||||||
|
public record EntryDto(RewardKind kind, String ref, String gameName, int points, Instant eventAt) {}
|
||||||
|
|
||||||
|
public static WalletDto of(RewardService.Wallet wallet) {
|
||||||
|
return new WalletDto(wallet.balance(),
|
||||||
|
Arrays.stream(ShopItem.values()).map(item -> new ItemDto(item.name(), item.slot(), item.price())).toList(),
|
||||||
|
wallet.owned(), wallet.equipped(),
|
||||||
|
wallet.history().stream()
|
||||||
|
.map(h -> new EntryDto(h.kind(), h.ref(), h.gameName(), h.points(), h.eventAt())).toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package core_service.modules.rewards.domain;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw facts of one finished game; {@link RewardRules} decides.
|
||||||
|
*
|
||||||
|
* @param playedSinceMinutes minutes of the playtime days on or after the finish day (playtimeMinutes has the old play too)
|
||||||
|
* @param normallyMinutes IGDB normal time to beat, null when unknown
|
||||||
|
* @param endingUnlocked the ending achievement is unlocked, and not earlier than 30 days before the finish
|
||||||
|
* @param playedAround a playtime day within 30 days before the finish or any time after
|
||||||
|
* @param allAchievements all achievements of a game with some are unlocked
|
||||||
|
* @param finishedAt the first status change to FINISHED or COMPLETED
|
||||||
|
*/
|
||||||
|
public record FinishFacts(long appid, boolean manual, boolean completed, int playtimeMinutes, int playedSinceMinutes, Integer normallyMinutes,
|
||||||
|
boolean endingUnlocked, boolean playedAround, boolean allAchievements, Instant finishedAt) {}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package core_service.modules.rewards.domain;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param gameName null when the ref is no game
|
||||||
|
*/
|
||||||
|
public record HistoryEntry(RewardKind kind, String ref, String gameName, int points, Instant eventAt) {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package core_service.modules.rewards.domain;
|
||||||
|
|
||||||
|
public enum LookSlot {
|
||||||
|
AVATAR_BORDER, BADGE, ACCENT, BANNER_FRAME
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package core_service.modules.rewards.domain;
|
||||||
|
|
||||||
|
import core_service.shared.security.Role;
|
||||||
|
|
||||||
|
import java.util.EnumMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public final class Looks {
|
||||||
|
|
||||||
|
private Looks() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A platform admin owns every item of the catalog, so later items need no extra work.
|
||||||
|
*/
|
||||||
|
public static Set<String> owned(Set<String> bought, Role role) {
|
||||||
|
return role == Role.PLATFORM_ADMIN ? ShopItem.codes() : bought;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The worn looks the user may show now: a code that is not owned or not in the catalog drops out, e.g. the free
|
||||||
|
* items of an admin who is no admin any more.
|
||||||
|
*/
|
||||||
|
public static Map<LookSlot, String> wearable(Map<LookSlot, String> equipped, Set<String> owned) {
|
||||||
|
Map<LookSlot, String> result = new EnumMap<>(LookSlot.class);
|
||||||
|
equipped.forEach((slot, code) -> {
|
||||||
|
if (owned.contains(code) && ShopItem.codes().contains(code)) {
|
||||||
|
result.put(slot, code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package core_service.modules.rewards.domain;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A ledger row to write.
|
||||||
|
*
|
||||||
|
* @param ref see V26
|
||||||
|
* @param eventAt when the game event happened, not when it is booked
|
||||||
|
*/
|
||||||
|
public record RewardEntry(RewardKind kind, String ref, int points, Instant eventAt) {}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package core_service.modules.rewards.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public enum RewardKind {
|
||||||
|
STARTED, FINISHED, FINISHED_MANUAL, COMPLETED, MONTH, STREAK, STARTER, BUY;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The kinds whose ref is a game (an appid).
|
||||||
|
*/
|
||||||
|
public static final List<RewardKind> GAME_KINDS = List.of(STARTED, FINISHED, FINISHED_MANUAL, COMPLETED);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The kinds that count as "finished a game" for the weekly streak.
|
||||||
|
*/
|
||||||
|
public static final List<RewardKind> FINISH_KINDS = List.of(FINISHED, FINISHED_MANUAL);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package core_service.modules.rewards.domain;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.YearMonth;
|
||||||
|
import java.time.temporal.IsoFields;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The numbers of Pile Points and who earns what. Pure rules, no database. The ledger is append-only, so a change here
|
||||||
|
* never touches paid rows. The starter grant (20 per finished game, cap 250) is in V26.
|
||||||
|
*/
|
||||||
|
public final class RewardRules {
|
||||||
|
|
||||||
|
public static final int START = 5;
|
||||||
|
public static final int FINISH_SHORT = 30;
|
||||||
|
public static final int FINISH_MID = 50;
|
||||||
|
public static final int FINISH_LONG = 80;
|
||||||
|
public static final int MID_HOURS = 10;
|
||||||
|
public static final int LONG_HOURS = 30;
|
||||||
|
public static final int COMPLETED_BONUS = 40;
|
||||||
|
public static final int MANUAL_FINISH = 15;
|
||||||
|
public static final int MANUAL_PER_WEEK = 3;
|
||||||
|
public static final int MONTH_BONUS = 40;
|
||||||
|
public static final int STREAK_BONUS = 20;
|
||||||
|
public static final double PROOF_SHARE = 0.5;
|
||||||
|
public static final int PLAYED_AROUND_DAYS = 30;
|
||||||
|
public static final int MONTHS_BACK = 2;
|
||||||
|
public static final int STREAK_WEEKS = 12;
|
||||||
|
|
||||||
|
private RewardRules() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Steam games only: a game added by hand has no playtime to prove the start.
|
||||||
|
*/
|
||||||
|
public static Optional<RewardEntry> start(StartFacts f) {
|
||||||
|
if (f.manual() || !f.playedAfter()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return Optional.of(new RewardEntry(RewardKind.STARTED, String.valueOf(f.appid()), START, f.startedAt()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A game added by hand pays {@link #MANUAL_FINISH} (the weekly cap is the caller's job). A Steam game pays by its
|
||||||
|
* length and needs playtime plus one proof: the ending achievement, playtime around the finish, or playtime of
|
||||||
|
* at least {@link #PROOF_SHARE} of the normal time.
|
||||||
|
*/
|
||||||
|
public static Optional<RewardEntry> finish(FinishFacts f) {
|
||||||
|
String ref = String.valueOf(f.appid());
|
||||||
|
if (f.manual()) {
|
||||||
|
return Optional.of(new RewardEntry(RewardKind.FINISHED_MANUAL, ref, MANUAL_FINISH, f.finishedAt()));
|
||||||
|
}
|
||||||
|
if (f.playtimeMinutes() <= 0 || !hasProof(f)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return Optional.of(new RewardEntry(RewardKind.FINISHED, ref, finishPoints(f.normallyMinutes()), f.finishedAt()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasProof(FinishFacts f) {
|
||||||
|
return f.endingUnlocked() || f.playedAround()
|
||||||
|
|| (f.normallyMinutes() != null && f.playedSinceMinutes() >= PROOF_SHARE * f.normallyMinutes());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int finishPoints(Integer normallyMinutes) {
|
||||||
|
if (normallyMinutes == null || normallyMinutes < MID_HOURS * 60) {
|
||||||
|
return FINISH_SHORT;
|
||||||
|
}
|
||||||
|
return normallyMinutes <= LONG_HOURS * 60 ? FINISH_MID : FINISH_LONG;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Optional<RewardEntry> completed(FinishFacts f) {
|
||||||
|
if (!f.completed() || f.manual() || !f.allAchievements()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return Optional.of(new RewardEntry(RewardKind.COMPLETED, String.valueOf(f.appid()), COMPLETED_BONUS, f.finishedAt()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String monthRef(YearMonth month) {
|
||||||
|
return month.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ISO week, e.g. {@code 2026-W41}; the year is the week-based year.
|
||||||
|
*/
|
||||||
|
public static String weekRef(LocalDate day) {
|
||||||
|
return "%d-W%02d".formatted(day.get(IsoFields.WEEK_BASED_YEAR), day.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package core_service.modules.rewards.domain;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whitelist of looks. The frontend maps a code to its visuals; nothing here is ever uploaded CSS.
|
||||||
|
*/
|
||||||
|
public enum ShopItem {
|
||||||
|
BORDER_GOLD(LookSlot.AVATAR_BORDER, 150),
|
||||||
|
BORDER_NEON(LookSlot.AVATAR_BORDER, 200),
|
||||||
|
BADGE_PILE_SLAYER(LookSlot.BADGE, 100),
|
||||||
|
BADGE_COMPLETIONIST(LookSlot.BADGE, 150),
|
||||||
|
ACCENT_GOLD(LookSlot.ACCENT, 200),
|
||||||
|
ACCENT_TEAL(LookSlot.ACCENT, 200),
|
||||||
|
FRAME_GOLD(LookSlot.BANNER_FRAME, 150),
|
||||||
|
FRAME_NEON(LookSlot.BANNER_FRAME, 200);
|
||||||
|
|
||||||
|
private final LookSlot slot;
|
||||||
|
private final int price;
|
||||||
|
|
||||||
|
ShopItem(LookSlot slot, int price) {
|
||||||
|
this.slot = slot;
|
||||||
|
this.price = price;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LookSlot slot() {
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int price() {
|
||||||
|
return price;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Set<String> codes() {
|
||||||
|
return Arrays.stream(values()).map(Enum::name).collect(Collectors.toSet());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package core_service.modules.rewards.domain;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param manual a game added by hand: it has no playtime proof
|
||||||
|
* @param playedAfter a playtime day on or after the day of the start
|
||||||
|
*/
|
||||||
|
public record StartFacts(long appid, boolean manual, Instant startedAt, boolean playedAfter) {}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package core_service.modules.rewards.repo;
|
||||||
|
|
||||||
|
import core_service.modules.rewards.domain.FinishFacts;
|
||||||
|
import core_service.modules.rewards.domain.RewardRules;
|
||||||
|
import core_service.modules.rewards.domain.StartFacts;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||||
|
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.sql.Date;
|
||||||
|
import java.time.YearMonth;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the library tables say about a user's games, as raw facts for {@link RewardRules}. Plain SQL, because the
|
||||||
|
* library module owns the entities and a module may not depend on another.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RewardFactsRepository {
|
||||||
|
|
||||||
|
private static final String NOT_DELETED_OR_BLOCKED =
|
||||||
|
"user_id not in (select user_id from deleted_user) and user_id not in (select user_id from blocked_user)";
|
||||||
|
|
||||||
|
private final NamedParameterJdbcTemplate jdbc;
|
||||||
|
|
||||||
|
public boolean isActive(long userId) {
|
||||||
|
return Boolean.TRUE.equals(jdbc.queryForObject("""
|
||||||
|
select not exists (select 1 from deleted_user where user_id = :userId)
|
||||||
|
and not exists (select 1 from blocked_user where user_id = :userId)
|
||||||
|
""", new MapSqlParameterSource("userId", userId), Boolean.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Long> activeUserIds() {
|
||||||
|
return jdbc.queryForList("select distinct user_id from user_game where " + NOT_DELETED_OR_BLOCKED
|
||||||
|
+ " order by user_id", new MapSqlParameterSource(), Long.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first BACKLOG to PLAYING change of each game not paid yet.
|
||||||
|
*/
|
||||||
|
public List<StartFacts> startCandidates(long userId, ZoneId zone) {
|
||||||
|
return jdbc.query("""
|
||||||
|
select c.appid, ug.source = 'MANUAL' as manual, c.started_at,
|
||||||
|
exists (select 1 from user_game_playtime_day d where d.user_id = c.user_id and d.appid = c.appid
|
||||||
|
and d.day >= (c.started_at at time zone :zone)::date) as played_after
|
||||||
|
from (select user_id, appid, min(changed_at) as started_at from user_game_status_change
|
||||||
|
where user_id = :userId and from_status = 'BACKLOG' and to_status = 'PLAYING'
|
||||||
|
group by user_id, appid) c
|
||||||
|
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid and ug.removed_at is null
|
||||||
|
and ug.hidden_at is null
|
||||||
|
where not exists (select 1 from reward_ledger l where l.user_id = c.user_id and l.kind = 'STARTED'
|
||||||
|
and l.ref = c.appid::text)
|
||||||
|
order by c.started_at
|
||||||
|
""", new MapSqlParameterSource("userId", userId).addValue("zone", zone.getId()),
|
||||||
|
(rs, i) -> new StartFacts(rs.getLong("appid"), rs.getBoolean("manual"),
|
||||||
|
rs.getTimestamp("started_at").toInstant(), rs.getBoolean("played_after")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Games that are FINISHED or COMPLETED now, with their first change to one of the two, and no FINISHED or
|
||||||
|
* FINISHED_MANUAL row yet. Oldest first. Only fresh play proves a finish: the ending achievement must be unlocked
|
||||||
|
* no earlier than {@link RewardRules#PLAYED_AROUND_DAYS} before that change, and the playtime share counts only
|
||||||
|
* the playtime days since it. Hidden games pay nothing.
|
||||||
|
*/
|
||||||
|
public List<FinishFacts> finishCandidates(long userId, ZoneId zone) {
|
||||||
|
return jdbc.query("""
|
||||||
|
select c.appid, ug.source = 'MANUAL' as manual, ug.status = 'COMPLETED' as completed,
|
||||||
|
ug.playtime_minutes, t.normally_minutes,
|
||||||
|
coalesce(ug.finished_at_estimate >= c.finished_at - make_interval(days => :aroundDays), false)
|
||||||
|
as ending_unlocked,
|
||||||
|
(select coalesce(sum(d.minutes), 0) from user_game_playtime_day d
|
||||||
|
where d.user_id = c.user_id and d.appid = c.appid
|
||||||
|
and d.day >= (c.finished_at at time zone :zone)::date) as played_since,
|
||||||
|
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.finished_at at time zone :zone)::date - :aroundDays) as played_around,
|
||||||
|
coalesce(s.achievements_total > 0 and m.achievements_unlocked >= s.achievements_total, false)
|
||||||
|
as all_achievements,
|
||||||
|
c.finished_at
|
||||||
|
from (select user_id, appid, min(changed_at) as finished_at from user_game_status_change
|
||||||
|
where user_id = :userId and to_status in ('FINISHED', 'COMPLETED')
|
||||||
|
group by user_id, appid) c
|
||||||
|
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid and ug.removed_at is null
|
||||||
|
and ug.status in ('FINISHED', 'COMPLETED') and ug.hidden_at is null
|
||||||
|
left join game_time_to_beat t on t.appid = c.appid
|
||||||
|
left join user_game_achievement_meta m on m.user_id = c.user_id and m.appid = c.appid
|
||||||
|
left join game_achievement_schema_meta s on s.appid = c.appid
|
||||||
|
where not exists (select 1 from reward_ledger l where l.user_id = c.user_id
|
||||||
|
and l.kind in ('FINISHED', 'FINISHED_MANUAL') and l.ref = c.appid::text)
|
||||||
|
order by c.finished_at
|
||||||
|
""", new MapSqlParameterSource("userId", userId).addValue("zone", zone.getId())
|
||||||
|
.addValue("aroundDays", RewardRules.PLAYED_AROUND_DAYS),
|
||||||
|
(rs, i) -> new FinishFacts(rs.getLong("appid"), rs.getBoolean("manual"), rs.getBoolean("completed"),
|
||||||
|
rs.getInt("playtime_minutes"), rs.getInt("played_since"),
|
||||||
|
rs.getObject("normally_minutes", Integer.class),
|
||||||
|
rs.getBoolean("ending_unlocked"), rs.getBoolean("played_around"),
|
||||||
|
rs.getBoolean("all_achievements"), rs.getTimestamp("finished_at").toInstant()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* COMPLETED Steam games with a paid FINISHED row and no COMPLETED row yet; finishedAt is the FINISHED event.
|
||||||
|
*/
|
||||||
|
public List<FinishFacts> completedCandidates(long userId) {
|
||||||
|
return jdbc.query("""
|
||||||
|
select ug.appid, ug.playtime_minutes, f.event_at,
|
||||||
|
coalesce(s.achievements_total > 0 and m.achievements_unlocked >= s.achievements_total, false)
|
||||||
|
as all_achievements
|
||||||
|
from user_game ug
|
||||||
|
join reward_ledger f on f.user_id = ug.user_id and f.kind = 'FINISHED' and f.ref = ug.appid::text
|
||||||
|
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.status = 'COMPLETED' and ug.removed_at is null and ug.hidden_at is null
|
||||||
|
and not exists (select 1 from reward_ledger l where l.user_id = ug.user_id and l.kind = 'COMPLETED'
|
||||||
|
and l.ref = ug.appid::text)
|
||||||
|
""", new MapSqlParameterSource("userId", userId),
|
||||||
|
(rs, i) -> new FinishFacts(rs.getLong("appid"), false, true, rs.getInt("playtime_minutes"), 0, null,
|
||||||
|
false, false, rs.getBoolean("all_achievements"), rs.getTimestamp("event_at").toInstant()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pile (backlog_games) on the first and on the last day of the month, {first, last}. Empty unless both days
|
||||||
|
* have a row.
|
||||||
|
*/
|
||||||
|
public Optional<int[]> pileFirstAndLast(long userId, YearMonth month) {
|
||||||
|
List<int[]> rows = jdbc.query("""
|
||||||
|
select (select backlog_games from backlog_snapshot where user_id = :userId and day = :first) as first_day,
|
||||||
|
(select backlog_games from backlog_snapshot where user_id = :userId and day = :last) as last_day
|
||||||
|
""", new MapSqlParameterSource("userId", userId).addValue("first", Date.valueOf(month.atDay(1)))
|
||||||
|
.addValue("last", Date.valueOf(month.atEndOfMonth())),
|
||||||
|
(rs, i) -> {
|
||||||
|
Integer first = rs.getObject("first_day", Integer.class);
|
||||||
|
Integer last = rs.getObject("last_day", Integer.class);
|
||||||
|
return first == null || last == null ? null : new int[]{first, last};
|
||||||
|
});
|
||||||
|
return rows.stream().filter(Objects::nonNull).findFirst();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package core_service.modules.rewards.repo;
|
||||||
|
|
||||||
|
import core_service.modules.rewards.domain.HistoryEntry;
|
||||||
|
import core_service.modules.rewards.domain.LookSlot;
|
||||||
|
import core_service.modules.rewards.domain.RewardEntry;
|
||||||
|
import core_service.modules.rewards.domain.RewardKind;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.jdbc.core.RowCallbackHandler;
|
||||||
|
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||||
|
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.EnumMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tables reward_ledger and reward_equipped. The balance is always the sum of the rows.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RewardLedgerRepository {
|
||||||
|
|
||||||
|
private final NamedParameterJdbcTemplate jdbc;
|
||||||
|
|
||||||
|
private static List<String> names(Collection<RewardKind> kinds) {
|
||||||
|
return kinds.stream().map(Enum::name).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds until the end of the transaction. Settle and buy take it first: the weekly cap and the balance are
|
||||||
|
* checked against rows another request could write at the same time.
|
||||||
|
*/
|
||||||
|
public void lockUser(long userId) {
|
||||||
|
jdbc.query("select pg_advisory_xact_lock(:userId)", new MapSqlParameterSource("userId", userId),
|
||||||
|
(RowCallbackHandler) rs -> { });
|
||||||
|
}
|
||||||
|
|
||||||
|
public long balance(long userId) {
|
||||||
|
Long sum = jdbc.queryForObject("select coalesce(sum(points), 0) from reward_ledger where user_id = :userId",
|
||||||
|
new MapSqlParameterSource("userId", userId), Long.class);
|
||||||
|
return sum != null ? sum : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return false when the user already has a row with this kind and ref
|
||||||
|
*/
|
||||||
|
public boolean add(long userId, RewardEntry entry) {
|
||||||
|
return jdbc.update("""
|
||||||
|
insert into reward_ledger (user_id, kind, ref, points, event_at)
|
||||||
|
values (:userId, :kind, :ref, :points, :eventAt)
|
||||||
|
on conflict (user_id, kind, ref) do nothing
|
||||||
|
""", new MapSqlParameterSource("userId", userId).addValue("kind", entry.kind().name())
|
||||||
|
.addValue("ref", entry.ref()).addValue("points", entry.points())
|
||||||
|
.addValue("eventAt", Timestamp.from(entry.eventAt()))) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Newest first. A manual game is in steam_game too (negative appid), so it has its name as well.
|
||||||
|
*/
|
||||||
|
public List<HistoryEntry> history(long userId, int limit) {
|
||||||
|
return jdbc.query("""
|
||||||
|
select l.kind, l.ref, sg.name, l.points, l.event_at
|
||||||
|
from reward_ledger l
|
||||||
|
left join steam_game sg on sg.appid::text = l.ref and l.kind in (:gameKinds)
|
||||||
|
where l.user_id = :userId
|
||||||
|
order by l.created_at desc, l.id desc
|
||||||
|
limit :limit
|
||||||
|
""", new MapSqlParameterSource("userId", userId).addValue("limit", limit)
|
||||||
|
.addValue("gameKinds", names(RewardKind.GAME_KINDS)),
|
||||||
|
(rs, i) -> new HistoryEntry(RewardKind.valueOf(rs.getString("kind")), rs.getString("ref"),
|
||||||
|
rs.getString("name"), rs.getInt("points"), rs.getTimestamp("event_at").toInstant()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The codes of the items the user bought.
|
||||||
|
*/
|
||||||
|
public Set<String> bought(long userId) {
|
||||||
|
return new HashSet<>(jdbc.queryForList("select ref from reward_ledger where user_id = :userId and kind = 'BUY'",
|
||||||
|
new MapSqlParameterSource("userId", userId), String.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rows with an event_at in [from, to).
|
||||||
|
*/
|
||||||
|
public int countKindInRange(long userId, Collection<RewardKind> kinds, Instant from, Instant to) {
|
||||||
|
Integer count = jdbc.queryForObject("""
|
||||||
|
select count(*) from reward_ledger
|
||||||
|
where user_id = :userId and kind in (:kinds) and event_at >= :from and event_at < :to
|
||||||
|
""", new MapSqlParameterSource("userId", userId).addValue("kinds", names(kinds))
|
||||||
|
.addValue("from", Timestamp.from(from)).addValue("to", Timestamp.from(to)), Integer.class);
|
||||||
|
return count != null ? count : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Instant> eventsOfKinds(long userId, Collection<RewardKind> kinds, Instant since) {
|
||||||
|
return jdbc.query("""
|
||||||
|
select event_at from reward_ledger
|
||||||
|
where user_id = :userId and kind in (:kinds) and event_at >= :since order by event_at
|
||||||
|
""", new MapSqlParameterSource("userId", userId).addValue("kinds", names(kinds))
|
||||||
|
.addValue("since", Timestamp.from(since)), (rs, i) -> rs.getTimestamp("event_at").toInstant());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<LookSlot, String> equipped(long userId) {
|
||||||
|
Map<LookSlot, String> result = new EnumMap<>(LookSlot.class);
|
||||||
|
jdbc.query("select slot, item_code from reward_equipped where user_id = :userId",
|
||||||
|
new MapSqlParameterSource("userId", userId),
|
||||||
|
(RowCallbackHandler) rs -> result.put(LookSlot.valueOf(rs.getString("slot")), rs.getString("item_code")));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void equip(long userId, LookSlot slot, String code) {
|
||||||
|
jdbc.update("""
|
||||||
|
insert into reward_equipped (user_id, slot, item_code) values (:userId, :slot, :code)
|
||||||
|
on conflict (user_id, slot) do update set item_code = excluded.item_code
|
||||||
|
""", new MapSqlParameterSource("userId", userId).addValue("slot", slot.name()).addValue("code", code));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void unequip(long userId, LookSlot slot) {
|
||||||
|
jdbc.update("delete from reward_equipped where user_id = :userId and slot = :slot",
|
||||||
|
new MapSqlParameterSource("userId", userId).addValue("slot", slot.name()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package core_service.modules.rewards.service;
|
||||||
|
|
||||||
|
import core_service.modules.rewards.domain.FinishFacts;
|
||||||
|
import core_service.modules.rewards.domain.HistoryEntry;
|
||||||
|
import core_service.modules.rewards.domain.LookSlot;
|
||||||
|
import core_service.modules.rewards.domain.Looks;
|
||||||
|
import core_service.modules.rewards.domain.RewardEntry;
|
||||||
|
import core_service.modules.rewards.domain.RewardKind;
|
||||||
|
import core_service.modules.rewards.domain.RewardRules;
|
||||||
|
import core_service.modules.rewards.repo.RewardFactsRepository;
|
||||||
|
import core_service.modules.rewards.repo.RewardLedgerRepository;
|
||||||
|
import core_service.shared.security.RequiresRole;
|
||||||
|
import core_service.shared.security.Role;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
|
||||||
|
import java.time.DayOfWeek;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.YearMonth;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.temporal.TemporalAdjusters;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pile Points. No code path pays points: a settle derives the rows from the data the library already keeps
|
||||||
|
* (status log, playtime days, achievements, pile snapshots) and inserts them once per (user, kind, ref).
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiresRole(Role.USER)
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RewardService {
|
||||||
|
|
||||||
|
private static final int HISTORY_SIZE = 50;
|
||||||
|
|
||||||
|
private final RewardLedgerRepository ledger;
|
||||||
|
private final RewardFactsRepository facts;
|
||||||
|
private final TransactionTemplate transactionTemplate;
|
||||||
|
|
||||||
|
@Value("${app.library.zone:Europe/Berlin}")
|
||||||
|
private ZoneId zone;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param owned the codes the user may wear: bought, or all for a platform admin
|
||||||
|
* @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) {}
|
||||||
|
|
||||||
|
// ponytail: settles on every wallet read (the app reads it once per start); a settled_at column if that hurts.
|
||||||
|
@Transactional
|
||||||
|
public Wallet wallet(long userId, Role role) {
|
||||||
|
settleLocked(userId);
|
||||||
|
Set<String> owned = Looks.owned(ledger.bought(userId), role);
|
||||||
|
return new Wallet(ledger.balance(userId), owned, Looks.wearable(ledger.equipped(userId), owned),
|
||||||
|
ledger.history(userId, HISTORY_SIZE));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every active user, each in its own transaction; one failure does not stop the others. Runs after the nightly
|
||||||
|
* sync and the image repair, so month and streak bonuses also reach users who do not open the app.
|
||||||
|
*/
|
||||||
|
@Scheduled(cron = "${app.rewards.settle-cron:0 30 2 * * *}", zone = "${app.library.zone:Europe/Berlin}")
|
||||||
|
public void settleAll() {
|
||||||
|
List<Long> userIds = facts.activeUserIds();
|
||||||
|
log.info("Nightly reward settle for {} users", userIds.size());
|
||||||
|
for (long userId : userIds) {
|
||||||
|
try {
|
||||||
|
transactionTemplate.executeWithoutResult(status -> settleLocked(userId));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Reward settle of user {} failed", userId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void settleLocked(long userId) {
|
||||||
|
if (!facts.isActive(userId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ledger.lockUser(userId);
|
||||||
|
int paid = settleStarts(userId) + settleFinishes(userId) + settleCompleted(userId)
|
||||||
|
+ settleMonths(userId) + settleStreaks(userId);
|
||||||
|
if (paid > 0) {
|
||||||
|
log.info("Reward settle of user {} booked {} rows", userId, paid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int book(long userId, RewardEntry entry) {
|
||||||
|
return ledger.add(userId, entry) ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int settleStarts(long userId) {
|
||||||
|
return facts.startCandidates(userId, zone).stream()
|
||||||
|
.flatMap(f -> RewardRules.start(f).stream()).mapToInt(e -> book(userId, e)).sum();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int settleFinishes(long userId) {
|
||||||
|
int paid = 0;
|
||||||
|
for (FinishFacts f : facts.finishCandidates(userId, zone)) {
|
||||||
|
RewardEntry entry = RewardRules.finish(f).orElse(null);
|
||||||
|
if (entry == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.kind() == RewardKind.FINISHED_MANUAL) {
|
||||||
|
LocalDate monday = weekStart(entry.eventAt());
|
||||||
|
// The rows booked earlier in this run are in the count already.
|
||||||
|
int used = ledger.countKindInRange(userId, List.of(RewardKind.FINISHED_MANUAL),
|
||||||
|
startOf(monday), startOf(monday.plusWeeks(1)));
|
||||||
|
if (used >= RewardRules.MANUAL_PER_WEEK) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paid += book(userId, entry);
|
||||||
|
}
|
||||||
|
return paid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int settleCompleted(long userId) {
|
||||||
|
return facts.completedCandidates(userId).stream()
|
||||||
|
.flatMap(f -> RewardRules.completed(f).stream()).mapToInt(e -> book(userId, e)).sum();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only full months: the running month is not over.
|
||||||
|
private int settleMonths(long userId) {
|
||||||
|
int paid = 0;
|
||||||
|
for (int back = 1; back <= RewardRules.MONTHS_BACK; back++) {
|
||||||
|
YearMonth month = YearMonth.now(zone).minusMonths(back);
|
||||||
|
Instant from = startOf(month.atDay(1));
|
||||||
|
Instant to = startOf(month.plusMonths(1).atDay(1));
|
||||||
|
boolean smaller = facts.pileFirstAndLast(userId, month).filter(pile -> pile[1] < pile[0]).isPresent();
|
||||||
|
// Hiding games alone shrinks the pile: a month pays only with a finished game in it.
|
||||||
|
if (smaller && ledger.countKindInRange(userId, RewardKind.FINISH_KINDS, from, to) > 0) {
|
||||||
|
paid += book(userId, new RewardEntry(RewardKind.MONTH, RewardRules.monthRef(month),
|
||||||
|
RewardRules.MONTH_BONUS, to));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int settleStreaks(long userId) {
|
||||||
|
LocalDate since = LocalDate.now(zone).with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
|
||||||
|
.minusWeeks(RewardRules.STREAK_WEEKS);
|
||||||
|
Set<LocalDate> weeks = new HashSet<>();
|
||||||
|
ledger.eventsOfKinds(userId, RewardKind.FINISH_KINDS, startOf(since)).forEach(at -> weeks.add(weekStart(at)));
|
||||||
|
int paid = 0;
|
||||||
|
for (LocalDate monday : weeks) {
|
||||||
|
if (weeks.contains(monday.minusWeeks(1))) {
|
||||||
|
paid += book(userId, new RewardEntry(RewardKind.STREAK, RewardRules.weekRef(monday),
|
||||||
|
RewardRules.STREAK_BONUS, startOf(monday)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private LocalDate weekStart(Instant at) {
|
||||||
|
return at.atZone(zone).toLocalDate().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Instant startOf(LocalDate day) {
|
||||||
|
return day.atStartOfDay(zone).toInstant();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,9 @@ app:
|
|||||||
image-repair-batch: 200
|
image-repair-batch: 200
|
||||||
image-repair-max-attempts: 3
|
image-repair-max-attempts: 3
|
||||||
image-repair-retry-days: 30
|
image-repair-retry-days: 30
|
||||||
|
rewards:
|
||||||
|
# Pile Points of every user, after the nightly sync (23:55) and the image repair (01:30).
|
||||||
|
settle-cron: "0 30 2 * * *"
|
||||||
account-cleanup:
|
account-cleanup:
|
||||||
# Log only, delete nothing. Auth's nightly cleanup drives this; see the Deployment README before flipping it.
|
# Log only, delete nothing. Auth's nightly cleanup drives this; see the Deployment README before flipping it.
|
||||||
dry-run: ${APP_ACCOUNT_CLEANUP_DRY_RUN:true}
|
dry-run: ${APP_ACCOUNT_CLEANUP_DRY_RUN:true}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- Pile Points (ROADMAP item 17): every earn and spend is a row, the balance is the sum, never one stored number.
|
||||||
|
-- (user_id, kind, ref) is the "once per game and kind, ever" rule; ref is the appid, a month (2026-10), an ISO
|
||||||
|
-- week (2026-W41), an item code (BUY) or STARTER. event_at is when the game event happened (the status change),
|
||||||
|
-- created_at when the row was written; a later settle still books the event in its own week and month.
|
||||||
|
CREATE TABLE reward_ledger (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
kind VARCHAR(16) NOT NULL,
|
||||||
|
ref VARCHAR(32) NOT NULL,
|
||||||
|
points INTEGER NOT NULL,
|
||||||
|
event_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (user_id, kind, ref)
|
||||||
|
);
|
||||||
|
CREATE TABLE reward_equipped (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
slot VARCHAR(16) NOT NULL,
|
||||||
|
item_code VARCHAR(32) NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, slot)
|
||||||
|
);
|
||||||
|
-- Starter grant: finishes from before the status log (V14) can never be paid by the rules; 20 per game, cap 250.
|
||||||
|
INSERT INTO reward_ledger (user_id, kind, ref, points, event_at)
|
||||||
|
SELECT ug.user_id, 'STARTER', 'STARTER', LEAST(250, 20 * COUNT(*)), NOW()
|
||||||
|
FROM listed_user_game ug
|
||||||
|
WHERE ug.status IN ('FINISHED', 'COMPLETED') AND ug.playtime_minutes > 0
|
||||||
|
AND ug.user_id NOT IN (SELECT user_id FROM deleted_user)
|
||||||
|
AND NOT 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'))
|
||||||
|
GROUP BY ug.user_id;
|
||||||
@@ -17,7 +17,7 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
|
|||||||
|
|
||||||
private static final List<String> USER_TABLES = List.of("user_game_status_change", "user_game_achievement",
|
private static final List<String> USER_TABLES = List.of("user_game_status_change", "user_game_achievement",
|
||||||
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
|
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
|
||||||
"user_steam_sync", "user_game", "game_import");
|
"user_steam_sync", "user_game", "game_import", "reward_ledger", "reward_equipped");
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private AccountCleanupRepository repository;
|
private AccountCleanupRepository repository;
|
||||||
@@ -43,6 +43,8 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
|
|||||||
insert into user_steam_sync (user_id, steam_id, user_seen_at) values (?, '76561198000000001', now())""",
|
insert into user_steam_sync (user_id, steam_id, user_seen_at) values (?, '76561198000000001', now())""",
|
||||||
userId);
|
userId);
|
||||||
jdbc.update("insert into game_import (user_id, source, status, total) values (?, 'TEXT', 'SAVED', 0)", userId);
|
jdbc.update("insert into game_import (user_id, source, status, total) values (?, 'TEXT', 'SAVED', 0)", userId);
|
||||||
|
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'STARTER', 'STARTER', 20, now())", userId);
|
||||||
|
jdbc.update("insert into reward_equipped (user_id, slot, item_code) values (?, 'BADGE', 'BADGE_PILE_SLAYER')", userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ class GameMergeFlowTest extends AbstractIntegrationTest {
|
|||||||
void seed() {
|
void seed() {
|
||||||
jdbc.update("delete from game_alias");
|
jdbc.update("delete from game_alias");
|
||||||
jdbc.update("delete from admin_audit");
|
jdbc.update("delete from admin_audit");
|
||||||
|
jdbc.update("delete from reward_ledger where user_id in (?, ?)", AUDITOR_ID, OTHER_USER);
|
||||||
gameCorrectionRepository.deleteAll();
|
gameCorrectionRepository.deleteAll();
|
||||||
userGameRepository.deleteAll();
|
userGameRepository.deleteAll();
|
||||||
gameMoodsRepository.deleteAll();
|
gameMoodsRepository.deleteAll();
|
||||||
@@ -160,6 +161,22 @@ class GameMergeFlowTest extends AbstractIntegrationTest {
|
|||||||
+ " \"Hades (Switch)\" into \"Hades\": 1 moved, 0 combined");
|
+ " \"Hades (Switch)\" into \"Hades\": 1 moved, 0 combined");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void paidGameRows_MoveToTheKeptKey_UnlessTheUserHoldsTheKindThereAlready() throws Exception {
|
||||||
|
manualRow(AUDITOR_ID, MANUAL, GameStatus.FINISHED);
|
||||||
|
for (Object[] row : List.of(new Object[]{"FINISHED_MANUAL", MANUAL}, new Object[]{"STARTED", MANUAL},
|
||||||
|
new Object[]{"STARTED", STEAM}, new Object[]{"MONTH", "2026-09"})) {
|
||||||
|
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, ?, ?, 5, now())",
|
||||||
|
AUDITOR_ID, row[0], String.valueOf(row[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
merge(Role.AUDITOR, MANUAL, STEAM).andExpect(status().isOk());
|
||||||
|
|
||||||
|
assertThat(jdbc.queryForList("select kind || ' ' || ref from reward_ledger where user_id = ? ",
|
||||||
|
String.class, AUDITOR_ID)).containsExactlyInAnyOrder("FINISHED_MANUAL " + STEAM, "MONTH 2026-09",
|
||||||
|
"STARTED " + MANUAL, "STARTED " + STEAM);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void userWithBothRows_KeepsTheSteamRowWithTheNewerHandSetFields() throws Exception {
|
void userWithBothRows_KeepsTheSteamRowWithTheNewerHandSetFields() throws Exception {
|
||||||
UserGame steam = new UserGame(AUDITOR_ID, STEAM, 300, Instant.now(), Instant.now());
|
UserGame steam = new UserGame(AUDITOR_ID, STEAM, 300, Instant.now(), Instant.now());
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package core_service.modules.rewards.api;
|
||||||
|
|
||||||
|
import core_service.modules.rewards.service.RewardService;
|
||||||
|
import core_service.shared.test.AbstractIntegrationTest;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dev profile authenticates every request as user 1.
|
||||||
|
*/
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
class RewardControllerTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
private static final long USER = 1L;
|
||||||
|
private static final long GAME = 4_100_001L;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RewardService rewardService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void clean() {
|
||||||
|
jdbc.update("delete from reward_ledger where user_id = ?", USER);
|
||||||
|
jdbc.update("delete from reward_equipped 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void game(long appid, String source, String status, int playtime) {
|
||||||
|
jdbc.update("insert into steam_game (appid, name) values (?, ?) on conflict do nothing", appid, "Game " + appid);
|
||||||
|
jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes, source) values (?, ?, ?, ?, ?)",
|
||||||
|
USER, appid, status, playtime, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setStatus(long appid, String status) {
|
||||||
|
jdbc.update("update user_game set status = ? where user_id = ? and appid = ?", status, USER, appid);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int rows(String kind) {
|
||||||
|
Integer count = jdbc.queryForObject("select count(*) from reward_ledger where user_id = ? and kind = ?",
|
||||||
|
Integer.class, USER, kind);
|
||||||
|
return count != null ? count : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void wallet() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/rewards/wallet")).andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wallet_SteamFinishWithPlaytimeProof_PaysOnceEvenAfterBacklogAndFinishedAgain() 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/wallet"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.balance").value(30))
|
||||||
|
.andExpect(jsonPath("$.history[0].kind").value("FINISHED"))
|
||||||
|
.andExpect(jsonPath("$.history[0].gameName").value("Game " + GAME))
|
||||||
|
.andExpect(jsonPath("$.catalog.length()").value(8))
|
||||||
|
.andExpect(jsonPath("$.owned").isEmpty());
|
||||||
|
|
||||||
|
setStatus(GAME, "BACKLOG");
|
||||||
|
setStatus(GAME, "FINISHED");
|
||||||
|
wallet();
|
||||||
|
|
||||||
|
assertThat(rows("FINISHED")).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wallet_HandSetFinishedWithoutPlaytime_PaysNothingEvenWithTheEndingProof() throws Exception {
|
||||||
|
game(GAME, "STEAM", "BACKLOG", 0);
|
||||||
|
jdbc.update("update user_game set finished_at_estimate = now() where user_id = ?", USER);
|
||||||
|
setStatus(GAME, "FINISHED");
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/rewards/wallet")).andExpect(jsonPath("$.balance").value(0));
|
||||||
|
|
||||||
|
assertThat(rows("FINISHED")).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wallet_FourManualFinishesInOneWeek_PayThree() throws Exception {
|
||||||
|
for (long appid = -4_100_010L; appid > -4_100_014L; appid--) {
|
||||||
|
game(appid, "MANUAL", "BACKLOG", 0);
|
||||||
|
setStatus(appid, "FINISHED");
|
||||||
|
}
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/rewards/wallet")).andExpect(jsonPath("$.balance").value(45));
|
||||||
|
|
||||||
|
assertThat(rows("FINISHED_MANUAL")).isEqualTo(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wallet_ManualGameInTheHistoryHasItsName() throws Exception {
|
||||||
|
game(-4_100_020L, "MANUAL", "BACKLOG", 0);
|
||||||
|
setStatus(-4_100_020L, "FINISHED");
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/rewards/wallet"))
|
||||||
|
.andExpect(jsonPath("$.history[0].kind").value("FINISHED_MANUAL"))
|
||||||
|
.andExpect(jsonPath("$.history[0].gameName").value("Game -4100020"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void settleAll_RunTwice_WritesTheSameRows() {
|
||||||
|
game(GAME, "STEAM", "BACKLOG", 120);
|
||||||
|
setStatus(GAME, "PLAYING");
|
||||||
|
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, current_date, ?, 30)",
|
||||||
|
USER, GAME);
|
||||||
|
setStatus(GAME, "FINISHED");
|
||||||
|
|
||||||
|
rewardService.settleAll();
|
||||||
|
long first = ledgerSum();
|
||||||
|
rewardService.settleAll();
|
||||||
|
|
||||||
|
assertThat(first).isEqualTo(35);
|
||||||
|
assertThat(ledgerSum()).isEqualTo(first);
|
||||||
|
assertThat(rows("STARTED")).isEqualTo(1);
|
||||||
|
assertThat(rows("FINISHED")).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long ledgerSum() {
|
||||||
|
Long sum = jdbc.queryForObject("select coalesce(sum(points), 0) from reward_ledger where user_id = ?", Long.class, USER);
|
||||||
|
return sum != null ? sum : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package core_service.modules.rewards.domain;
|
||||||
|
|
||||||
|
import core_service.shared.security.Role;
|
||||||
|
import core_service.shared.test.AbstractUnitTest;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class LooksTest extends AbstractUnitTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void owned_AdminOwnsTheWholeCatalog() {
|
||||||
|
assertThat(Looks.owned(Set.of(), Role.PLATFORM_ADMIN)).isEqualTo(ShopItem.codes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void owned_UserOwnsOnlyWhatIsBought() {
|
||||||
|
assertThat(Looks.owned(Set.of("BORDER_GOLD"), Role.USER)).containsExactly("BORDER_GOLD");
|
||||||
|
assertThat(Looks.owned(Set.of(), Role.AUDITOR)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wearable_DropsLooksThatAreNotOwnedOrNotInTheCatalog() {
|
||||||
|
Map<LookSlot, String> equipped = Map.of(LookSlot.AVATAR_BORDER, "BORDER_GOLD", LookSlot.BADGE, "BADGE_PILE_SLAYER",
|
||||||
|
LookSlot.ACCENT, "ACCENT_NEON_PINK");
|
||||||
|
|
||||||
|
assertThat(Looks.wearable(equipped, Set.of("BORDER_GOLD", "ACCENT_NEON_PINK")))
|
||||||
|
.containsOnly(Map.entry(LookSlot.AVATAR_BORDER, "BORDER_GOLD"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package core_service.modules.rewards.domain;
|
||||||
|
|
||||||
|
import core_service.shared.test.AbstractUnitTest;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.YearMonth;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class RewardRulesTest extends AbstractUnitTest {
|
||||||
|
|
||||||
|
private static final Instant AT = Instant.parse("2026-10-01T10:00:00Z");
|
||||||
|
|
||||||
|
private static FinishFacts steam(int playtime, Integer normallyMinutes, boolean ending, boolean around) {
|
||||||
|
return new FinishFacts(220L, false, false, playtime, playtime, normallyMinutes, ending, around, false, AT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int points(FinishFacts facts) {
|
||||||
|
return RewardRules.finish(facts).map(RewardEntry::points).orElse(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void finish_PaysByTheLengthOfTheGame() {
|
||||||
|
assertThat(points(steam(60, null, true, false))).isEqualTo(30);
|
||||||
|
assertThat(points(steam(60, 9 * 60 + 59, true, false))).isEqualTo(30);
|
||||||
|
assertThat(points(steam(60, 10 * 60, true, false))).isEqualTo(50);
|
||||||
|
assertThat(points(steam(60, 30 * 60, true, false))).isEqualTo(50);
|
||||||
|
assertThat(points(steam(60, 31 * 60, true, false))).isEqualTo(80);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void finish_WithoutPlaytime_PaysNothingEvenWithTheEndingProof() {
|
||||||
|
assertThat(RewardRules.finish(steam(0, 600, true, true))).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void finish_NeedsOneProof() {
|
||||||
|
assertThat(points(steam(60, 300, false, false))).isZero();
|
||||||
|
assertThat(points(steam(60, 300, true, false))).isEqualTo(30);
|
||||||
|
assertThat(points(steam(60, 300, false, true))).isEqualTo(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void finish_HalfOfTheNormalTimeIsAProof() {
|
||||||
|
assertThat(points(steam(149, 300, false, false))).isZero();
|
||||||
|
assertThat(points(steam(150, 300, false, false))).isEqualTo(30);
|
||||||
|
assertThat(points(steam(150, null, false, false))).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void finish_ShareProofCountsOnlyPlaytimeSinceTheFinish() {
|
||||||
|
FinishFacts oldPlay = new FinishFacts(220L, false, false, 500, 100, 300, false, false, false, AT);
|
||||||
|
|
||||||
|
assertThat(RewardRules.finish(oldPlay)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void finish_ManualGame_PaysTheSmallPriceWithoutProof() {
|
||||||
|
FinishFacts manual = new FinishFacts(-5L, true, false, 0, 0, null, false, false, false, AT);
|
||||||
|
|
||||||
|
assertThat(RewardRules.finish(manual)).contains(new RewardEntry(RewardKind.FINISHED_MANUAL, "-5", 15, AT));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void completed_NeedsAllAchievementsAndASteamGame() {
|
||||||
|
FinishFacts all = new FinishFacts(220L, false, true, 60, 60, null, true, true, true, AT);
|
||||||
|
|
||||||
|
assertThat(RewardRules.completed(all)).contains(new RewardEntry(RewardKind.COMPLETED, "220", 40, AT));
|
||||||
|
assertThat(RewardRules.completed(new FinishFacts(220L, false, true, 60, 60, null, true, true, false, AT))).isEmpty();
|
||||||
|
assertThat(RewardRules.completed(new FinishFacts(220L, false, false, 60, 60, null, true, true, true, AT))).isEmpty();
|
||||||
|
assertThat(RewardRules.completed(new FinishFacts(220L, true, true, 60, 60, null, true, true, true, AT))).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void start_NeedsASteamGameWithPlaytimeAfterTheStart() {
|
||||||
|
Optional<RewardEntry> paid = RewardRules.start(new StartFacts(220L, false, AT, true));
|
||||||
|
|
||||||
|
assertThat(paid).contains(new RewardEntry(RewardKind.STARTED, "220", 5, AT));
|
||||||
|
assertThat(RewardRules.start(new StartFacts(220L, false, AT, false))).isEmpty();
|
||||||
|
assertThat(RewardRules.start(new StartFacts(-5L, true, AT, true))).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refs_UseTheIsoWeekAndTheMonth() {
|
||||||
|
assertThat(RewardRules.monthRef(YearMonth.of(2026, 10))).isEqualTo("2026-10");
|
||||||
|
assertThat(RewardRules.weekRef(LocalDate.of(2026, 10, 8))).isEqualTo("2026-W41");
|
||||||
|
assertThat(RewardRules.weekRef(LocalDate.of(2027, 1, 1))).isEqualTo("2026-W53");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
package core_service.modules.rewards.repo;
|
||||||
|
|
||||||
|
import core_service.modules.rewards.domain.FinishFacts;
|
||||||
|
import core_service.modules.rewards.domain.RewardEntry;
|
||||||
|
import core_service.modules.rewards.domain.RewardKind;
|
||||||
|
import core_service.modules.rewards.domain.StartFacts;
|
||||||
|
import core_service.shared.test.AbstractPersistenceTest;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.YearMonth;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
@Import({RewardFactsRepository.class, RewardLedgerRepository.class})
|
||||||
|
class RewardFactsRepositoryTest extends AbstractPersistenceTest {
|
||||||
|
|
||||||
|
private static final long USER = 7L;
|
||||||
|
private static final long GAME = 220L;
|
||||||
|
private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin");
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RewardFactsRepository repository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RewardLedgerRepository ledger;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
jdbc.update("delete from reward_ledger");
|
||||||
|
jdbc.update("delete from backlog_snapshot");
|
||||||
|
jdbc.update("insert into steam_game (appid, name) values (?, 'Half-Life 2') on conflict do nothing", GAME);
|
||||||
|
jdbc.update("insert into game_time_to_beat (appid, normally_minutes, fetched_at) values (?, 600, now()) "
|
||||||
|
+ "on conflict do nothing", GAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void game(String status, int playtime) {
|
||||||
|
jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes) values (?, ?, ?, ?)",
|
||||||
|
USER, GAME, status, playtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setStatus(String status) {
|
||||||
|
jdbc.update("update user_game set status = ? where user_id = ? and appid = ?", status, USER, GAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The trigger stamps every change with the transaction start; this moves them apart.
|
||||||
|
private void changedAt(String toStatus, String timestamp) {
|
||||||
|
jdbc.update("update user_game_status_change set changed_at = ?::timestamptz where user_id = ? and appid = ? "
|
||||||
|
+ "and to_status = ?", timestamp, USER, GAME, toStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void playedOn(String day) {
|
||||||
|
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, ?::date, ?, 30)",
|
||||||
|
USER, day, GAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void finishCandidates_AppearsAfterTheChangeWithItsFacts() {
|
||||||
|
game("BACKLOG", 120);
|
||||||
|
setStatus("FINISHED");
|
||||||
|
changedAt("FINISHED", "2026-09-15T10:00:00Z");
|
||||||
|
playedOn("2026-08-20");
|
||||||
|
jdbc.update("update user_game set finished_at_estimate = now() where user_id = ?", USER);
|
||||||
|
|
||||||
|
List<FinishFacts> candidates = repository.finishCandidates(USER, BERLIN);
|
||||||
|
|
||||||
|
assertThat(candidates).singleElement().satisfies(f -> {
|
||||||
|
assertThat(f.appid()).isEqualTo(GAME);
|
||||||
|
assertThat(f.manual()).isFalse();
|
||||||
|
assertThat(f.completed()).isFalse();
|
||||||
|
assertThat(f.playtimeMinutes()).isEqualTo(120);
|
||||||
|
assertThat(f.normallyMinutes()).isEqualTo(600);
|
||||||
|
assertThat(f.endingUnlocked()).isTrue();
|
||||||
|
assertThat(f.playedAround()).isTrue();
|
||||||
|
assertThat(f.allAchievements()).isFalse();
|
||||||
|
assertThat(f.finishedAt()).isEqualTo(Instant.parse("2026-09-15T10:00:00Z"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void finishCandidates_PlaytimeMoreThan30DaysBeforeIsNoProof() {
|
||||||
|
game("BACKLOG", 120);
|
||||||
|
setStatus("FINISHED");
|
||||||
|
changedAt("FINISHED", "2026-09-15T10:00:00Z");
|
||||||
|
playedOn("2026-08-10");
|
||||||
|
|
||||||
|
assertThat(repository.finishCandidates(USER, BERLIN)).singleElement()
|
||||||
|
.satisfies(f -> assertThat(f.playedAround()).isFalse());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void finishCandidates_AllAchievementsNeedTheSchemaTotalAndTheUnlockCount() {
|
||||||
|
game("BACKLOG", 120);
|
||||||
|
setStatus("COMPLETED");
|
||||||
|
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)", USER, GAME);
|
||||||
|
|
||||||
|
assertThat(repository.finishCandidates(USER, BERLIN)).singleElement().satisfies(f -> {
|
||||||
|
assertThat(f.completed()).isTrue();
|
||||||
|
assertThat(f.allAchievements()).isTrue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void finishCandidates_GoneOnceAFinishedRowExistsOrTheGameWentBackToBacklog() {
|
||||||
|
game("BACKLOG", 120);
|
||||||
|
setStatus("FINISHED");
|
||||||
|
assertThat(repository.finishCandidates(USER, BERLIN)).hasSize(1);
|
||||||
|
|
||||||
|
setStatus("BACKLOG");
|
||||||
|
assertThat(repository.finishCandidates(USER, BERLIN)).isEmpty();
|
||||||
|
|
||||||
|
setStatus("FINISHED");
|
||||||
|
ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(GAME), 30, Instant.now()));
|
||||||
|
assertThat(repository.finishCandidates(USER, BERLIN)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void finishCandidates_HiddenGameIsNoCandidate() {
|
||||||
|
game("BACKLOG", 120);
|
||||||
|
setStatus("FINISHED");
|
||||||
|
jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = ?", USER, GAME);
|
||||||
|
|
||||||
|
assertThat(repository.finishCandidates(USER, BERLIN)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void startAndCompletedCandidates_HiddenGameIsNoCandidate() {
|
||||||
|
game("BACKLOG", 60);
|
||||||
|
setStatus("PLAYING");
|
||||||
|
assertThat(repository.startCandidates(USER, BERLIN)).hasSize(1);
|
||||||
|
setStatus("COMPLETED");
|
||||||
|
ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(GAME), 30, Instant.now()));
|
||||||
|
assertThat(repository.completedCandidates(USER)).hasSize(1);
|
||||||
|
|
||||||
|
jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = ?", USER, GAME);
|
||||||
|
|
||||||
|
assertThat(repository.startCandidates(USER, BERLIN)).isEmpty();
|
||||||
|
assertThat(repository.completedCandidates(USER)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void finishCandidates_EndingUnlockedBeforeTheFinishWindowIsNoProof() {
|
||||||
|
game("BACKLOG", 120);
|
||||||
|
setStatus("FINISHED");
|
||||||
|
changedAt("FINISHED", "2026-09-15T10:00:00Z");
|
||||||
|
jdbc.update("update user_game set finished_at_estimate = '2015-01-01T00:00:00Z' where user_id = ?", USER);
|
||||||
|
|
||||||
|
assertThat(repository.finishCandidates(USER, BERLIN)).singleElement()
|
||||||
|
.satisfies(f -> assertThat(f.endingUnlocked()).isFalse());
|
||||||
|
|
||||||
|
jdbc.update("update user_game set finished_at_estimate = '2026-08-20T00:00:00Z' where user_id = ?", USER);
|
||||||
|
|
||||||
|
assertThat(repository.finishCandidates(USER, BERLIN)).singleElement()
|
||||||
|
.satisfies(f -> assertThat(f.endingUnlocked()).isTrue());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void finishCandidates_PlayedSinceCountsOnlyDaysFromTheFinishDay() {
|
||||||
|
game("BACKLOG", 500);
|
||||||
|
setStatus("FINISHED");
|
||||||
|
changedAt("FINISHED", "2026-09-15T10:00:00Z");
|
||||||
|
playedOn("2026-09-14");
|
||||||
|
playedOn("2026-09-15");
|
||||||
|
playedOn("2026-09-20");
|
||||||
|
|
||||||
|
assertThat(repository.finishCandidates(USER, BERLIN)).singleElement()
|
||||||
|
.satisfies(f -> assertThat(f.playedSinceMinutes()).isEqualTo(60));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void startCandidates_PlayedAfterTheStartDayAndGoneOncePaid() {
|
||||||
|
game("BACKLOG", 60);
|
||||||
|
setStatus("PLAYING");
|
||||||
|
changedAt("PLAYING", "2026-09-15T10:00:00Z");
|
||||||
|
|
||||||
|
assertThat(repository.startCandidates(USER, BERLIN)).singleElement()
|
||||||
|
.extracting(StartFacts::playedAfter).isEqualTo(false);
|
||||||
|
|
||||||
|
playedOn("2026-09-15");
|
||||||
|
assertThat(repository.startCandidates(USER, BERLIN)).singleElement()
|
||||||
|
.extracting(StartFacts::playedAfter).isEqualTo(true);
|
||||||
|
|
||||||
|
ledger.add(USER, new RewardEntry(RewardKind.STARTED, String.valueOf(GAME), 5, Instant.now()));
|
||||||
|
assertThat(repository.startCandidates(USER, BERLIN)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void completedCandidates_NeedACompletedGameWithAPaidFinish() {
|
||||||
|
game("BACKLOG", 120);
|
||||||
|
setStatus("COMPLETED");
|
||||||
|
assertThat(repository.completedCandidates(USER)).isEmpty();
|
||||||
|
|
||||||
|
Instant finishedAt = Instant.parse("2026-09-15T10:00:00Z");
|
||||||
|
ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(GAME), 30, finishedAt));
|
||||||
|
assertThat(repository.completedCandidates(USER)).singleElement()
|
||||||
|
.extracting(FinishFacts::finishedAt).isEqualTo(finishedAt);
|
||||||
|
|
||||||
|
ledger.add(USER, new RewardEntry(RewardKind.COMPLETED, String.valueOf(GAME), 40, finishedAt));
|
||||||
|
assertThat(repository.completedCandidates(USER)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pileFirstAndLast_NeedsBothDays() {
|
||||||
|
YearMonth month = YearMonth.of(2026, 9);
|
||||||
|
jdbc.update("insert into backlog_snapshot values (?, '2026-09-01', 10, 0, 0, 10)", USER);
|
||||||
|
assertThat(repository.pileFirstAndLast(USER, month)).isEmpty();
|
||||||
|
|
||||||
|
jdbc.update("insert into backlog_snapshot values (?, '2026-09-30', 8, 0, 0, 10)", USER);
|
||||||
|
assertThat(repository.pileFirstAndLast(USER, month)).hasValueSatisfying(pile -> assertThat(pile).containsExactly(10, 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isActive_FalseForDeletedAndBlockedUsers() {
|
||||||
|
game("BACKLOG", 0);
|
||||||
|
jdbc.update("insert into deleted_user (user_id) values (8)");
|
||||||
|
jdbc.update("insert into blocked_user (user_id) values (9)");
|
||||||
|
jdbc.update("insert into user_game (user_id, appid, status) values (8, ?, 'BACKLOG'), (9, ?, 'BACKLOG')", GAME, GAME);
|
||||||
|
|
||||||
|
assertThat(repository.isActive(USER)).isTrue();
|
||||||
|
assertThat(repository.isActive(8L)).isFalse();
|
||||||
|
assertThat(repository.isActive(9L)).isFalse();
|
||||||
|
assertThat(repository.activeUserIds()).contains(USER).doesNotContain(8L, 9L);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package core_service.modules.rewards.repo;
|
||||||
|
|
||||||
|
import core_service.modules.rewards.domain.HistoryEntry;
|
||||||
|
import core_service.modules.rewards.domain.LookSlot;
|
||||||
|
import core_service.modules.rewards.domain.RewardEntry;
|
||||||
|
import core_service.modules.rewards.domain.RewardKind;
|
||||||
|
import core_service.shared.test.AbstractPersistenceTest;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.tuple;
|
||||||
|
|
||||||
|
@Import(RewardLedgerRepository.class)
|
||||||
|
class RewardLedgerRepositoryTest extends AbstractPersistenceTest {
|
||||||
|
|
||||||
|
private static final long USER = 7L;
|
||||||
|
private static final Instant JAN_1 = Instant.parse("2026-01-01T12:00:00Z");
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RewardLedgerRepository repository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
// V26 paid the starter grant of the migration day; the tests start empty.
|
||||||
|
jdbc.update("delete from reward_ledger");
|
||||||
|
jdbc.update("insert into steam_game (appid, name) values (220, 'Half-Life 2') on conflict do nothing");
|
||||||
|
jdbc.update("insert into steam_game (appid, name) values (-1942, 'Zelda') on conflict do nothing");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void add_TheSameKindAndRefTwice_WritesOneRow() {
|
||||||
|
assertThat(repository.add(USER, new RewardEntry(RewardKind.FINISHED, "220", 30, JAN_1))).isTrue();
|
||||||
|
assertThat(repository.add(USER, new RewardEntry(RewardKind.FINISHED, "220", 80, JAN_1))).isFalse();
|
||||||
|
|
||||||
|
assertThat(repository.balance(USER)).isEqualTo(30);
|
||||||
|
assertThat(repository.add(USER, new RewardEntry(RewardKind.STARTED, "220", 5, JAN_1))).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void balance_IsTheSumIncludingSpends() {
|
||||||
|
repository.add(USER, new RewardEntry(RewardKind.FINISHED, "220", 80, JAN_1));
|
||||||
|
repository.add(USER, new RewardEntry(RewardKind.BUY, "BORDER_GOLD", -50, JAN_1));
|
||||||
|
repository.add(USER + 1, new RewardEntry(RewardKind.FINISHED, "220", 30, JAN_1));
|
||||||
|
|
||||||
|
assertThat(repository.balance(USER)).isEqualTo(30);
|
||||||
|
assertThat(repository.balance(99L)).isZero();
|
||||||
|
assertThat(repository.bought(USER)).containsExactly("BORDER_GOLD");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void countKindInRange_CountsByEventTimeFromInclusiveToExclusive() {
|
||||||
|
repository.add(USER, new RewardEntry(RewardKind.FINISHED_MANUAL, "-1", 15, JAN_1));
|
||||||
|
repository.add(USER, new RewardEntry(RewardKind.FINISHED_MANUAL, "-2", 15, JAN_1.plusSeconds(60)));
|
||||||
|
repository.add(USER, new RewardEntry(RewardKind.FINISHED, "220", 30, JAN_1));
|
||||||
|
|
||||||
|
List<RewardKind> manual = List.of(RewardKind.FINISHED_MANUAL);
|
||||||
|
assertThat(repository.countKindInRange(USER, manual, JAN_1, JAN_1.plusSeconds(60))).isEqualTo(1);
|
||||||
|
assertThat(repository.countKindInRange(USER, manual, JAN_1, JAN_1.plusSeconds(61))).isEqualTo(2);
|
||||||
|
assertThat(repository.countKindInRange(USER, RewardKind.FINISH_KINDS, JAN_1, JAN_1.plusSeconds(61))).isEqualTo(3);
|
||||||
|
assertThat(repository.eventsOfKinds(USER, manual, JAN_1.plusSeconds(1))).containsExactly(JAN_1.plusSeconds(60));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void history_CarriesTheGameNameOfSteamAndManualGamesAndNullForOtherRows() {
|
||||||
|
repository.add(USER, new RewardEntry(RewardKind.FINISHED, "220", 30, JAN_1));
|
||||||
|
repository.add(USER, new RewardEntry(RewardKind.FINISHED_MANUAL, "-1942", 15, JAN_1));
|
||||||
|
repository.add(USER, new RewardEntry(RewardKind.MONTH, "2026-09", 40, JAN_1));
|
||||||
|
|
||||||
|
List<HistoryEntry> history = repository.history(USER, 10);
|
||||||
|
|
||||||
|
assertThat(history).extracting(HistoryEntry::kind, HistoryEntry::gameName).containsExactlyInAnyOrder(
|
||||||
|
tuple(RewardKind.FINISHED, "Half-Life 2"),
|
||||||
|
tuple(RewardKind.FINISHED_MANUAL, "Zelda"),
|
||||||
|
tuple(RewardKind.MONTH, null));
|
||||||
|
assertThat(repository.history(USER, 2)).hasSize(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void equip_ReplacesTheItemOfTheSlotAndUnequipRemovesIt() {
|
||||||
|
repository.equip(USER, LookSlot.BADGE, "BADGE_PILE_SLAYER");
|
||||||
|
repository.equip(USER, LookSlot.BADGE, "BADGE_COMPLETIONIST");
|
||||||
|
repository.equip(USER, LookSlot.ACCENT, "ACCENT_GOLD");
|
||||||
|
|
||||||
|
assertThat(repository.equipped(USER)).containsOnly(
|
||||||
|
Map.entry(LookSlot.BADGE, "BADGE_COMPLETIONIST"), Map.entry(LookSlot.ACCENT, "ACCENT_GOLD"));
|
||||||
|
|
||||||
|
repository.unequip(USER, LookSlot.BADGE);
|
||||||
|
|
||||||
|
assertThat(repository.equipped(USER)).containsOnlyKeys(LookSlot.ACCENT);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package core_service.modules.rewards.service;
|
||||||
|
|
||||||
|
import core_service.shared.security.Role;
|
||||||
|
import core_service.shared.test.AbstractIntegrationTest;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
import java.sql.Date;
|
||||||
|
import java.time.YearMonth;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class RewardServiceMonthTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
private static final long USER = 13L;
|
||||||
|
private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin");
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RewardService rewardService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
private final YearMonth month = YearMonth.now(BERLIN).minusMonths(1);
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
@AfterEach
|
||||||
|
void clean() {
|
||||||
|
jdbc.update("delete from reward_ledger where user_id = ?", USER);
|
||||||
|
jdbc.update("delete from backlog_snapshot where user_id = ?", USER);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void smallerPile() {
|
||||||
|
jdbc.update("insert into backlog_snapshot values (?, ?, 10, 0, 0, 10)", USER, Date.valueOf(month.atDay(1)));
|
||||||
|
jdbc.update("insert into backlog_snapshot values (?, ?, 8, 0, 0, 10)", USER, Date.valueOf(month.atEndOfMonth()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void paid(String kind) {
|
||||||
|
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, ?, '1', 5, ?)",
|
||||||
|
USER, kind, java.sql.Timestamp.from(month.atDay(10).atStartOfDay(BERLIN).toInstant()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private int monthRows() {
|
||||||
|
return jdbc.queryForObject("select count(*) from reward_ledger where user_id = ? and kind = 'MONTH'",
|
||||||
|
Integer.class, USER);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wallet_MonthBonusNeedsAFinishedGameInTheMonth_AStartAloneIsNotEnough() {
|
||||||
|
smallerPile();
|
||||||
|
paid("STARTED");
|
||||||
|
|
||||||
|
rewardService.wallet(USER, Role.USER);
|
||||||
|
|
||||||
|
assertThat(monthRows()).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wallet_SmallerPileAndAFinishedGame_PaysTheMonthBonus() {
|
||||||
|
smallerPile();
|
||||||
|
paid("FINISHED_MANUAL");
|
||||||
|
|
||||||
|
rewardService.wallet(USER, Role.USER);
|
||||||
|
|
||||||
|
assertThat(monthRows()).isEqualTo(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ import java.util.concurrent.atomic.AtomicReference;
|
|||||||
// import watchdog runs only when a test calls it.
|
// import watchdog runs only when a test calls it.
|
||||||
// Every cached context keeps its own pool: small pools keep them all below Postgres' 100 clients.
|
// Every cached context keeps its own pool: small pools keep them all below Postgres' 100 clients.
|
||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||||
properties = {"app.library.nightly-sync-cron=-", "app.library.image-repair-cron=-", "app.speedrun.enabled=false",
|
properties = {"app.library.nightly-sync-cron=-", "app.library.image-repair-cron=-", "app.rewards.settle-cron=-", "app.speedrun.enabled=false",
|
||||||
"app.library.import-watchdog-ms=86400000",
|
"app.library.import-watchdog-ms=86400000",
|
||||||
"spring.datasource.hikari.maximum-pool-size=4"})
|
"spring.datasource.hikari.maximum-pool-size=4"})
|
||||||
@ActiveProfiles("dev")
|
@ActiveProfiles("dev")
|
||||||
|
|||||||
Reference in new issue
Block a user