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