diff --git a/readme.md b/readme.md index 766a3a0..141f3e5 100644 --- a/readme.md +++ b/readme.md @@ -143,7 +143,8 @@ 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 `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 - a finish. Manual finishes and early finishes share the cap of 3 per ISO week. `COMPLETED` needs a `FINISHED` or `FINISHED_TOPUP` row. + 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 + (first `FINISHED`/`COMPLETED` status change) is on or after `app.rewards.early-since`; 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 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 diff --git a/src/main/java/core_service/modules/rewards/domain/NoPointsReason.java b/src/main/java/core_service/modules/rewards/domain/NoPointsReason.java index 83eb49f..795320a 100644 --- a/src/main/java/core_service/modules/rewards/domain/NoPointsReason.java +++ b/src/main/java/core_service/modules/rewards/domain/NoPointsReason.java @@ -30,7 +30,7 @@ public enum NoPointsReason { WEEKLY_LIMIT, /** The weekly cap is used up, so no early points; proof still pays the full finish. */ WEEKLY_LIMIT_NO_PROOF, - /** COMPLETED needs a FINISHED or FINISHED_TOPUP row first. */ + /** COMPLETED needs a FINISHED, FINISHED_TOPUP or FINISHED_MANUAL row on the game or its set first. */ NEEDS_PROVEN_FINISH, /** COMPLETED needs all achievements. */ NOT_ALL_ACHIEVEMENTS diff --git a/src/main/java/core_service/modules/rewards/domain/RewardKind.java b/src/main/java/core_service/modules/rewards/domain/RewardKind.java index ab62ce4..36ae077 100644 --- a/src/main/java/core_service/modules/rewards/domain/RewardKind.java +++ b/src/main/java/core_service/modules/rewards/domain/RewardKind.java @@ -30,6 +30,11 @@ public enum RewardKind { */ public static final List FINISH_KINDS = List.of(FINISHED, FINISHED_MANUAL, FINISHED_EARLY); + /** + * The finish kinds that pay the full finish: a proven Steam finish, its top-up, or a manual finish. They open COMPLETED. + */ + public static final List FULL_FINISH_KINDS = List.of(FINISHED, FINISHED_MANUAL, FINISHED_TOPUP); + /** * The finish kinds that share the weekly cap: a manual finish and an unproven Steam finish. */ diff --git a/src/main/java/core_service/modules/rewards/domain/RewardRules.java b/src/main/java/core_service/modules/rewards/domain/RewardRules.java index b052f79..5fdcb7a 100644 --- a/src/main/java/core_service/modules/rewards/domain/RewardRules.java +++ b/src/main/java/core_service/modules/rewards/domain/RewardRules.java @@ -191,17 +191,17 @@ public final class RewardRules { /** * @param c the completed candidate of the game, null when it is none - * @param provenFinishPaid a FINISHED or FINISHED_TOPUP row is paid for the game or its set + * @param finishPaid a FINISHED or FINISHED_TOPUP row is paid for the game or its set, or a FINISHED_MANUAL row opens COMPLETED * @return why no COMPLETED row is paid, null when nothing stops it */ - public static NoPointsReason noCompletedReason(GameRewardFacts g, FinishFacts c, boolean provenFinishPaid) { + public static NoPointsReason noCompletedReason(GameRewardFacts g, FinishFacts c, boolean finishPaid) { if (!g.completed()) { return NoPointsReason.NOT_COMPLETED; } if (g.manual()) { return NoPointsReason.MANUAL_GAME; } - if (!provenFinishPaid) { + if (!finishPaid) { return NoPointsReason.NEEDS_PROVEN_FINISH; } return c != null && !c.allAchievements() ? NoPointsReason.NOT_ALL_ACHIEVEMENTS : null; diff --git a/src/main/java/core_service/modules/rewards/repo/RewardFactsRepository.java b/src/main/java/core_service/modules/rewards/repo/RewardFactsRepository.java index 4875896..7adedf1 100644 --- a/src/main/java/core_service/modules/rewards/repo/RewardFactsRepository.java +++ b/src/main/java/core_service/modules/rewards/repo/RewardFactsRepository.java @@ -13,6 +13,7 @@ import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Repository; import java.sql.Date; +import java.time.Instant; import java.time.YearMonth; import java.time.ZoneId; import java.util.Collection; @@ -165,25 +166,34 @@ public class RewardFactsRepository { } /** - * COMPLETED Steam games with a paid FINISHED row (on the key or any key of its set) and no COMPLETED row yet; finishedAt is the FINISHED event. - * Every edition of a linked set is a candidate; the service books one per 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 + * the rules reject it. Every edition of a linked set is a candidate; the service books one per set. */ - public List completedCandidates(long userId) { - return completedCandidates(userId, null); + public List completedCandidates(long userId, Instant earlySince) { + return completedCandidates(userId, null, earlySince); } /** * @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 + * COMPLETED on or after this. A manual edition can be added already finished, so its fresh row + * says nothing about an old Steam finish; no status change at all counts as old. */ - public List completedCandidates(long userId, Long appid) { + public List completedCandidates(long userId, Long appid, Instant earlySince) { return jdbc.query(""" - select ug.appid, ug.playtime_minutes, f.event_at, + select ug.appid, ug.source = 'MANUAL' as manual, 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 left join user_game_set st on st.user_id = ug.user_id and st.appid = ug.appid join lateral (select min(l.event_at) as event_at from reward_ledger l - where l.user_id = ug.user_id and l.kind in ('FINISHED', 'FINISHED_TOPUP') + where l.user_id = ug.user_id + and (l.kind in ('FINISHED', 'FINISHED_TOPUP') + or (l.kind = 'FINISHED_MANUAL' and (select min(c.changed_at) 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')) + >= :earlySince)) and (l.ref = ug.appid::text or l.ref in (select o.appid::text from user_game_set o where o.user_id = st.user_id and o.set_id = st.set_id))) f @@ -196,9 +206,10 @@ public class RewardFactsRepository { and not %1$s order by f.event_at, ug.appid """.formatted(paidInSet("ug", "'COMPLETED'")), - new MapSqlParameterSource("userId", userId).addValue("appid", appid), - (rs, i) -> new FinishFacts(rs.getLong("appid"), false, true, rs.getInt("playtime_minutes"), 0, null, - false, false, rs.getBoolean("all_achievements"), rs.getTimestamp("event_at").toInstant(), + new MapSqlParameterSource("userId", userId).addValue("appid", appid) + .addValue("earlySince", java.sql.Timestamp.from(earlySince)), + (rs, i) -> new FinishFacts(rs.getLong("appid"), rs.getBoolean("manual"), true, rs.getInt("playtime_minutes"), 0, + null, false, false, rs.getBoolean("all_achievements"), rs.getTimestamp("event_at").toInstant(), false)); } diff --git a/src/main/java/core_service/modules/rewards/service/RewardService.java b/src/main/java/core_service/modules/rewards/service/RewardService.java index 727644f..9cec1e5 100644 --- a/src/main/java/core_service/modules/rewards/service/RewardService.java +++ b/src/main/java/core_service/modules/rewards/service/RewardService.java @@ -301,9 +301,10 @@ public class RewardService { putReason(missing, RewardKind.FINISHED_TOPUP, RewardRules.noTopUpReason(game, finish)); } if (noRow(paid, List.of(RewardKind.COMPLETED))) { - FinishFacts completed = facts.completedCandidates(userId, appid).stream().findFirst().orElse(null); - putReason(missing, RewardKind.COMPLETED, RewardRules.noCompletedReason(game, completed, - !noRow(paid, List.of(RewardKind.FINISHED, RewardKind.FINISHED_TOPUP)))); + 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. + boolean finishPaid = completed != null || !noRow(paid, List.of(RewardKind.FINISHED, RewardKind.FINISHED_TOPUP)); + putReason(missing, RewardKind.COMPLETED, RewardRules.noCompletedReason(game, completed, finishPaid)); } return missing; } @@ -363,7 +364,7 @@ public class RewardService { private int bookOnePerSet(long userId, RewardEntry entry) { List kinds = switch (entry.kind()) { case FINISHED, FINISHED_MANUAL, FINISHED_EARLY -> RewardKind.FINISH_KINDS; - case FINISHED_TOPUP -> List.of(RewardKind.FINISHED, RewardKind.FINISHED_MANUAL, RewardKind.FINISHED_TOPUP); + case FINISHED_TOPUP -> RewardKind.FULL_FINISH_KINDS; default -> List.of(entry.kind()); }; return facts.paidInSet(userId, Long.parseLong(entry.ref()), kinds) ? 0 : book(userId, entry); @@ -397,7 +398,7 @@ public class RewardService { } private int settleCompleted(long userId) { - return facts.completedCandidates(userId).stream() + return facts.completedCandidates(userId, earlySince()).stream() .flatMap(f -> RewardRules.completed(f).stream()).mapToInt(e -> bookOnePerSet(userId, e)).sum(); } diff --git a/src/test/java/core_service/modules/rewards/api/RewardControllerTest.java b/src/test/java/core_service/modules/rewards/api/RewardControllerTest.java index b740094..2b18c35 100644 --- a/src/test/java/core_service/modules/rewards/api/RewardControllerTest.java +++ b/src/test/java/core_service/modules/rewards/api/RewardControllerTest.java @@ -481,6 +481,79 @@ class RewardControllerTest extends AbstractIntegrationTest { .andExpect(jsonPath("$.balance").value(70)); } + private void paidRow(String kind, long appid) { + jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, ?, ?, 15, now())", + USER, kind, String.valueOf(appid)); + } + + private void completedSetWithManualFinish() { + game(EDITION, "MANUAL", "COMPLETED", 0); + game(GAME, "STEAM", "BACKLOG", 120); + setStatus(GAME, "COMPLETED"); + allAchievements(); + jdbc.update("insert into game_link (appid, set_id) values (?, 4100), (?, 4100)", GAME, EDITION); + paidRow("FINISHED_MANUAL", EDITION); + } + + @Test + void gamePoints_SetManualFinishPaid_SteamEditionCompletedPaysOnce() throws Exception { + completedSetWithManualFinish(); + + gamePoints(GAME) + .andExpect(jsonPath("$.paid[?(@.kind == 'COMPLETED')].points").value(40)) + .andExpect(jsonPath("$.missing.COMPLETED").doesNotExist()); + gamePoints(GAME); + gamePoints(EDITION); + + assertThat(rows("COMPLETED")).isEqualTo(1); + assertThat(rows("FINISHED")).isZero(); + assertThat(rows("FINISHED_MANUAL")).isEqualTo(1); + } + + @Test + void gamePoints_SetManualFinishPaid_SteamFinishedBeforeEarlySince_NoCompleted() throws Exception { + completedSetWithManualFinish(); + jdbc.update("update user_game_status_change set changed_at = '2026-01-01T00:00:00Z' where user_id = ? and appid = ?", + USER, GAME); + + gamePoints(GAME).andExpect(jsonPath("$.missing.COMPLETED").value("NEEDS_PROVEN_FINISH")); + assertThat(rows("COMPLETED")).isZero(); + } + + @Test + void gamePoints_ManualEditionAddedCompletedForOldSteamFinish_NoCompleted() throws Exception { + game(GAME, "STEAM", "COMPLETED", 120); + jdbc.update("update user_game_status_change set changed_at = '2025-01-01T00:00:00Z' where user_id = ? and appid = ?", + USER, GAME); + allAchievements(); + game(EDITION, "MANUAL", "COMPLETED", 0); + jdbc.update("insert into game_link (appid, set_id) values (?, 4100), (?, 4100)", GAME, EDITION); + + gamePoints(GAME).andExpect(jsonPath("$.missing.COMPLETED").value("NEEDS_PROVEN_FINISH")); + assertThat(rows("COMPLETED")).isZero(); + } + + @Test + void gamePoints_SetManualAndSteamFinish_CompletedOnce() throws Exception { + completedSetWithManualFinish(); + paidRow("FINISHED", GAME); + + gamePoints(GAME); + gamePoints(EDITION); + + assertThat(rows("COMPLETED")).isEqualTo(1); + } + + @Test + void gamePoints_ManualGameCompletedWithManualFinish_NoCompleted() throws Exception { + game(GAME, "MANUAL", "COMPLETED", 0); + allAchievements(); + paidRow("FINISHED_MANUAL", GAME); + + gamePoints(GAME).andExpect(jsonPath("$.missing.COMPLETED").value("MANUAL_GAME")); + assertThat(rows("COMPLETED")).isZero(); + } + @Test void gamePoints_FinishedPlayingFinishedAfterEarly_PaysNoSecondEarly() throws Exception { game(GAME, "STEAM", "BACKLOG", 120); diff --git a/src/test/java/core_service/modules/rewards/repo/RewardFactsRepositoryTest.java b/src/test/java/core_service/modules/rewards/repo/RewardFactsRepositoryTest.java index 90e038d..6978e89 100644 --- a/src/test/java/core_service/modules/rewards/repo/RewardFactsRepositoryTest.java +++ b/src/test/java/core_service/modules/rewards/repo/RewardFactsRepositoryTest.java @@ -25,6 +25,7 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest { private static final long USER = 7L; private static final long GAME = 220L; private static final long EDITION = 221L; + private static final java.time.Instant EARLY = java.time.Instant.parse("2026-09-10T00:00:00Z"); private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin"); @Autowired @@ -105,8 +106,8 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest { ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(GAME), 30, Instant.now())); ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(EDITION), 30, Instant.now())); - assertThat(repository.completedCandidates(USER)).hasSize(2); - assertThat(repository.completedCandidates(USER, GAME)).extracting(FinishFacts::appid).containsExactly(GAME); + assertThat(repository.completedCandidates(USER, EARLY)).hasSize(2); + assertThat(repository.completedCandidates(USER, GAME, EARLY)).extracting(FinishFacts::appid).containsExactly(GAME); } @Test @@ -217,12 +218,12 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest { 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); + assertThat(repository.completedCandidates(USER, EARLY)).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(); + assertThat(repository.completedCandidates(USER, EARLY)).isEmpty(); } @Test @@ -275,15 +276,15 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest { void completedCandidates_NeedACompletedGameWithAPaidFinish() { game("BACKLOG", 120); setStatus("COMPLETED"); - assertThat(repository.completedCandidates(USER)).isEmpty(); + assertThat(repository.completedCandidates(USER, EARLY)).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() + assertThat(repository.completedCandidates(USER, EARLY)).singleElement() .extracting(FinishFacts::finishedAt).isEqualTo(finishedAt); ledger.add(USER, new RewardEntry(RewardKind.COMPLETED, String.valueOf(GAME), 40, finishedAt)); - assertThat(repository.completedCandidates(USER)).isEmpty(); + assertThat(repository.completedCandidates(USER, EARLY)).isEmpty(); } // The game and a manual edition of it, one set, both with the given status. @@ -329,11 +330,11 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest { setOfTwo("COMPLETED"); paid(RewardKind.FINISHED, GAME); paid(RewardKind.FINISHED, EDITION); - assertThat(repository.completedCandidates(USER)).hasSize(2); + assertThat(repository.completedCandidates(USER, EARLY)).hasSize(2); paid(RewardKind.COMPLETED, EDITION); - assertThat(repository.completedCandidates(USER)).isEmpty(); + assertThat(repository.completedCandidates(USER, EARLY)).isEmpty(); } @Test @@ -411,7 +412,7 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest { assertThat(repository.startCandidates(USER, BERLIN)).isEmpty(); assertThat(repository.finishCandidates(USER, BERLIN)).isEmpty(); - assertThat(repository.completedCandidates(USER)).isEmpty(); + assertThat(repository.completedCandidates(USER, EARLY)).isEmpty(); assertThat(repository.minutesPerMonth(USER)).isEmpty(); } @@ -483,7 +484,56 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest { jdbc.update("insert into user_game_achievement_meta (user_id, appid, achievements_unlocked) values (?, ?, 10)", USER, GAME); paid(RewardKind.FINISHED, other); - assertThat(repository.completedCandidates(USER)).extracting(FinishFacts::appid).contains(GAME); + assertThat(repository.completedCandidates(USER, EARLY)).extracting(FinishFacts::appid).contains(GAME); + } + + private void allAchievements() { + jdbc.update("insert into game_achievement_schema_meta (appid, achievements_total, has_schema) values (?, 10, true) " + + "on conflict do nothing", GAME); + jdbc.update("insert into user_game_achievement_meta (user_id, appid, achievements_unlocked) values (?, ?, 10)", USER, GAME); + } + + @Test + void completedCandidates_ManualFinishPaidOnAnotherEditionOfTheSet() { + setOfTwo("COMPLETED"); + jdbc.update("update user_game set status = 'COMPLETED' where user_id = ? and appid = ?", USER, EDITION); + allAchievements(); + paid(RewardKind.FINISHED_MANUAL, EDITION); + + assertThat(repository.completedCandidates(USER, EARLY)).filteredOn(f -> f.appid() == GAME).singleElement() + .satisfies(f -> assertThat(f.manual()).isFalse()); + assertThat(repository.completedCandidates(USER, EARLY)).filteredOn(f -> f.appid() == EDITION) + .allSatisfy(f -> assertThat(f.manual()).isTrue()); + } + + @Test + void completedCandidates_ManualFinishOpensNothingWhenTheSteamGameFinishedBeforeEarlySince() { + setOfTwo("COMPLETED"); + allAchievements(); + paid(RewardKind.FINISHED_MANUAL, EDITION); + changedAt("COMPLETED", "2026-01-01T00:00:00Z"); + + assertThat(repository.completedCandidates(USER, GAME, EARLY)).isEmpty(); + } + + @Test + void completedCandidates_ManualFinishOpensNothingWhenTheSteamGameHasNoStatusChange() { + setOfTwo("COMPLETED"); + allAchievements(); + paid(RewardKind.FINISHED_MANUAL, EDITION); + jdbc.update("delete from user_game_status_change where user_id = ? and appid = ?", USER, GAME); + + assertThat(repository.completedCandidates(USER, GAME, EARLY)).isEmpty(); + } + + @Test + void completedCandidates_ManualFinishOnOwnSteamKey() { + game("BACKLOG", 120); + setStatus("COMPLETED"); + allAchievements(); + paid(RewardKind.FINISHED_MANUAL, GAME); + + assertThat(repository.completedCandidates(USER, EARLY)).extracting(FinishFacts::appid).containsExactly(GAME); } private void linkedTitle() { @@ -554,11 +604,11 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest { game("BACKLOG", 120); setStatus("COMPLETED"); paid(RewardKind.FINISHED_EARLY, GAME); - assertThat(repository.completedCandidates(USER)).isEmpty(); + assertThat(repository.completedCandidates(USER, EARLY)).isEmpty(); paid(RewardKind.FINISHED_TOPUP, GAME); - assertThat(repository.completedCandidates(USER)).extracting(FinishFacts::appid).containsExactly(GAME); + assertThat(repository.completedCandidates(USER, EARLY)).extracting(FinishFacts::appid).containsExactly(GAME); } @Test