feat(library): group same game across platforms into sets

One entry per game across platforms (Gitea #34, slice 1 of 3).
Same IGDB game is linked; a user can split one key off its set.
V42 keeps status, rating and notes per set; rewards pay once per set.
This commit is contained in:
lobo committed 2026-10-09 10:57:23 +02:00
1 parent 8e4c382563
commit 82d3b59165
21 files changed
+1134 -64

No files matched your search

@@ -19,10 +19,10 @@ import java.util.Map;
@RequiredArgsConstructor
public class AccountCleanupRepository {
// Children before user_game (user_game_status_change would also go by its cascade), the tombstones last.
// Children before user_game (several would also go by its cascade), the tombstones last.
static final List<String> TABLES = List.of("user_game_question", "user_game_status_change", "user_game_achievement",
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot", "backlog_order",
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_psn_trophy", "user_psn_trophy_set", "user_game", "game_link_request", "account_name", "game_import", "user_psn_title", "user_psn_account", "reward_ledger", "reward_equipped",
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_psn_trophy", "user_psn_trophy_set", "user_game_split", "user_game_set_backup", "user_game", "game_link_request", "account_name", "game_import", "user_psn_title", "user_psn_account", "reward_ledger", "reward_equipped",
"account_role_change", "blocked_user", "listed_user", "follow", "follow_digest", "inbox_note", "public_user", "deleted_user");
// Every user with data has a row in one of these; deleteRows then removes the child rows too.
@@ -299,6 +299,17 @@ public class LibraryController {
: ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("NOT_LINKED", "The game is not linked"));
}
/**
* The user says the game is not the same as its other editions: it stands alone for this user from now on.
*/
@DeleteMapping("/games/{appid}/set")
public ResponseEntity<?> splitGame(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("appid") Long appid) {
return gameLinkService.split(principal.getIdAsLong(), appid) ? ResponseEntity.noContent().build()
: ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("NOT_LINKED",
"The game has no other edition in your library"));
}
@GetMapping("/link-queue")
public ResponseEntity<PageDto<LinkPairDto>> getLinkQueue(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
@@ -3,6 +3,7 @@ package core_service.modules.library.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
@@ -10,14 +11,20 @@ import lombok.NoArgsConstructor;
import org.hibernate.annotations.Immutable;
/**
* A key in a linked set: read-only, see V28. Writes go through {@code GameLinkRepository}.
* A key in a user's set (game_link minus the keys the user split off): read-only view, see V42. Writes go through
* {@code GameLinkRepository}.
*/
@Entity
@Immutable
@Table(name = "game_link")
@Table(name = "user_game_set")
@IdClass(UserGameId.class)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class GameLinkMember {
public class UserGameSetMember {
@Id
@Column(name = "user_id")
private Long userId;
@Id
@Column(name = "appid")
@@ -19,8 +19,8 @@ import java.util.Map;
import java.util.Optional;
/**
* The global game sets and the auditor queue (V28). Plain SQL: sets span keys and users. Every pair is stored as
* (least, greatest).
* The global game sets and the auditor queue (V28), and the keys a user split off a set (V42). Plain SQL: sets span
* keys and users. Every pair is stored as (least, greatest).
*/
@Repository
@RequiredArgsConstructor
@@ -55,6 +55,11 @@ public class GameLinkRepository {
(rs, row) -> rs.getLong("set_id")).stream().findFirst();
}
public boolean inOneSet(long appid, long otherAppid) {
Optional<Long> set = setOf(appid);
return set.isPresent() && set.equals(setOf(otherAppid));
}
/**
* Puts both keys in one set, joining two sets when each key has one, and marks the pair LINKED.
*
@@ -128,6 +133,69 @@ public class GameLinkRepository {
""", Map.of());
}
/**
* The pairs of keys that share an IGDB "same game" key, are not in one set and were not dismissed (by an auditor
* or an unlink), as (least, greatest).
*
* @param appids at least one key of a pair is in it; null for every pair
*/
public List<long[]> sameGamePairs(Collection<Long> appids) {
if (appids != null && appids.isEmpty()) {
return List.of();
}
return jdbc.query("""
select e1.appid as a, e2.appid as b
from game_edition e1
join game_edition e2 on e2.same_game_id = e1.same_game_id and e1.appid < e2.appid
where (cast(:all as boolean) or e1.appid in (:appids) or e2.appid in (:appids))
and not exists (select 1 from game_link l1 join game_link l2 on l2.set_id = l1.set_id
where l1.appid = e1.appid and l2.appid = e2.appid)
and not exists (select 1 from game_link_pair p
where p.appid_a = e1.appid and p.appid_b = e2.appid and p.state = 'DISMISSED')
order by e1.appid, e2.appid
""", new MapSqlParameterSource("all", appids == null)
.addValue("appids", appids == null ? List.of(0L) : appids),
(rs, row) -> new long[]{rs.getLong("a"), rs.getLong("b")});
}
/**
* True when a dismissed pair has one key in the set of {@code appid} and the other in the set of
* {@code otherAppid}; the keys are not in a set of their own when they have no link row.
*/
public boolean dismissedBetweenSets(long appid, long otherAppid) {
return Boolean.TRUE.equals(jdbc.queryForObject("""
select exists (
select 1 from game_link_pair p
where p.state = 'DISMISSED'
and (p.appid_a in (select appid from game_link where set_id in (select set_id from game_link where appid = :a)
union select :a)
and p.appid_b in (select appid from game_link where set_id in (select set_id from game_link where appid = :b)
union select :b)
or p.appid_b in (select appid from game_link where set_id in (select set_id from game_link where appid = :a)
union select :a)
and p.appid_a in (select appid from game_link where set_id in (select set_id from game_link where appid = :b)
union select :b)))
""", new MapSqlParameterSource("a", appid).addValue("b", otherAppid), Boolean.class));
}
/**
* Makes the key stand alone for the user, only while the user's set has another listed game; no link or sync
* puts it back.
*
* @return false when there is no other listed edition, or the key is split already
*/
public boolean split(long userId, long appid) {
return jdbc.update("""
insert into user_game_split (user_id, appid)
select :user, :appid
where exists (select 1 from user_game_set me
join user_game_set o on o.user_id = me.user_id and o.set_id = me.set_id and o.appid <> me.appid
join listed_user_game og on og.user_id = o.user_id and og.appid = o.appid
where me.user_id = :user and me.appid = :appid)
on conflict do nothing
""", new MapSqlParameterSource("user", userId).addValue("appid", appid)) == 1;
}
/**
* Holds until the end of the transaction, so one user's link requests run one after the other.
*/
@@ -198,16 +266,16 @@ public class GameLinkRepository {
}
/**
* The other listed games of the user in the set of {@code appid}.
* The other listed games in the user's set of {@code appid} (see V42).
*/
public List<Edition> linkedEditions(long userId, long appid) {
return jdbc.query("""
select ug.appid, g.name, ug.platform, ug.source, ug.status, g.image_url
from game_link me
join game_link o on o.set_id = me.set_id and o.appid <> me.appid
join listed_user_game ug on ug.user_id = :user and ug.appid = o.appid
from user_game_set me
join user_game_set o on o.user_id = me.user_id and o.set_id = me.set_id and o.appid <> me.appid
join listed_user_game ug on ug.user_id = o.user_id and ug.appid = o.appid
join steam_game g on g.appid = ug.appid
where me.appid = :appid
where me.user_id = :user and me.appid = :appid
order by g.name, ug.appid
""", new MapSqlParameterSource("user", userId).addValue("appid", appid),
(rs, row) -> new Edition(rs.getLong("appid"), rs.getString("name"), rs.getString("platform"),
@@ -226,10 +294,10 @@ public class GameLinkRepository {
}
jdbc.query("""
select l.appid, count(*) as owned
from game_link l
join game_link o on o.set_id = l.set_id
join listed_user_game ug on ug.user_id = :user and ug.appid = o.appid
where l.appid in (:appids)
from user_game_set l
join user_game_set o on o.user_id = l.user_id and o.set_id = l.set_id
join listed_user_game ug on ug.user_id = o.user_id and ug.appid = o.appid
where l.user_id = :user and l.appid in (:appids)
group by l.appid
having count(*) >= 2
""", new MapSqlParameterSource("user", userId).addValue("appids", appids),
@@ -38,8 +38,8 @@ public class QuestionRepository {
select ug.appid as counted, ug.appid as member from counted_user_game ug where ug.user_id = :userId
union
select ug.appid, o.appid from counted_user_game ug
join game_link l on l.appid = ug.appid
join game_link o on o.set_id = l.set_id and o.appid <> l.appid
join user_game_set l on l.user_id = ug.user_id and l.appid = ug.appid
join user_game_set o on o.user_id = l.user_id and o.set_id = l.set_id and o.appid <> l.appid
join listed_user_game og on og.user_id = ug.user_id and og.appid = o.appid
where ug.user_id = :userId
)
@@ -321,7 +321,7 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
* @param namePattern a {@link #containsPattern} result, null for every name
* @param maxMinutes only games whose normal time to beat is known and at most this; null for every game
* @param hideDuplicates only the games a linked set counts (see V28), not its lesser editions
* @param sources the sources a row or one of its listed linked editions must have; never empty
* @param sources the sources a row or one of its listed editions in the user's set (see V42) must have; never empty
*/
@Query("select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " +
"where ug.userId = :userId and ug.removedAt is null " +
@@ -332,10 +332,10 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
" where t.appid = ug.appid and t.normallyMinutes <= :maxMinutes)) " +
"and (:hideDuplicates = false or exists (select 1 from CountedUserGame c " +
" where c.userId = ug.userId and c.appid = ug.appid)) " +
"and (ug.source in :sources or exists (select 1 from GameLinkMember l " +
" join GameLinkMember o on o.setId = l.setId and o.appid <> l.appid " +
"and (ug.source in :sources or exists (select 1 from UserGameSetMember l " +
" join UserGameSetMember o on o.userId = l.userId and o.setId = l.setId and o.appid <> l.appid " +
" join UserGame og on og.userId = ug.userId and og.appid = o.appid " +
" where l.appid = ug.appid and og.removedAt is null and og.hiddenAt is null and og.source in :sources))")
" where l.userId = ug.userId and l.appid = ug.appid and og.removedAt is null and og.hiddenAt is null and og.source in :sources))")
Page<UserGame> findOwnedGames(@Param("userId") Long userId,
@Param("hidden") boolean hidden,
@Param("status") GameStatus status,
@@ -2,11 +2,15 @@ package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.repo.GameEditionRepository;
import lombok.RequiredArgsConstructor;
import core_service.modules.library.repo.GameLinkRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.core.task.TaskExecutor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.Instant;
import java.util.Collection;
@@ -15,20 +19,32 @@ import java.util.Set;
/**
* Stores IGDB's "same game" key per library key, the same way {@link MoodService} stores moods: only for games
* without a fresh row, and nothing when IGDB fails.
* without a fresh row, and nothing when IGDB fails. Keys with the same "same game" key are linked into one set at once
* (no auditor, so no audit row); a pair an auditor or an unlink dismissed is never linked again.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class GameEditionService {
private final IgdbApiService igdbApiService;
private final GameEditionRepository gameEditionRepository;
private final GameLinkRepository gameLinkRepository;
private final TaskExecutor applicationTaskExecutor;
private final TransactionTemplate transaction;
@Value("${app.library.edition-ttl-days:30}")
private long ttlDays;
public GameEditionService(IgdbApiService igdbApiService, GameEditionRepository gameEditionRepository,
GameLinkRepository gameLinkRepository, TaskExecutor applicationTaskExecutor,
PlatformTransactionManager transactionManager) {
this.igdbApiService = igdbApiService;
this.gameEditionRepository = gameEditionRepository;
this.gameLinkRepository = gameLinkRepository;
this.applicationTaskExecutor = applicationTaskExecutor;
this.transaction = new TransactionTemplate(transactionManager);
}
public void refresh(Collection<Long> appids) {
if (!igdbApiService.isEnabled() || appids.isEmpty()) return;
Set<Long> stale = stale(appids);
@@ -46,6 +62,50 @@ public class GameEditionService {
gameEditionRepository.upsert(appid, edition == null ? null : edition.igdbId(),
edition == null ? null : edition.sameGameId(), now);
}
linkSameGames(answer.answered());
}
/**
* Links every pair of keys that share a "same game" key, one transaction per pair.
*
* @param appids at least one key of a pair is in it; null for every pair
* @return the number of pairs linked
*/
public int linkSameGames(Collection<Long> appids) {
int linked = 0;
for (long[] pair : gameLinkRepository.sameGamePairs(appids)) {
if (Boolean.TRUE.equals(transaction.execute(tx -> linkPair(pair[0], pair[1])))) {
linked++;
}
}
if (linked > 0) {
log.info("Linked {} pairs of the same game from IGDB", linked);
}
return linked;
}
private boolean linkPair(long appid, long otherAppid) {
if (gameLinkRepository.lockGames(appid, otherAppid).size() < 2
|| gameLinkRepository.inOneSet(appid, otherAppid)
|| gameLinkRepository.dismissedBetweenSets(appid, otherAppid)) {
return false;
}
gameLinkRepository.link(appid, otherAppid);
return true;
}
/**
* Groups the games already in the database; the editions refresh only sees new ones.
*/
@EventListener(ApplicationReadyEvent.class)
public void linkExisting() {
applicationTaskExecutor.execute(() -> {
try {
linkSameGames(null);
} catch (RuntimeException e) {
log.warn("Existing games not grouped: {}", e.getMessage());
}
});
}
/**
@@ -16,12 +16,12 @@ import org.springframework.transaction.support.TransactionTemplate;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* An auditor links keys of one game for every user, from IGDB's suggestions and the users' requests; a user can
* ask for a link. Every change is written to the audit log.
* ask for a link, and split a key off a set for himself. Every auditor change is written to the audit log. Links
* also come from IGDB without an auditor, see {@link GameEditionService#linkSameGames}.
*/
@Service
@RequiresRole(Role.AUDITOR)
@@ -56,7 +56,7 @@ public class GameLinkService {
if (names.size() < 2) {
return LinkResult.NOT_FOUND;
}
if (inOneSet(appid, otherAppid)) {
if (linkRepository.inOneSet(appid, otherAppid)) {
return LinkResult.ALREADY_LINKED;
}
linkRepository.link(appid, otherAppid);
@@ -123,7 +123,7 @@ public class GameLinkService {
return RequestResult.NOT_FOUND;
}
String state = linkRepository.pairState(appid, otherAppid);
if ("LINKED".equals(state) || inOneSet(appid, otherAppid)) {
if ("LINKED".equals(state) || linkRepository.inOneSet(appid, otherAppid)) {
return RequestResult.ALREADY_LINKED;
}
if ("DISMISSED".equals(state)) {
@@ -137,8 +137,11 @@ public class GameLinkService {
});
}
private boolean inOneSet(long appid, long otherAppid) {
Optional<Long> set = linkRepository.setOf(appid);
return set.isPresent() && set.equals(linkRepository.setOf(otherAppid));
/**
* @return false when the game has no other listed edition in the user's set
*/
@RequiresRole(Role.USER)
public boolean split(long userId, long appid) {
return Boolean.TRUE.equals(transaction.execute(tx -> linkRepository.split(userId, appid)));
}
}
@@ -1,6 +1,7 @@
package core_service.modules.rewards.repo;
import core_service.modules.rewards.domain.FinishFacts;
import core_service.modules.rewards.domain.RewardKind;
import core_service.modules.rewards.domain.RewardRules;
import core_service.modules.rewards.domain.StartFacts;
import lombok.RequiredArgsConstructor;
@@ -17,6 +18,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* What the library tables say about a user's games, as raw facts for {@link RewardRules}. Plain SQL, because the
@@ -32,6 +34,32 @@ public class RewardFactsRepository {
private final NamedParameterJdbcTemplate jdbc;
/**
* A set pays once: true when the game or another edition in the user's set has a ledger row of the kinds.
*/
private static String paidInSet(String alias, String kinds) {
return """
exists (select 1 from reward_ledger l where l.user_id = %1$s.user_id and l.kind in (%2$s)
and (l.ref = %1$s.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)))
""".formatted(alias, kinds);
}
/**
* True when the game or another edition in the user's set has a ledger row of the kinds: the rows booked earlier
* in a settle run count.
*/
public boolean paidInSet(long userId, long appid, List<RewardKind> kinds) {
String quoted = kinds.stream().map(k -> "'" + k.name() + "'").collect(Collectors.joining(", "));
return Boolean.TRUE.equals(jdbc.queryForObject("""
select %s
from (select cast(:userId as bigint) as user_id, cast(:appid as bigint) as appid) x
left join user_game_set st on st.user_id = x.user_id and st.appid = x.appid
""".formatted(paidInSet("x", quoted)),
new MapSqlParameterSource("userId", userId).addValue("appid", appid), Boolean.class));
}
public boolean isActive(long userId) {
return Boolean.TRUE.equals(jdbc.queryForObject("""
select not exists (select 1 from deleted_user where user_id = :userId)
@@ -45,7 +73,8 @@ public class RewardFactsRepository {
}
/**
* The first BACKLOG to PLAYING change of each game not paid yet.
* The first BACKLOG to PLAYING change of each game not paid yet. Every edition of a
* linked set is a candidate; the service books one per set.
*/
public List<StartFacts> startCandidates(long userId, ZoneId zone) {
return jdbc.query("""
@@ -59,10 +88,11 @@ public class RewardFactsRepository {
and ug.hidden_at is null and ug.source <> 'PSN'
and not exists (select 1 from user_psn_title t where t.user_id = ug.user_id and t.appid = ug.appid
and t.state = 'LINKED')
where not exists (select 1 from reward_ledger l where l.user_id = c.user_id and l.kind = 'STARTED'
and l.ref = c.appid::text)
order by c.started_at
""", new MapSqlParameterSource("userId", userId).addValue("zone", zone.getId()),
left join user_game_set st on st.user_id = c.user_id and st.appid = c.appid
where not %1$s
order by c.started_at, c.appid
""".formatted(paidInSet("c", "'STARTED'")),
new MapSqlParameterSource("userId", userId).addValue("zone", zone.getId()),
(rs, i) -> new StartFacts(rs.getLong("appid"), rs.getBoolean("manual"),
rs.getTimestamp("started_at").toInstant(), rs.getBoolean("played_after")));
}
@@ -71,7 +101,8 @@ public class RewardFactsRepository {
* Games that are FINISHED or COMPLETED now, with their first change to one of the two, and no FINISHED or
* FINISHED_MANUAL row yet. Oldest first. Only fresh play proves a finish: the ending achievement must be unlocked
* no earlier than {@link RewardRules#PLAYED_AROUND_DAYS} before that change, and the playtime share counts only
* the playtime days since it. Hidden games pay nothing.
* the playtime days since it. Hidden games pay nothing. Every
* edition of a linked set is a candidate; the service books one per set.
*/
public List<FinishFacts> finishCandidates(long userId, ZoneId zone) {
return jdbc.query("""
@@ -99,10 +130,11 @@ public class RewardFactsRepository {
left join game_time_to_beat t on t.appid = c.appid
left join user_game_achievement_meta m on m.user_id = c.user_id and m.appid = c.appid
left join game_achievement_schema_meta s on s.appid = c.appid
where not exists (select 1 from reward_ledger l where l.user_id = c.user_id
and l.kind in ('FINISHED', 'FINISHED_MANUAL') and l.ref = c.appid::text)
order by c.finished_at
""", new MapSqlParameterSource("userId", userId).addValue("zone", zone.getId())
left join user_game_set st on st.user_id = c.user_id and st.appid = c.appid
where not %1$s
order by c.finished_at, c.appid
""".formatted(paidInSet("c", "'FINISHED', 'FINISHED_MANUAL'")),
new MapSqlParameterSource("userId", userId).addValue("zone", zone.getId())
.addValue("aroundDays", RewardRules.PLAYED_AROUND_DAYS),
(rs, i) -> new FinishFacts(rs.getLong("appid"), rs.getBoolean("manual"), rs.getBoolean("completed"),
rs.getInt("playtime_minutes"), rs.getInt("played_since"),
@@ -112,7 +144,8 @@ public class RewardFactsRepository {
}
/**
* COMPLETED Steam games with a paid FINISHED row and no COMPLETED row yet; finishedAt is the FINISHED event.
* 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.
*/
public List<FinishFacts> completedCandidates(long userId) {
return jdbc.query("""
@@ -120,16 +153,22 @@ public class RewardFactsRepository {
coalesce(s.achievements_total > 0 and m.achievements_unlocked >= s.achievements_total, false)
as all_achievements
from user_game ug
join reward_ledger f on f.user_id = ug.user_id and f.kind = 'FINISHED' and f.ref = ug.appid::text
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 = 'FINISHED'
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
on f.event_at is not null
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.status = 'COMPLETED' and ug.removed_at is null and ug.hidden_at is null
and ug.source <> 'PSN'
and not exists (select 1 from user_psn_title t where t.user_id = ug.user_id and t.appid = ug.appid
and t.state = 'LINKED')
and not exists (select 1 from reward_ledger l where l.user_id = ug.user_id and l.kind = 'COMPLETED'
and l.ref = ug.appid::text)
""", new MapSqlParameterSource("userId", userId),
and not %1$s
order by f.event_at, ug.appid
""".formatted(paidInSet("ug", "'COMPLETED'")), new MapSqlParameterSource("userId", userId),
(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()));
}
@@ -247,9 +247,16 @@ public class RewardService {
return ledger.add(userId, entry) ? 1 : 0;
}
// A linked set pays once: every edition is a candidate, the first that passed the rules books, the others see its row.
private int bookOnePerSet(long userId, RewardEntry entry) {
List<RewardKind> kinds = entry.kind() == RewardKind.FINISHED || entry.kind() == RewardKind.FINISHED_MANUAL
? List.of(RewardKind.FINISHED, RewardKind.FINISHED_MANUAL) : List.of(entry.kind());
return facts.paidInSet(userId, Long.parseLong(entry.ref()), kinds) ? 0 : book(userId, entry);
}
private int settleStarts(long userId) {
return facts.startCandidates(userId, zone).stream()
.flatMap(f -> RewardRules.start(f).stream()).mapToInt(e -> book(userId, e)).sum();
.flatMap(f -> RewardRules.start(f).stream()).mapToInt(e -> bookOnePerSet(userId, e)).sum();
}
private int settleFinishes(long userId) {
@@ -268,14 +275,14 @@ public class RewardService {
continue;
}
}
paid += book(userId, entry);
paid += bookOnePerSet(userId, entry);
}
return paid;
}
private int settleCompleted(long userId) {
return facts.completedCandidates(userId).stream()
.flatMap(f -> RewardRules.completed(f).stream()).mapToInt(e -> book(userId, e)).sum();
.flatMap(f -> RewardRules.completed(f).stream()).mapToInt(e -> bookOnePerSet(userId, e)).sum();
}
// Only full months: the running month is not over.
@@ -0,0 +1,179 @@
-- One entry per game across platforms (Gitea #34, slice 1). The sets of game_link (V28) now also come from IGDB
-- without an auditor. A user can split one key off the set for himself; no sync or link touches it afterwards.
-- Status, rating, notes and priority are one value per set, kept by the triggers below for every code path (like V14).
CREATE TABLE user_game_split (
user_id BIGINT NOT NULL,
appid BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, appid),
FOREIGN KEY (user_id, appid) REFERENCES user_game (user_id, appid) ON DELETE CASCADE ON UPDATE CASCADE
);
-- The values a row had before a join changed them (first copy wins). Holds notes, so it goes with the account.
CREATE TABLE user_game_set_backup (
user_id BIGINT NOT NULL,
appid BIGINT NOT NULL,
status VARCHAR(16) NOT NULL,
status_set_by_user BOOLEAN NOT NULL,
rating INTEGER,
notes VARCHAR(2000),
priority VARCHAR(8),
saved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, appid),
FOREIGN KEY (user_id, appid) REFERENCES user_game (user_id, appid) ON DELETE CASCADE ON UPDATE CASCADE
);
-- The game_link triggers look the rows of a key up by appid.
CREATE INDEX user_game_appid_idx ON user_game (appid);
-- A user's sets: the user's rows in game_link, minus the keys the user split off.
CREATE VIEW user_game_set AS
SELECT ug.user_id, ug.appid, l.set_id
FROM user_game ug
JOIN game_link l ON l.appid = ug.appid
WHERE NOT EXISTS (SELECT 1 FROM user_game_split s WHERE s.user_id = ug.user_id AND s.appid = ug.appid);
-- Same rule as V28 (same column rule too), but a set is the user's set.
DROP VIEW counted_user_game;
CREATE VIEW counted_user_game AS
SELECT ug.* FROM listed_user_game ug
WHERE NOT EXISTS (
SELECT 1 FROM user_game_set me
JOIN user_game_set o ON o.user_id = me.user_id AND o.set_id = me.set_id AND o.appid <> me.appid
JOIN listed_user_game og ON og.user_id = o.user_id AND og.appid = o.appid
WHERE me.user_id = ug.user_id AND me.appid = ug.appid
AND (CASE og.status WHEN 'COMPLETED' THEN 4 WHEN 'FINISHED' THEN 3 WHEN 'PLAYING' THEN 2 WHEN 'DROPPED' THEN 1 ELSE 0 END,
og.playtime_minutes, -extract(epoch from og.first_seen_at), -og.appid)
> (CASE ug.status WHEN 'COMPLETED' THEN 4 WHEN 'FINISHED' THEN 3 WHEN 'PLAYING' THEN 2 WHEN 'DROPPED' THEN 1 ELSE 0 END,
ug.playtime_minutes, -extract(epoch from ug.first_seen_at), -ug.appid));
-- The join rule: the winner is the counted rule (listed rows first, then the rank of counted_user_game, keep both
-- equal). Status from the winner, status_set_by_user true if any row has it, rating and priority from the winner or
-- else the first row that has one, notes: the distinct non-blank notes in winner order, cut to 2000. A row that
-- changes is copied to user_game_set_backup first. The status log of the change is no hand change (see below).
CREATE FUNCTION align_user_game_set(p_user BIGINT, p_set BIGINT) RETURNS void AS $$
DECLARE
v_count INTEGER;
v_status VARCHAR(16);
v_by_user BOOLEAN;
v_rating INTEGER;
v_notes VARCHAR(2000);
v_priority VARCHAR(8);
v_log BIGINT;
BEGIN
WITH members AS (
SELECT ug.status, ug.status_set_by_user, ug.rating, ug.notes, ug.priority,
row_number() OVER (ORDER BY (ug.removed_at IS NULL AND ug.hidden_at IS NULL) DESC,
CASE ug.status WHEN 'COMPLETED' THEN 4 WHEN 'FINISHED' THEN 3 WHEN 'PLAYING' THEN 2 WHEN 'DROPPED' THEN 1 ELSE 0 END DESC,
ug.playtime_minutes DESC, ug.first_seen_at, ug.appid) AS pos
FROM user_game ug
JOIN user_game_set s ON s.user_id = ug.user_id AND s.appid = ug.appid
WHERE s.user_id = p_user AND s.set_id = p_set
)
SELECT count(*),
(array_agg(status ORDER BY pos))[1],
bool_or(status_set_by_user),
(array_agg(rating ORDER BY pos) FILTER (WHERE rating IS NOT NULL))[1],
(array_agg(priority ORDER BY pos) FILTER (WHERE priority IS NOT NULL))[1],
coalesce((SELECT left(string_agg(d.notes, E'\n\n' ORDER BY d.first_pos), 2000)
FROM (SELECT m.notes, min(m.pos) AS first_pos FROM members m
WHERE btrim(m.notes) <> '' GROUP BY m.notes) d),
(array_agg(notes ORDER BY pos))[1])
INTO v_count, v_status, v_by_user, v_rating, v_priority, v_notes
FROM members;
IF v_count < 2 THEN
RETURN;
END IF;
INSERT INTO user_game_set_backup (user_id, appid, status, status_set_by_user, rating, notes, priority)
SELECT ug.user_id, ug.appid, ug.status, ug.status_set_by_user, ug.rating, ug.notes, ug.priority
FROM user_game ug
JOIN user_game_set s ON s.user_id = ug.user_id AND s.appid = ug.appid
WHERE s.user_id = p_user AND s.set_id = p_set
AND (ug.status, ug.status_set_by_user, ug.rating, ug.notes, ug.priority)
IS DISTINCT FROM (v_status, v_by_user, v_rating, v_notes, v_priority)
ON CONFLICT DO NOTHING;
SELECT coalesce(max(id), 0) INTO v_log FROM user_game_status_change;
UPDATE user_game ug
SET status = v_status, status_set_by_user = v_by_user, rating = v_rating, notes = v_notes, priority = v_priority
FROM user_game_set s
WHERE s.user_id = p_user AND s.set_id = p_set AND ug.user_id = s.user_id AND ug.appid = s.appid
AND (ug.status, ug.status_set_by_user, ug.rating, ug.notes, ug.priority)
IS DISTINCT FROM (v_status, v_by_user, v_rating, v_notes, v_priority);
-- The V14 trigger logged the status changes of the update as hand changes of now. A join is no hand change, and
-- a row that takes a finished status takes the first finish of the set, so "finished per year" does not move.
UPDATE user_game_status_change sc
SET by_user = false,
changed_at = coalesce(CASE WHEN sc.to_status IN ('FINISHED', 'COMPLETED') THEN
(SELECT min(o.changed_at) FROM user_game_status_change o
JOIN user_game_set s ON s.user_id = o.user_id AND s.appid = o.appid
WHERE s.user_id = p_user AND s.set_id = p_set AND o.id <= v_log
AND o.to_status IN ('FINISHED', 'COMPLETED')) END, sc.changed_at)
WHERE sc.id > v_log AND sc.user_id = p_user
AND sc.appid IN (SELECT appid FROM user_game_set WHERE user_id = p_user AND set_id = p_set);
END;
$$ LANGUAGE plpgsql;
-- A change on one edition goes to the other rows of the user's set, also hidden and removed ones, so a later unhide
-- matches. The update of the other rows fires this trigger again: depth 2 ends it.
CREATE FUNCTION share_user_game_set() RETURNS TRIGGER AS $$
BEGIN
IF pg_trigger_depth() > 1 THEN
RETURN NULL;
END IF;
UPDATE user_game ug
SET status = NEW.status, status_set_by_user = NEW.status_set_by_user, rating = NEW.rating, notes = NEW.notes,
priority = NEW.priority
FROM user_game_set me
JOIN user_game_set o ON o.user_id = me.user_id AND o.set_id = me.set_id AND o.appid <> me.appid
WHERE me.user_id = NEW.user_id AND me.appid = NEW.appid AND ug.user_id = o.user_id AND ug.appid = o.appid
AND (ug.status, ug.status_set_by_user, ug.rating, ug.notes, ug.priority)
IS DISTINCT FROM (NEW.status, NEW.status_set_by_user, NEW.rating, NEW.notes, NEW.priority);
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER user_game_set_share AFTER UPDATE OF status, status_set_by_user, rating, notes, priority ON user_game
FOR EACH ROW WHEN ((OLD.status, OLD.status_set_by_user, OLD.rating, OLD.notes, OLD.priority)
IS DISTINCT FROM (NEW.status, NEW.status_set_by_user, NEW.rating, NEW.notes, NEW.priority))
EXECUTE FUNCTION share_user_game_set();
-- A new row joins the set its key is in.
CREATE FUNCTION join_user_game_set() RETURNS TRIGGER AS $$
DECLARE
v_set BIGINT;
BEGIN
IF pg_trigger_depth() > 1 THEN
RETURN NULL;
END IF;
SELECT set_id INTO v_set FROM user_game_set WHERE user_id = NEW.user_id AND appid = NEW.appid;
IF v_set IS NOT NULL THEN
PERFORM align_user_game_set(NEW.user_id, v_set);
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER user_game_set_join AFTER INSERT ON user_game
FOR EACH ROW EXECUTE FUNCTION join_user_game_set();
-- A key joins a set (link, or a merge of two sets): every user with a row on it gets the set aligned.
CREATE FUNCTION join_game_link() RETURNS TRIGGER AS $$
DECLARE
v_user BIGINT;
BEGIN
IF pg_trigger_depth() > 1 THEN
RETURN NULL;
END IF;
FOR v_user IN SELECT user_id FROM user_game_set WHERE appid = NEW.appid LOOP
PERFORM align_user_game_set(v_user, NEW.set_id);
END LOOP;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER game_link_join AFTER INSERT OR UPDATE OF set_id ON game_link
FOR EACH ROW EXECUTE FUNCTION join_game_link();
-- Existing sets with different values: the same rule, once.
SELECT align_user_game_set(user_id, set_id) FROM user_game_set GROUP BY user_id, set_id HAVING count(*) > 1;
@@ -17,7 +17,7 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
private static final List<String> USER_TABLES = List.of("user_game_question", "user_game_status_change", "user_game_achievement",
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_psn_trophy", "user_psn_trophy_set", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger",
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_psn_trophy", "user_psn_trophy_set", "user_game_split", "user_game_set_backup", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger",
"reward_equipped", "user_psn_account", "listed_user", "public_user", "follow", "follow_digest", "inbox_note");
@Autowired
@@ -35,6 +35,9 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
for (long userId : List.of(1L, 2L)) {
// The V14 trigger adds the user_game_status_change row.
jdbc.update("insert into user_game (user_id, appid, status) values (?, 220, 'BACKLOG')", userId);
jdbc.update("insert into user_game_split (user_id, appid) values (?, 220)", userId);
jdbc.update("insert into user_game_set_backup (user_id, appid, status, status_set_by_user, notes) "
+ "values (?, 220, 'PLAYING', true, 'old note')", userId);
jdbc.update("insert into user_game_question (user_id, appid, question, playtime_minutes) values (?, 220, 'START', 0)", userId);
jdbc.update("insert into user_game_achievement (user_id, appid, api_name) values (?, 220, 'ACH_1')", userId);
jdbc.update("insert into user_game_achievement_meta (user_id, appid) values (?, 220)", userId);
@@ -8,6 +8,7 @@ import core_service.modules.library.domain.UserGame;
import core_service.modules.library.repo.GameLinkRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.service.GameEditionService;
import core_service.shared.security.Role;
import core_service.shared.test.AbstractIntegrationTest;
import core_service.shared.test.ContractTokens;
@@ -41,6 +42,7 @@ import static org.mockito.Mockito.doAnswer;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -67,6 +69,9 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private GameEditionService gameEditionService;
@MockitoSpyBean
private GameLinkRepository linkRepository;
@@ -351,4 +356,95 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.sources[1]").value("PSN"))
.andExpect(jsonPath("$.sources", hasSize(2)));
}
private ResultActions splitAs(long userId, long appid) throws Exception {
return mockMvc.perform(as(delete("/api/library/games/" + appid + "/set"), userId, Role.USER));
}
@Test
void autoLink_LinksTheIgdbPairWithoutAnAuditor() throws Exception {
assertThat(gameEditionService.linkSameGames(List.of(GAME))).isEqualTo(1);
assertThat(gameEditionService.linkSameGames(List.of(GAME))).isZero();
queue().andExpect(jsonPath("$.content", hasSize(0)));
assertThat(audit()).isEmpty();
mockMvc.perform(as(get("/api/library/games/" + GAME), USER_A, Role.USER)).andExpect(status().isOk())
.andExpect(jsonPath("$.editions", hasSize(1)));
}
@Test
void autoLink_NeverLinksADismissedPair() throws Exception {
queue();
mockMvc.perform(as(delete("/api/library/link-queue/" + GAME_2 + "/" + GAME), AUDITOR_ID, Role.AUDITOR))
.andExpect(status().isNoContent());
assertThat(gameEditionService.linkSameGames(null)).isZero();
}
@Test
void split_StandsAloneForTheUserOnly_AndStaysSplit() throws Exception {
gameEditionService.linkSameGames(null);
splitAs(USER_A, GAME).andExpect(status().isNoContent());
splitAs(USER_A, GAME).andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value("NOT_LINKED"));
gameEditionService.linkSameGames(null);
mockMvc.perform(as(get("/api/library/games/" + GAME), USER_A, Role.USER)).andExpect(status().isOk())
.andExpect(jsonPath("$.editions", hasSize(0)));
mockMvc.perform(as(get("/api/library/summary"), USER_A, Role.USER)).andExpect(jsonPath("$.total").value(2));
mockMvc.perform(as(get("/api/library/games/" + GAME), USER_B, Role.USER)).andExpect(status().isOk())
.andExpect(jsonPath("$.editions", hasSize(1)));
mockMvc.perform(as(get("/api/library/summary"), USER_B, Role.USER)).andExpect(jsonPath("$.total").value(1));
}
@Test
void split_GameWithoutAnotherEdition_Is404() throws Exception {
splitAs(USER_A, GAME).andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value("NOT_LINKED"));
}
@Test
void status_ChangeOnOneEditionShowsOnTheOther_UntilTheUserSplits() throws Exception {
gameEditionService.linkSameGames(null);
mockMvc.perform(as(put("/api/library/games/" + GAME + "/status"), USER_A, Role.USER)
.contentType(MediaType.APPLICATION_JSON).content("{\"status\": \"PLAYING\"}")).andExpect(status().isOk());
mockMvc.perform(as(get("/api/library/games/" + GAME_2), USER_A, Role.USER)).andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("PLAYING"));
mockMvc.perform(as(get("/api/library/games/" + GAME_2), USER_B, Role.USER)).andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("BACKLOG"));
splitAs(USER_A, GAME_2).andExpect(status().isNoContent());
mockMvc.perform(as(put("/api/library/games/" + GAME + "/status"), USER_A, Role.USER)
.contentType(MediaType.APPLICATION_JSON).content("{\"status\": \"FINISHED\"}")).andExpect(status().isOk());
mockMvc.perform(as(get("/api/library/games/" + GAME_2), USER_A, Role.USER)).andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("PLAYING"));
}
@Test
void sourceFilter_AfterASplitTheOtherEditionNoLongerMatches() throws Exception {
setSource(GAME_2, GameSource.PSN);
gameEditionService.linkSameGames(null);
games("?source=PSN").andExpect(jsonPath("$.totalElements").value(2));
splitAs(USER_A, GAME).andExpect(status().isNoContent());
games("?source=PSN").andExpect(jsonPath("$.totalElements").value(1))
.andExpect(jsonPath("$.content[0].appid").value(GAME_2));
}
@Test
void autoLink_DismissedPairStaysApartAlsoThroughAThirdEdition() throws Exception {
long third = FILLER - 1;
steamGameRepository.save(new SteamGame(third, "Hades (Xbox)", null, Instant.now()));
jdbc.update("insert into game_edition (appid, igdb_id, same_game_id, checked_at) values (?, 9, 7, now())", third);
queue();
mockMvc.perform(as(delete("/api/library/link-queue/" + GAME_2 + "/" + GAME), AUDITOR_ID, Role.AUDITOR))
.andExpect(status().isNoContent());
gameEditionService.linkSameGames(null);
assertThat(linkRepository.inOneSet(GAME, GAME_2)).isFalse();
}
}
@@ -39,6 +39,13 @@ class CountedUserGameViewTest extends AbstractPersistenceTest {
userId, appid, status, playtime);
}
// The set trigger would copy the status to the sibling; the view rule is about rows that differ.
private void differ(long appid, String status) {
jdbc.execute("alter table user_game disable trigger user_game_set_share");
jdbc.update("update user_game set status = ? where appid = ?", status, appid);
jdbc.execute("alter table user_game enable trigger user_game_set_share");
}
private List<Long> counted(long userId) {
return jdbc.queryForList("select appid from counted_user_game where user_id = ? order by appid", Long.class, userId);
}
@@ -49,12 +56,25 @@ class CountedUserGameViewTest extends AbstractPersistenceTest {
jdbc.queryForList(COLUMNS, String.class, "counted_user_game"));
}
@Test
void join_CountsTheEditionThatWasMoreAdvancedBeforeIt() {
own(USER, 10, "BACKLOG", 500);
own(USER, 20, "BACKLOG", 100);
jdbc.update("update user_game set status = 'FINISHED' where appid = 20");
set(10, 1);
set(20, 1);
assertThat(counted(USER)).containsExactly(10L);
}
@Test
void set_CountsTheMostAdvancedEdition() {
set(10, 1);
set(20, 1);
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "COMPLETED", 0);
own(USER, 20, "BACKLOG", 0);
differ(20, "COMPLETED");
assertThat(counted(USER)).containsExactly(20L);
}
@@ -97,7 +117,8 @@ class CountedUserGameViewTest extends AbstractPersistenceTest {
set(10, 1);
set(20, 1);
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "DROPPED", 0);
own(USER, 20, "BACKLOG", 0);
differ(20, "DROPPED");
assertThat(counted(USER)).containsExactly(20L);
}
@@ -113,6 +134,31 @@ class CountedUserGameViewTest extends AbstractPersistenceTest {
assertThat(counted(USER)).containsExactly(10L);
}
@Test
void splitEdition_IsCountedOnItsOwn() {
set(10, 1);
set(20, 1);
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "BACKLOG", 0);
jdbc.update("insert into user_game_split (user_id, appid) values (?, 20)", USER);
assertThat(counted(USER)).containsExactly(10L, 20L);
}
@Test
void splitOfOneUser_DoesNotChangeTheSetOfAnotherUser() {
set(10, 1);
set(20, 1);
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "BACKLOG", 0);
own(OTHER_USER, 10, "BACKLOG", 0);
own(OTHER_USER, 20, "BACKLOG", 0);
jdbc.update("insert into user_game_split (user_id, appid) values (?, 20)", USER);
assertThat(counted(USER)).containsExactly(10L, 20L);
assertThat(counted(OTHER_USER)).hasSize(1);
}
@Test
void keyInASetTheUserOwnsOnce_IsCounted_AndOtherUsersRowsHaveNoEffect() {
set(10, 1);
@@ -267,4 +267,63 @@ class GameLinkRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.editionCounts(OTHER_USER, List.of(100L))).isEmpty();
assertThat(repository.editionCounts(USER, List.of())).isEmpty();
}
@Test
void sameGamePairs_FindsEveryPairOnceAndFiltersByKey() {
assertThat(repository.sameGamePairs(null)).containsExactly(new long[]{-7, 100}, new long[]{-7, 200},
new long[]{100, 200});
assertThat(repository.sameGamePairs(List.of(200L))).containsExactly(new long[]{-7, 200}, new long[]{100, 200});
assertThat(repository.sameGamePairs(List.of(-8L, 300L))).isEmpty();
assertThat(repository.sameGamePairs(List.of())).isEmpty();
}
@Test
void sameGamePairs_SkipsPairsInOneSetAndDismissedPairs() {
repository.link(100, 200);
repository.addIgdbPairs();
repository.dismiss(-7, 100);
assertThat(repository.sameGamePairs(null)).containsExactly(new long[]{-7, 200});
}
@Test
void split_OnlyWhileAnotherListedEditionExists_AndOnlyOnce() {
repository.link(100, 200);
own(USER, 100, "BACKLOG");
own(USER, 200, "BACKLOG");
assertThat(repository.split(USER, 100)).isTrue();
assertThat(repository.split(USER, 100)).isFalse();
assertThat(repository.split(USER, 200)).isFalse();
assertThat(repository.split(USER, 300)).isFalse();
assertThat(repository.split(OTHER_USER, 100)).isFalse();
}
@Test
void split_AHiddenOrRemovedEditionDoesNotCount() {
repository.link(100, 200);
own(USER, 100, "BACKLOG");
own(USER, 200, "BACKLOG");
jdbc.update("update user_game set hidden_at = now() where appid = 200");
assertThat(repository.split(USER, 100)).isFalse();
}
@Test
void linkedEditionsAndEditionCounts_IgnoreASplitKey() {
repository.link(100, 200);
repository.link(100, -7);
own(USER, 100, "BACKLOG");
own(USER, 200, "BACKLOG");
own(USER, -7, "BACKLOG");
own(OTHER_USER, 100, "BACKLOG");
own(OTHER_USER, 200, "BACKLOG");
repository.split(USER, -7);
assertThat(repository.linkedEditions(USER, 100)).extracting(Edition::appid).containsExactly(200L);
assertThat(repository.linkedEditions(USER, -7)).isEmpty();
assertThat(repository.editionCounts(USER, List.of(100L, 200L, -7L))).isEqualTo(Map.of(100L, 2, 200L, 2));
assertThat(repository.editionCounts(OTHER_USER, List.of(100L, -7L))).isEqualTo(Map.of(100L, 2));
}
}
@@ -0,0 +1,239 @@
package core_service.modules.library.repo;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The triggers and functions of V42: one status, rating, notes and priority per user set.
*/
class GameSetTriggerTest extends AbstractPersistenceTest {
private static final long USER = 1L;
private static final long OTHER_USER = 2L;
@Autowired
private JdbcTemplate jdbc;
@Autowired
private UserGameRepository userGameRepository;
@BeforeEach
void seed() {
jdbc.update("delete from game_link");
jdbc.update("delete from user_game");
for (long appid : List.of(10L, 20L, 30L)) {
jdbc.update("insert into steam_game (appid, name) values (?, ?) on conflict do nothing", appid, "Game " + appid);
}
}
private void link(long appid, long other) {
jdbc.update("insert into game_link (appid, set_id) values (?, 1), (?, 1)", appid, other);
}
private void own(long userId, long appid, String status, int playtime) {
jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes) values (?, ?, ?, ?)",
userId, appid, status, playtime);
}
private Map<String, Object> row(long userId, long appid) {
return jdbc.queryForMap("select status, status_set_by_user, rating, notes, priority from user_game "
+ "where user_id = ? and appid = ?", userId, appid);
}
private Map<String, Object> backup(long appid) {
return jdbc.queryForMap("select status, rating, notes from user_game_set_backup where user_id = ? and appid = ?",
USER, appid);
}
@Test
void statusChange_GoesToTheOtherEditionOfTheSet() {
link(10, 20);
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "BACKLOG", 0);
jdbc.update("update user_game set status = 'PLAYING', status_set_by_user = true where appid = 10");
assertThat(row(USER, 20)).containsEntry("status", "PLAYING").containsEntry("status_set_by_user", true);
}
@Test
void ratingNotesAndPriority_GoToTheOtherEditionToo() {
link(10, 20);
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "BACKLOG", 0);
jdbc.update("update user_game set rating = 4, notes = 'great', priority = 'HIGH' where appid = 20");
assertThat(row(USER, 10)).containsEntry("rating", 4).containsEntry("notes", "great")
.containsEntry("priority", "HIGH");
}
@Test
void otherUsersAndUnlinkedGames_AreNotTouched() {
link(10, 20);
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "BACKLOG", 0);
own(OTHER_USER, 20, "BACKLOG", 0);
own(USER, 30, "BACKLOG", 0);
jdbc.update("update user_game set status = 'DROPPED' where user_id = ? and appid = 10", USER);
assertThat(row(OTHER_USER, 20)).containsEntry("status", "BACKLOG");
assertThat(row(USER, 30)).containsEntry("status", "BACKLOG");
}
@Test
void splitEdition_IsNotChanged_ButAHiddenEditionIs() {
link(10, 20);
jdbc.update("insert into game_link (appid, set_id) values (30, 1)");
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "BACKLOG", 0);
own(USER, 30, "BACKLOG", 0);
jdbc.update("insert into user_game_split (user_id, appid) values (?, 20)", USER);
jdbc.update("update user_game set hidden_at = now() where appid = 30");
jdbc.update("update user_game set status = 'FINISHED' where appid = 10");
assertThat(row(USER, 20)).containsEntry("status", "BACKLOG");
assertThat(row(USER, 30)).containsEntry("status", "FINISHED");
}
@Test
void statusLog_HasOneRowPerEditionAndNoEndlessLoop() {
link(10, 20);
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "BACKLOG", 0);
jdbc.update("delete from user_game_status_change");
jdbc.update("update user_game set status = 'PLAYING' where appid = 10");
assertThat(jdbc.queryForObject("select count(*) from user_game_status_change", Integer.class)).isEqualTo(2);
}
@Test
void finishIfUntouched_OnTheSteamEdition_AlsoFinishesTheOtherEdition() {
link(10, 20);
own(USER, 10, "PLAYING", 60);
own(USER, 20, "PLAYING", 0);
jdbc.update("update user_game set source = 'PSN' where appid = 20");
int changed = userGameRepository.finishIfUntouched(USER, 10L);
assertThat(changed).isEqualTo(1);
assertThat(row(USER, 20)).containsEntry("status", "FINISHED");
}
@Test
void join_TheMostAdvancedEditionWins_GapsAreFilledAndNotesJoined() {
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "PLAYING", 100);
jdbc.update("update user_game set rating = 4, notes = 'first' where appid = 10");
jdbc.update("update user_game set notes = 'second', status_set_by_user = true where appid = 20");
link(10, 20);
for (long appid : List.of(10L, 20L)) {
assertThat(row(USER, appid)).containsEntry("status", "PLAYING").containsEntry("status_set_by_user", true)
.containsEntry("rating", 4).containsEntry("notes", "second\n\nfirst");
}
assertThat(backup(10)).containsEntry("status", "BACKLOG").containsEntry("notes", "first");
assertThat(backup(20)).containsEntry("status", "PLAYING").containsEntry("notes", "second");
}
@Test
void join_NotesAreCutTo2000() {
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "PLAYING", 100);
jdbc.update("update user_game set notes = ? where appid = 10", "a".repeat(1500));
jdbc.update("update user_game set notes = ? where appid = 20", "b".repeat(1500));
link(10, 20);
assertThat((String) row(USER, 10).get("notes")).hasSize(2000).startsWith("bbb").endsWith("aaa");
}
@Test
void join_ASecondJoinDoesNotOverwriteTheBackup() {
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "PLAYING", 100);
jdbc.update("update user_game set notes = 'kept' where appid = 20");
link(10, 20);
jdbc.update("delete from game_link where appid = 10");
jdbc.update("update user_game set status = 'DROPPED', notes = 'newer' where appid = 10");
jdbc.update("insert into game_link (appid, set_id) values (10, 1)");
assertThat(row(USER, 10)).containsEntry("status", "PLAYING");
assertThat(backup(10)).containsEntry("status", "BACKLOG").containsEntry("notes", null);
}
@Test
void join_ASplitEditionStaysOutOfTheRule() {
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "PLAYING", 100);
jdbc.update("insert into user_game_split (user_id, appid) values (?, 20)", USER);
link(10, 20);
assertThat(row(USER, 10)).containsEntry("status", "BACKLOG");
assertThat(row(USER, 20)).containsEntry("status", "PLAYING");
}
@Test
void newRow_TakesTheValuesOfTheSet() {
link(10, 20);
own(USER, 10, "PLAYING", 0);
jdbc.update("update user_game set rating = 3 where appid = 10");
own(USER, 20, "BACKLOG", 0);
assertThat(row(USER, 20)).containsEntry("status", "PLAYING").containsEntry("rating", 3);
}
@Test
void newRow_MoreAdvancedThanTheSet_WinsForAllEditions() {
link(10, 20);
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "FINISHED", 0);
assertThat(row(USER, 10)).containsEntry("status", "FINISHED");
}
@Test
void join_IsNoHandStatusChangeOfTheUser() {
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "BACKLOG", 0);
jdbc.update("update user_game set status = 'FINISHED', status_set_by_user = true where appid = 20");
jdbc.update("delete from user_game_status_change");
link(10, 20);
assertThat(jdbc.queryForObject("select count(*) from user_game_status_change where user_id = ? and appid = 10 "
+ "and by_user", Integer.class, USER)).isZero();
}
@Test
void join_KeepsTheFinishDateOfTheCountedEdition() {
own(USER, 10, "BACKLOG", 500);
own(USER, 20, "BACKLOG", 100);
jdbc.update("update user_game set status = 'FINISHED', status_set_by_user = true where appid = 20");
jdbc.update("update user_game_status_change set changed_at = '2025-03-01T10:00:00Z' where appid = 20");
link(10, 20);
assertThat(jdbc.queryForObject("""
select extract(year from min(sc.changed_at))::int from counted_user_game c
join user_game_status_change sc on sc.user_id = c.user_id and sc.appid = c.appid
where c.user_id = ? and sc.to_status in ('FINISHED', 'COMPLETED')
and sc.from_status not in ('FINISHED', 'COMPLETED')""", Integer.class, USER)).isEqualTo(2025);
}
}
@@ -115,6 +115,15 @@ class QuestionRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.facts(USER, null)).extracting(QuestionFacts::appid).containsExactly(10L);
}
@Test
void facts_ASplitEditionIsItsOwnEntry() {
game(USER, 20, "BACKLOG", 0);
jdbc.update("insert into game_link (appid, set_id) values (10, 1), (20, 1)");
jdbc.update("insert into user_game_split (user_id, appid) values (?, 20)", USER);
assertThat(repository.facts(USER, null)).extracting(QuestionFacts::appid).containsExactlyInAnyOrder(10L, 20L);
}
@Test
void facts_AppidFilter() {
game(USER, 20, "BACKLOG", 0);
@@ -2,6 +2,7 @@ package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.repo.GameEditionRepository;
import core_service.modules.library.repo.GameLinkRepository;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -11,6 +12,7 @@ import org.mockito.Spy;
import org.springframework.core.task.SyncTaskExecutor;
import org.springframework.core.task.TaskExecutor;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.PlatformTransactionManager;
import java.time.Instant;
import java.util.List;
@@ -19,7 +21,9 @@ import java.util.Optional;
import java.util.Set;
import static org.mockito.ArgumentMatchers.any;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.never;
@@ -35,6 +39,12 @@ class GameEditionServiceTest extends AbstractUnitTest {
@Mock
private GameEditionRepository gameEditionRepository;
@Mock
private GameLinkRepository gameLinkRepository;
@Mock
private PlatformTransactionManager transactionManager;
@Spy
private TaskExecutor applicationTaskExecutor = new SyncTaskExecutor();
@@ -89,6 +99,42 @@ class GameEditionServiceTest extends AbstractUnitTest {
service.refresh(List.of(620L));
verifyNoInteractions(gameEditionRepository);
verifyNoInteractions(gameEditionRepository, gameLinkRepository);
}
@Test
void refresh_LinksThePairsOfTheAnsweredGames() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(gameEditionRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getEditions(Set.of(620L, 700L))).thenReturn(Optional.of(Map.of(
620L, new IgdbApiService.Edition(72, 10), 700L, new IgdbApiService.Edition(73, 10))));
when(gameLinkRepository.sameGamePairs(any())).thenReturn(List.<long[]>of(new long[]{620, 700}));
when(gameLinkRepository.lockGames(620, 700)).thenReturn(Map.of(620L, "A", 700L, "B"));
service.refresh(List.of(620L, 700L));
verify(gameLinkRepository).sameGamePairs(Set.of(620L, 700L));
verify(gameLinkRepository).link(620, 700);
}
@Test
void linkSameGames_PairAlreadyInOneSet_IsNotLinkedAgain() {
when(gameLinkRepository.sameGamePairs(null)).thenReturn(List.<long[]>of(new long[]{620, 700}));
when(gameLinkRepository.lockGames(620, 700)).thenReturn(Map.of(620L, "A", 700L, "B"));
when(gameLinkRepository.inOneSet(620, 700)).thenReturn(true);
assertEquals(0, service.linkSameGames(null));
verify(gameLinkRepository, never()).link(anyLong(), anyLong());
}
@Test
void linkSameGames_UnknownKey_IsNotLinked() {
when(gameLinkRepository.sameGamePairs(null)).thenReturn(List.<long[]>of(new long[]{620, 700}));
when(gameLinkRepository.lockGames(620, 700)).thenReturn(Map.of(620L, "A"));
assertEquals(0, service.linkSameGames(null));
verify(gameLinkRepository, never()).link(anyLong(), anyLong());
}
}
@@ -23,6 +23,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 ZoneId BERLIN = ZoneId.of("Europe/Berlin");
@Autowired
@@ -222,6 +223,75 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.completedCandidates(USER)).isEmpty();
}
// The game and a manual edition of it, one set, both with the given status.
private void setOfTwo(String status) {
jdbc.update("insert into steam_game (appid, name) values (?, 'Half-Life 2 (manual)') on conflict do nothing", EDITION);
game("BACKLOG", 120);
jdbc.update("insert into user_game (user_id, appid, status, source) values (?, ?, 'BACKLOG', 'MANUAL')", USER, EDITION);
jdbc.update("insert into game_link (appid, set_id) values (?, 1), (?, 1)", GAME, EDITION);
setStatus(status);
}
private void paid(RewardKind kind, long appid) {
ledger.add(USER, new RewardEntry(kind, String.valueOf(appid), 10, Instant.now()));
}
@Test
void finishCandidates_ASetGivesACandidatePerEdition() {
setOfTwo("FINISHED");
assertThat(repository.finishCandidates(USER, BERLIN)).hasSize(2);
}
@Test
void finishCandidates_NoneWhenAnotherEditionOfTheSetWasPaid() {
setOfTwo("FINISHED");
paid(RewardKind.FINISHED_MANUAL, EDITION);
assertThat(repository.finishCandidates(USER, BERLIN)).isEmpty();
}
@Test
void startCandidates_NoneWhenAnotherEditionOfTheSetWasPaid() {
setOfTwo("PLAYING");
assertThat(repository.startCandidates(USER, BERLIN)).hasSize(2);
paid(RewardKind.STARTED, EDITION);
assertThat(repository.startCandidates(USER, BERLIN)).isEmpty();
}
@Test
void completedCandidates_NoneWhenAnotherEditionOfTheSetWasPaid() {
setOfTwo("COMPLETED");
paid(RewardKind.FINISHED, GAME);
paid(RewardKind.FINISHED, EDITION);
assertThat(repository.completedCandidates(USER)).hasSize(2);
paid(RewardKind.COMPLETED, EDITION);
assertThat(repository.completedCandidates(USER)).isEmpty();
}
@Test
void candidates_ASplitEditionIsItsOwnCandidateAgain() {
setOfTwo("FINISHED");
jdbc.update("insert into user_game_split (user_id, appid) values (?, ?)", USER, EDITION);
paid(RewardKind.FINISHED, GAME);
assertThat(repository.finishCandidates(USER, BERLIN)).singleElement()
.satisfies(f -> assertThat(f.appid()).isEqualTo(EDITION));
}
@Test
void finishCandidates_AGameInNoSetIsUnchanged() {
game("BACKLOG", 120);
setStatus("FINISHED");
paid(RewardKind.FINISHED, EDITION);
assertThat(repository.finishCandidates(USER, BERLIN)).hasSize(1);
}
@Test
void pileFirstAndLast_NeedsBothDays() {
YearMonth month = YearMonth.of(2026, 9);
@@ -310,4 +380,46 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.minutesPerMonth(USER)).containsOnly(java.util.Map.entry(YearMonth.of(2026, 9), 30L));
}
@Test
void finishCandidates_SetKeepsTheEditionThatCanPay() {
long unplayed = 219L;
jdbc.update("insert into steam_game (appid, name) values (?, 'Half-Life 2 (old key)') on conflict do nothing", unplayed);
game("BACKLOG", 120);
jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes) values (?, ?, 'BACKLOG', 0)",
USER, unplayed);
jdbc.update("insert into game_link (appid, set_id) values (?, 1), (?, 1)", GAME, unplayed);
setStatus("FINISHED");
assertThat(repository.finishCandidates(USER, BERLIN)).extracting(FinishFacts::appid).contains(GAME);
}
@Test
void startCandidates_ManualEditionDoesNotBlockTheSteamStart() {
long manual = -1_220L;
jdbc.update("insert into steam_game (appid, name) values (?, 'Half-Life 2 (Switch)') on conflict do nothing", manual);
game("BACKLOG", 120);
jdbc.update("insert into user_game (user_id, appid, status, source) values (?, ?, 'BACKLOG', 'MANUAL')", USER, manual);
jdbc.update("insert into game_link (appid, set_id) values (?, 1), (?, 1)", GAME, manual);
setStatus("PLAYING");
assertThat(repository.startCandidates(USER, BERLIN)).extracting(StartFacts::appid).contains(GAME);
}
@Test
void completedCandidates_FinishPaidOnAnotherEditionOfTheSet() {
long other = 219L;
jdbc.update("insert into steam_game (appid, name) values (?, 'Half-Life 2 (old key)') on conflict do nothing", other);
game("BACKLOG", 120);
jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes) values (?, ?, 'BACKLOG', 30)",
USER, other);
jdbc.update("insert into game_link (appid, set_id) values (?, 1), (?, 1)", GAME, other);
setStatus("COMPLETED");
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);
paid(RewardKind.FINISHED, other);
assertThat(repository.completedCandidates(USER)).extracting(FinishFacts::appid).contains(GAME);
}
}
@@ -0,0 +1,49 @@
package core_service.modules.rewards.service;
import core_service.shared.security.Role;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import static org.assertj.core.api.Assertions.assertThat;
class RewardServiceSetTest extends AbstractIntegrationTest {
private static final long USER = 14L;
private static final long GAME = 230L;
private static final long EDITION = 231L;
@Autowired
private RewardService rewardService;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
@AfterEach
void clean() {
jdbc.update("delete from reward_ledger where user_id = ?", USER);
jdbc.update("delete from game_link where appid in (?, ?)", GAME, EDITION);
jdbc.update("delete from user_game where user_id = ?", USER);
}
@Test
void wallet_TwoEditionsThatCouldPay_BooksTheStartOnce() {
for (long appid : new long[]{GAME, EDITION}) {
jdbc.update("insert into steam_game (appid, name) values (?, 'Set game') on conflict do nothing", appid);
jdbc.update("insert into user_game (user_id, appid, status) values (?, ?, 'BACKLOG')", USER, appid);
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, current_date, ?, 30)",
USER, appid);
}
jdbc.update("insert into game_link (appid, set_id) values (?, 2300), (?, 2300)", GAME, EDITION);
jdbc.update("update user_game set status = 'PLAYING' where user_id = ?", USER);
rewardService.wallet(USER, Role.USER);
assertThat(jdbc.queryForObject("select count(*) from reward_ledger where user_id = ? and kind = 'STARTED'",
Integer.class, USER)).isEqualTo(1);
}
}