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:
lobo committed 2026-10-11 13:07:26 +02:00
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