Public Access
fix(rewards): let manual finish open COMPLETED for Steam edition
A paid FINISHED_MANUAL on a set edition blocked COMPLETED for the Steam game forever. It now opens COMPLETED, but only when the Steam game's first finish is on or after app.rewards.early-since. Refs #35
This commit is contained in:
1 parent
6215bb0dec
commit
1a9a4e912c
8 files changed
+174
-33
No files matched your search
@@ -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
|
||||
|
||||
@@ -30,6 +30,11 @@ public enum RewardKind {
|
||||
*/
|
||||
public static final List<RewardKind> 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<RewardKind> 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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<FinishFacts> completedCandidates(long userId) {
|
||||
return completedCandidates(userId, null);
|
||||
public List<FinishFacts> 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<FinishFacts> completedCandidates(long userId, Long appid) {
|
||||
public List<FinishFacts> 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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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<RewardKind> 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user