fix(rewards): check last achievement unlock for manual COMPLETED

Refs #44. Check the last achievement unlock, not the first status change.
Old 100% games no longer pass. New reason COMPLETION_TOO_OLD.
Add earlySince to the games and wallet GET responses.
This commit is contained in:
lobo committed 2026-10-11 13:51:34 +02:00
1 parent 1a9a4e912c
commit 59d6b3394d
11 files changed
+188 -44

No files matched your search

+4 -4
View File
@@ -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 |
@@ -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<PaidDto> paid, Map<RewardKind, NoPointsReason> missing) {
public record GamePointsDto(long balance, List<PaidDto> paid, Map<RewardKind, NoPointsReason> missing,
LocalDate earlySince) {
/**
* @param appid the edition the row was paid for
@@ -27,6 +30,7 @@ public record GamePointsDto(long balance, List<PaidDto> paid, Map<RewardKind, No
}
public static GamePointsDto of(RewardService.GamePoints points) {
return new GamePointsDto(points.balance(), points.paid().stream().map(PaidDto::of).toList(), points.missing());
return new GamePointsDto(points.balance(), points.paid().stream().map(PaidDto::of).toList(), points.missing(),
points.earlySince());
}
}
@@ -7,6 +7,7 @@ import core_service.modules.rewards.domain.Track;
import core_service.modules.rewards.service.RewardService;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
@@ -16,9 +17,10 @@ import java.util.Set;
* @param owned the item codes the user may wear
* @param equipped the worn item code per slot
* @param progress the value of each unlock track
* @param earlySince app.rewards.early-since, for the rules text
*/
public record WalletDto(long balance, List<ItemDto> catalog, Set<String> owned, Map<LookSlot, String> equipped,
List<EntryDto> history, Map<Track, Integer> progress) {
List<EntryDto> history, Map<Track, Integer> progress, LocalDate earlySince) {
/**
* @param track null when the item is buyable
@@ -38,6 +40,6 @@ public record WalletDto(long balance, List<ItemDto> catalog, Set<String> owned,
wallet.owned(), wallet.equipped(),
wallet.history().stream()
.map(h -> new EntryDto(h.kind(), h.ref(), h.gameName(), h.points(), h.eventAt())).toList(),
wallet.progress());
wallet.progress(), wallet.earlySince());
}
}
@@ -8,6 +8,8 @@ package core_service.modules.rewards.domain;
* @param startLogged a BACKLOG to PLAYING change is in the status log
* @param 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) {}
@@ -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
}
@@ -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;
}
@@ -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<FinishFacts> 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<FinishFacts> 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();
}
@@ -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<String> owned, Map<LookSlot, String> equipped, List<HistoryEntry> history,
Map<Track, Integer> progress) {}
Map<Track, Integer> progress, LocalDate earlySince) {}
// ponytail: settles on every wallet read (the app reads it once per start); a settled_at column if that hurts.
@Transactional
@@ -84,7 +85,7 @@ public class RewardService {
Map<Track, Integer> progress = settleLocked(userId);
Set<String> 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<PaidRow> paid, Map<RewardKind, NoPointsReason> missing) {}
public record GamePoints(long balance, List<PaidRow> paid, Map<RewardKind, NoPointsReason> missing,
LocalDate earlySince) {}
/**
* Settles first, like the wallet; {@link PaidRow#bookedNow()} relies on settle and read sharing this one
@@ -265,7 +267,8 @@ public class RewardService {
}
settleLocked(userId);
List<PaidRow> paid = facts.paidRows(userId, appid);
return Optional.of(new GamePoints(ledger.balance(userId), paid, missing(userId, appid, found.get(), paid)));
return Optional.of(new GamePoints(ledger.balance(userId), paid, missing(userId, appid, found.get(), paid),
earlySince));
}
private Map<RewardKind, NoPointsReason> missing(long userId, long appid, GameRewardFacts game, List<PaidRow> paid) {
@@ -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;
}
@@ -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);
@@ -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);
}
@@ -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);
}