Public Access
fix(rewards): check last achievement unlock for manual COMPLETED
Refs #44. Check the last achievement unlock, not the first status change. Old 100% games no longer pass. New reason COMPLETION_TOO_OLD. Add earlySince to the games and wallet GET responses.
This commit is contained in:
1 parent
1a9a4e912c
commit
59d6b3394d
11 files changed
+188
-44
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user