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

+47 -10
View File
@@ -139,12 +139,15 @@ flowchart LR
track reaches the item's target, retroactively and never lost. The five tracks (`Unlocks`): games finished track reaches the item's target, retroactively and never lost. The five tracks (`Unlocks`): games finished
(`FINISHED` + `FINISHED_MANUAL` rows + starter grant / 20), games completed, weeks in a row with a finish, months in (`FINISHED` + `FINISHED_MANUAL` rows + starter grant / 20), games completed, weeks in a row with a finish, months in
a row with a `MONTH` bonus, months in a row with more Steam playtime than the month before (hidden games count too). a row with a `MONTH` bonus, months in a row with more Steam playtime than the month before (hidden games count too).
A set (`user_game_set`, see "Editions of one game") pays `STARTED`, `FINISHED`/`FINISHED_MANUAL` and `COMPLETED` once: a
game whose other edition was paid is no candidate, and two editions of a set give one candidate (the first event).
A `BUY` or `UNLOCK` row proves ownership, A `BUY` or `UNLOCK` row proves ownership,
`reward_equipped` holds what is worn. A platform admin owns every item (the role of the login token, or the `role` `reward_equipped` holds what is worn. A platform admin owns every item (the role of the login token, or the `role`
claim of the profile ticket for visitors). claim of the profile ticket for visitors).
- **Editions and links**: editions, remasters and ports of one game stay separate library rows, but an AUDITOR can - **Editions and links**: editions, remasters and ports of one game stay separate library rows, but their keys are one
link their keys as one game for every user (see "Editions of one game"). IGDB suggests pairs, users can ask for game (a set): IGDB's "same game" pairs are linked automatically, an AUDITOR can link or unlink keys for every user,
one, and one queue holds both. Every link, unlink and dismissal goes to `admin_audit` and a user can split a key off a set for themselves (see "Editions of one game"). A set shares one status, rating,
note and priority. Users can ask for a pair; the auditor queue holds user requests and the IGDB pairs not linked yet (`OPEN` only). Every link, unlink and dismissal goes to `admin_audit`
(`GAME_LINK`, `GAME_UNLINK`, `GAME_LINK_DISMISS`); auditors read that log through `GET /api/library/audit-log`, with (`GAME_LINK`, `GAME_UNLINK`, `GAME_LINK_DISMISS`); auditors read that log through `GET /api/library/audit-log`, with
the actor's display name from Auth's account state push. the actor's display name from Auth's account state push.
- **Wishlist**: synced from Steam with store price and discount. - **Wishlist**: synced from Steam with store price and discount.
@@ -220,6 +223,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `GET` | `/api/library/catalog/{appid}` (what the correction dialog reads: name, image, speedrun, moods, endless, genres, timeToBeat, corrections; `404 GAME_NOT_FOUND`) | JWT, AUDITOR | | `GET` | `/api/library/catalog/{appid}` (what the correction dialog reads: name, image, speedrun, moods, endless, genres, timeToBeat, corrections; `404 GAME_NOT_FOUND`) | JWT, AUDITOR |
| `POST` | `/api/library/games/{appid}/merge` (`{otherAppid}`; `200 {appid}` = the key that stays, `400 SAME_GAME` / `STEAM_GAMES`, `404 GAME_NOT_FOUND`) | JWT, AUDITOR | | `POST` | `/api/library/games/{appid}/merge` (`{otherAppid}`; `200 {appid}` = the key that stays, `400 SAME_GAME` / `STEAM_GAMES`, `404 GAME_NOT_FOUND`) | JWT, AUDITOR |
| `POST` | `/api/library/merges/{mergeId}/undo` (`mergeId` = the `id` of a `GAME_MERGE` audit row, within 30 days; `204`, `404 MERGE_NOT_FOUND`, `409 UNDO_NOT_AVAILABLE` / `NEWER_MERGE`, `410 UNDO_EXPIRED`) | JWT, AUDITOR | | `POST` | `/api/library/merges/{mergeId}/undo` (`mergeId` = the `id` of a `GAME_MERGE` audit row, within 30 days; `204`, `404 MERGE_NOT_FOUND`, `409 UNDO_NOT_AVAILABLE` / `NEWER_MERGE`, `410 UNDO_EXPIRED`) | JWT, AUDITOR |
| `DELETE` | `/api/library/games/{appid}/set` (the user splits the key off its set for themselves; `204`, `404 NOT_LINKED` when no other listed edition is in the user's set) | JWT |
| `POST`, `DELETE` | `/api/library/games/{appid}/link` (POST `{otherAppid}`, `204`, `400 SAME_GAME`, `404 GAME_NOT_FOUND`, `409 ALREADY_LINKED`; DELETE takes the game out of its set, `204`, `404 NOT_LINKED`) | JWT, AUDITOR | | `POST`, `DELETE` | `/api/library/games/{appid}/link` (POST `{otherAppid}`, `204`, `400 SAME_GAME`, `404 GAME_NOT_FOUND`, `409 ALREADY_LINKED`; DELETE takes the game out of its set, `204`, `404 NOT_LINKED`) | JWT, AUDITOR |
| `GET` | `/api/library/audit-log?action=&page=&size=` (the newest `admin_audit` rows, `PageDto` of `{id, actorUserId, actorName, action, appid, gameName, targetAppid, targetGameName, detail, createdAt, undoable}`; `action` is `GAME_MERGE`, `GAME_MERGE_UNDO`, `GAME_LINK`, `GAME_UNLINK`, `GAME_LINK_DISMISS`, `GAME_CORRECT` or `GAME_CORRECTION_RESET`; size 20, 40 or 80) | JWT, AUDITOR | | `GET` | `/api/library/audit-log?action=&page=&size=` (the newest `admin_audit` rows, `PageDto` of `{id, actorUserId, actorName, action, appid, gameName, targetAppid, targetGameName, detail, createdAt, undoable}`; `action` is `GAME_MERGE`, `GAME_MERGE_UNDO`, `GAME_LINK`, `GAME_UNLINK`, `GAME_LINK_DISMISS`, `GAME_CORRECT` or `GAME_CORRECTION_RESET`; size 20, 40 or 80) | JWT, AUDITOR |
| `GET` | `/api/library/link-queue?page=&size=` (`PageDto` of `{a, b, igdb, requests, createdAt}`: both games, most requested first; size 20, 40 or 80) | JWT, AUDITOR | | `GET` | `/api/library/link-queue?page=&size=` (`PageDto` of `{a, b, igdb, requests, createdAt}`: both games, most requested first; size 20, 40 or 80) | JWT, AUDITOR |
@@ -338,7 +342,8 @@ flowchart LR
SQL, which ArchUnit cannot see: `stats` reads `listed_user`, `account_name`, `game_alias`, `listed_user_game`, `counted_user_game`, `steam_game`, `game_mood`, `coming_game`, `user_game_status_change`, `deleted_user` and `blocked_user`; `account` SQL, which ArchUnit cannot see: `stats` reads `listed_user`, `account_name`, `game_alias`, `listed_user_game`, `counted_user_game`, `steam_game`, `game_mood`, `coming_game`, `user_game_status_change`, `deleted_user` and `blocked_user`; `account`
deletes the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes deletes the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes
into its list) and writes `user_seen_at` and `last_synced_at` of `user_steam_sync` (`AccountStateRepository`); `library` into its list) and writes `user_seen_at` and `last_synced_at` of `user_steam_sync` (`AccountStateRepository`); `library`
reads `deleted_user` and `blocked_user`, and for the picker `follow`, `public_user` and `account_name`. A module that moves to its own service takes these reads along as calls. reads `deleted_user` and `blocked_user`, and for the picker `follow`, `public_user` and `account_name`; `rewards` reads
`user_game_set` (a set pays once). A module that moves to its own service takes these reads along as calls.
The listed rule ("still owned, not hidden") is `UserGameRepository.LISTED` for JPQL and the view The listed rule ("still owned, not hidden") is `UserGameRepository.LISTED` for JPQL and the view
`listed_user_game` for native SQL; V24 says what a migration of `user_game` must do with the view. The numbers that `listed_user_game` for native SQL; V24 says what a migration of `user_game` must do with the view. The numbers that
count games once per linked set read `counted_user_game` (V28), built on `listed_user_game`: a migration that changes count games once per linked set read `counted_user_game` (V28), built on `listed_user_game`: a migration that changes
@@ -521,18 +526,49 @@ like `game_mood` (stale check, nightly sync, add, detail view; `app.library.edit
`version_parent` for an edition, else `parent_game` when `game_type` is 9 (remaster) or 11 (port), else the game's own `version_parent` for an edition, else `parent_game` when `game_type` is 9 (remaster) or 11 (port), else the game's own
IGDB id; remakes (8) keep their own id. `null` when IGDB does not know the game. IGDB id; remakes (8) keep their own id. `null` when IGDB does not know the game.
- `game_link(appid, set_id)`: the keys an auditor linked, for every user. A set is every row with one `set_id`. - `game_link(appid, set_id)`: the keys that are one game, for every user. A set is every row with one `set_id`.
An unlink takes one key out; a set of one is dropped. A merge puts the kept key in the merged key's set; when both have a set, the sets join. An unlink takes one key out; a set of one is dropped. A merge puts the kept key in the merged key's set; when both have a set, the sets join.
- `game_link_pair` is the auditor queue (`OPEN`, `DISMISSED`, `LINKED`). IGDB pairs are inserted when the queue is - **Auto link** (V42): `GameEditionService.linkSameGames` links every pair of keys with the same `same_game_id`, after each
read; a row in any state keeps the pair out for good. An unlink dismisses the pairs of that key with the former editions refresh and once at start for the games already there (`linkExisting`). No auditor, so no audit row. A pair
in `DISMISSED` (an auditor dismissal or an unlink) is never linked again, also not through a third edition: a link
is skipped when a dismissed pair lies between the two sets. The link is global: a wrong IGDB key groups
the game for every user until an auditor unlinks it.
- **Split** (`DELETE /api/library/games/{appid}/set`): `user_game_split(user_id, appid)` makes the key stand alone for
this user, only while another listed edition is in the user's set. No sync or link touches it, so it stays split.
The view `user_game_set(user_id, appid, set_id)` is `game_link` for the user's rows minus the split keys; every
reader of a user's set (`counted_user_game`, the editions of the detail, the `source` filter, the questions, rewards)
reads it. There is no undo of a split yet.
- **One status per set** (V42 triggers, like V14, so no code path can forget it): a change of `status`,
`status_set_by_user`, `rating`, `notes` or `priority` on one row is copied to the other rows of the user's set,
also hidden and removed ones (`user_game_set_share`; `pg_trigger_depth()` stops the echo). When rows join a set (a new
row, a new link, a merge of sets), `align_user_game_set` applies the join rule: the winner is the counted edition
(listed first, then status rank, playtime, older `first_seen_at`, lower appid); its status wins, `status_set_by_user`
is true if any row has it, rating and priority fall back to the first row that has one, and the distinct notes are
joined with a blank line, cut to 2000. A row that changes is first copied to `user_game_set_backup` (the first copy
wins; kept until the account is deleted). The status log of a join is no hand change (`by_user` false), and a row that
takes a finished status takes the set's first finish time, so "finished per year" does not move.
- `game_link_pair` is the auditor queue (`OPEN`, `DISMISSED`, `LINKED`). IGDB pairs are inserted when the queue is read. A row in any state keeps the pair out for good. An unlink dismisses the pairs of that key with the former
members. `game_link_request` holds who asked for a pair (the queue shows only the count). A user asks for a pair of members. `game_link_request` holds who asked for a pair (the queue shows only the count). A user asks for a pair of
two games of their own library; at most 20 open requests per user (a per-user advisory lock makes the cap hold for parallel calls), a dismissed pair answers `409 PAIR_DISMISSED`. two games of their own library; at most 20 open requests per user (a per-user advisory lock makes the cap hold for parallel calls), a dismissed pair answers `409 PAIR_DISMISSED`.
- Pile, stats, snapshots and the dashboard count a set once (`counted_user_game`, see "Stats"); the library - Pile, stats, snapshots and the dashboard count a set once (`counted_user_game`, see "Stats"); the library
filter `hideDuplicates=true` hides the lesser editions. Pile Points are unchanged: `STARTED` and `FINISHED` stay per row, filter `hideDuplicates=true` hides the lesser editions. Pile Points pay a set once (see "Pile Points"), and the
and the `MONTH` bonus reads `backlog_snapshot`, which counts a set once. `MONTH` bonus reads `backlog_snapshot`, which counts a set once.
- The game detail has `linked` and `editions` (the user's other listed games of the set); a game row has `editions` - The game detail has `linked` and `editions` (the user's other listed games of the set); a game row has `editions`
(size of the set in the user's library, `null` below 2). (size of the set in the user's library, `null` below 2).
```mermaid
flowchart LR
Edition["game_edition (IGDB same_game_id)"] -->|"linkSameGames"| Link["game_link (global sets)"]
Auditor["auditor link / unlink"] --> Link
Link --> Set["user_game_set"]
Split["user_game_split (user splits)"] -->|"minus"| Set
Set --> Counted["counted_user_game"]
Set -->|"join: align_user_game_set"| Row["user_game (status, rating, notes, priority)"]
Row -->|"update: user_game_set_share"| Row
Row -.->|"old values"| Backup["user_game_set_backup"]
Set --> Rewards["rewards: a set pays once"]
```
### Deleted accounts ### Deleted accounts
Auth drives it (see Auth's readme). `POST /internal/accounts/deleted` adds a tombstone in `deleted_user`: the user Auth drives it (see Auth's readme). `POST /internal/accounts/deleted` adds a tombstone in `deleted_user`: the user
@@ -542,7 +578,8 @@ user ids, which removes the rows of users Auth does not know (refused for more t
`app.account-cleanup.max-orphans`, default 5). `app.account-cleanup.dry-run` (default `true`) only counts the rows. `app.account-cleanup.max-orphans`, default 5). `app.account-cleanup.dry-run` (default `true`) only counts the rows.
The tombstone also ends the user's follows in both directions without a note and removes the user's `public_user` and The tombstone also ends the user's follows in both directions without a note and removes the user's `public_user` and
`follow_digest` rows; the purge removes the user's inbox notes and the notes about the user. `follow_digest` rows; the purge removes the user's inbox notes and the notes about the user.
The purge also removes the user's `account_name` and `listed_user` rows and link requests (`game_link_request`). The purge also removes the user's `account_name` and `listed_user` rows, link requests (`game_link_request`), splits
(`user_game_split`) and set backups (`user_game_set_backup`).
The tombstone also deletes the user's stored PlayStation token (`user_psn_account`) at once. The tombstone also deletes the user's stored PlayStation token (`user_psn_account`) at once.
### Blocked users and Steam ID changes ### Blocked users and Steam ID changes
@@ -19,10 +19,10 @@ import java.util.Map;
@RequiredArgsConstructor @RequiredArgsConstructor
public class AccountCleanupRepository { 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", 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", "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"); "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. // 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")); : 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") @GetMapping("/link-queue")
public ResponseEntity<PageDto<LinkPairDto>> getLinkQueue(@RequestParam(defaultValue = "0") int page, public ResponseEntity<PageDto<LinkPairDto>> getLinkQueue(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) { @RequestParam(defaultValue = "20") int size) {
@@ -3,6 +3,7 @@ package core_service.modules.library.domain;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import lombok.AccessLevel; import lombok.AccessLevel;
import lombok.Getter; import lombok.Getter;
@@ -10,14 +11,20 @@ import lombok.NoArgsConstructor;
import org.hibernate.annotations.Immutable; 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 @Entity
@Immutable @Immutable
@Table(name = "game_link") @Table(name = "user_game_set")
@IdClass(UserGameId.class)
@Getter @Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PROTECTED)
public class GameLinkMember { public class UserGameSetMember {
@Id
@Column(name = "user_id")
private Long userId;
@Id @Id
@Column(name = "appid") @Column(name = "appid")
@@ -19,8 +19,8 @@ import java.util.Map;
import java.util.Optional; 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 * The global game sets and the auditor queue (V28), and the keys a user split off a set (V42). Plain SQL: sets span
* (least, greatest). * keys and users. Every pair is stored as (least, greatest).
*/ */
@Repository @Repository
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -55,6 +55,11 @@ public class GameLinkRepository {
(rs, row) -> rs.getLong("set_id")).stream().findFirst(); (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. * 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()); """, 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. * 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) { public List<Edition> linkedEditions(long userId, long appid) {
return jdbc.query(""" return jdbc.query("""
select ug.appid, g.name, ug.platform, ug.source, ug.status, g.image_url select ug.appid, g.name, ug.platform, ug.source, ug.status, g.image_url
from game_link me from user_game_set me
join game_link o on o.set_id = me.set_id and o.appid <> me.appid 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 = :user and ug.appid = o.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 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 order by g.name, ug.appid
""", new MapSqlParameterSource("user", userId).addValue("appid", appid), """, new MapSqlParameterSource("user", userId).addValue("appid", appid),
(rs, row) -> new Edition(rs.getLong("appid"), rs.getString("name"), rs.getString("platform"), (rs, row) -> new Edition(rs.getLong("appid"), rs.getString("name"), rs.getString("platform"),
@@ -226,10 +294,10 @@ public class GameLinkRepository {
} }
jdbc.query(""" jdbc.query("""
select l.appid, count(*) as owned select l.appid, count(*) as owned
from game_link l from user_game_set l
join game_link o on o.set_id = l.set_id 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 = :user and ug.appid = o.appid join listed_user_game ug on ug.user_id = o.user_id and ug.appid = o.appid
where l.appid in (:appids) where l.user_id = :user and l.appid in (:appids)
group by l.appid group by l.appid
having count(*) >= 2 having count(*) >= 2
""", new MapSqlParameterSource("user", userId).addValue("appids", appids), """, 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 select ug.appid as counted, ug.appid as member from counted_user_game ug where ug.user_id = :userId
union union
select ug.appid, o.appid from counted_user_game ug select ug.appid, o.appid from counted_user_game ug
join game_link l on l.appid = ug.appid join user_game_set l on l.user_id = ug.user_id and l.appid = ug.appid
join game_link o on o.set_id = l.set_id and o.appid <> l.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 join listed_user_game og on og.user_id = ug.user_id and og.appid = o.appid
where ug.user_id = :userId 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 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 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 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 " + @Query("select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " +
"where ug.userId = :userId and ug.removedAt is null " + "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)) " + " where t.appid = ug.appid and t.normallyMinutes <= :maxMinutes)) " +
"and (:hideDuplicates = false or exists (select 1 from CountedUserGame c " + "and (:hideDuplicates = false or exists (select 1 from CountedUserGame c " +
" where c.userId = ug.userId and c.appid = ug.appid)) " + " where c.userId = ug.userId and c.appid = ug.appid)) " +
"and (ug.source in :sources or exists (select 1 from GameLinkMember l " + "and (ug.source in :sources or exists (select 1 from UserGameSetMember l " +
" join GameLinkMember o on o.setId = l.setId and o.appid <> l.appid " + " 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 " + " 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, Page<UserGame> findOwnedGames(@Param("userId") Long userId,
@Param("hidden") boolean hidden, @Param("hidden") boolean hidden,
@Param("status") GameStatus status, @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.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.repo.GameEditionRepository; import core_service.modules.library.repo.GameEditionRepository;
import lombok.RequiredArgsConstructor; import core_service.modules.library.repo.GameLinkRepository;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; 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.core.task.TaskExecutor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.Instant; import java.time.Instant;
import java.util.Collection; 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 * 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 @Service
@RequiredArgsConstructor
@Slf4j @Slf4j
public class GameEditionService { public class GameEditionService {
private final IgdbApiService igdbApiService; private final IgdbApiService igdbApiService;
private final GameEditionRepository gameEditionRepository; private final GameEditionRepository gameEditionRepository;
private final GameLinkRepository gameLinkRepository;
private final TaskExecutor applicationTaskExecutor; private final TaskExecutor applicationTaskExecutor;
private final TransactionTemplate transaction;
@Value("${app.library.edition-ttl-days:30}") @Value("${app.library.edition-ttl-days:30}")
private long ttlDays; 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) { public void refresh(Collection<Long> appids) {
if (!igdbApiService.isEnabled() || appids.isEmpty()) return; if (!igdbApiService.isEnabled() || appids.isEmpty()) return;
Set<Long> stale = stale(appids); Set<Long> stale = stale(appids);
@@ -46,6 +62,50 @@ public class GameEditionService {
gameEditionRepository.upsert(appid, edition == null ? null : edition.igdbId(), gameEditionRepository.upsert(appid, edition == null ? null : edition.igdbId(),
edition == null ? null : edition.sameGameId(), now); 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.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors; 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 * 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 @Service
@RequiresRole(Role.AUDITOR) @RequiresRole(Role.AUDITOR)
@@ -56,7 +56,7 @@ public class GameLinkService {
if (names.size() < 2) { if (names.size() < 2) {
return LinkResult.NOT_FOUND; return LinkResult.NOT_FOUND;
} }
if (inOneSet(appid, otherAppid)) { if (linkRepository.inOneSet(appid, otherAppid)) {
return LinkResult.ALREADY_LINKED; return LinkResult.ALREADY_LINKED;
} }
linkRepository.link(appid, otherAppid); linkRepository.link(appid, otherAppid);
@@ -123,7 +123,7 @@ public class GameLinkService {
return RequestResult.NOT_FOUND; return RequestResult.NOT_FOUND;
} }
String state = linkRepository.pairState(appid, otherAppid); 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; return RequestResult.ALREADY_LINKED;
} }
if ("DISMISSED".equals(state)) { 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 false when the game has no other listed edition in the user's set
return set.isPresent() && set.equals(linkRepository.setOf(otherAppid)); */
@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; package core_service.modules.rewards.repo;
import core_service.modules.rewards.domain.FinishFacts; 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.RewardRules;
import core_service.modules.rewards.domain.StartFacts; import core_service.modules.rewards.domain.StartFacts;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -17,6 +18,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; 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 * 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; 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) { public boolean isActive(long userId) {
return Boolean.TRUE.equals(jdbc.queryForObject(""" return Boolean.TRUE.equals(jdbc.queryForObject("""
select not exists (select 1 from deleted_user where user_id = :userId) 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) { public List<StartFacts> startCandidates(long userId, ZoneId zone) {
return jdbc.query(""" return jdbc.query("""
@@ -59,10 +88,11 @@ public class RewardFactsRepository {
and ug.hidden_at is null and ug.source <> 'PSN' 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 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 t.state = 'LINKED')
where not exists (select 1 from reward_ledger l where l.user_id = c.user_id and l.kind = 'STARTED' left join user_game_set st on st.user_id = c.user_id and st.appid = c.appid
and l.ref = c.appid::text) where not %1$s
order by c.started_at order by c.started_at, c.appid
""", new MapSqlParameterSource("userId", userId).addValue("zone", zone.getId()), """.formatted(paidInSet("c", "'STARTED'")),
new MapSqlParameterSource("userId", userId).addValue("zone", zone.getId()),
(rs, i) -> new StartFacts(rs.getLong("appid"), rs.getBoolean("manual"), (rs, i) -> new StartFacts(rs.getLong("appid"), rs.getBoolean("manual"),
rs.getTimestamp("started_at").toInstant(), rs.getBoolean("played_after"))); 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 * 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 * 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 * 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) { public List<FinishFacts> finishCandidates(long userId, ZoneId zone) {
return jdbc.query(""" return jdbc.query("""
@@ -99,10 +130,11 @@ public class RewardFactsRepository {
left join game_time_to_beat t on t.appid = c.appid 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 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 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 left join user_game_set st on st.user_id = c.user_id and st.appid = c.appid
and l.kind in ('FINISHED', 'FINISHED_MANUAL') and l.ref = c.appid::text) where not %1$s
order by c.finished_at order by c.finished_at, c.appid
""", new MapSqlParameterSource("userId", userId).addValue("zone", zone.getId()) """.formatted(paidInSet("c", "'FINISHED', 'FINISHED_MANUAL'")),
new MapSqlParameterSource("userId", userId).addValue("zone", zone.getId())
.addValue("aroundDays", RewardRules.PLAYED_AROUND_DAYS), .addValue("aroundDays", RewardRules.PLAYED_AROUND_DAYS),
(rs, i) -> new FinishFacts(rs.getLong("appid"), rs.getBoolean("manual"), rs.getBoolean("completed"), (rs, i) -> new FinishFacts(rs.getLong("appid"), rs.getBoolean("manual"), rs.getBoolean("completed"),
rs.getInt("playtime_minutes"), rs.getInt("played_since"), 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) { public List<FinishFacts> completedCandidates(long userId) {
return jdbc.query(""" return jdbc.query("""
@@ -120,16 +153,22 @@ public class RewardFactsRepository {
coalesce(s.achievements_total > 0 and m.achievements_unlocked >= s.achievements_total, false) coalesce(s.achievements_total > 0 and m.achievements_unlocked >= s.achievements_total, false)
as all_achievements as all_achievements
from user_game ug 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 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 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 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 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 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 t.state = 'LINKED')
and not exists (select 1 from reward_ledger l where l.user_id = ug.user_id and l.kind = 'COMPLETED' and not %1$s
and l.ref = ug.appid::text) order by f.event_at, ug.appid
""", new MapSqlParameterSource("userId", userId), """.formatted(paidInSet("ug", "'COMPLETED'")), new MapSqlParameterSource("userId", userId),
(rs, i) -> new FinishFacts(rs.getLong("appid"), false, true, rs.getInt("playtime_minutes"), 0, null, (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())); 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; 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) { private int settleStarts(long userId) {
return facts.startCandidates(userId, zone).stream() 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) { private int settleFinishes(long userId) {
@@ -268,14 +275,14 @@ public class RewardService {
continue; continue;
} }
} }
paid += book(userId, entry); paid += bookOnePerSet(userId, entry);
} }
return paid; return paid;
} }
private int settleCompleted(long userId) { private int settleCompleted(long userId) {
return facts.completedCandidates(userId).stream() 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. // 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", 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", "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"); "reward_equipped", "user_psn_account", "listed_user", "public_user", "follow", "follow_digest", "inbox_note");
@Autowired @Autowired
@@ -35,6 +35,9 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
for (long userId : List.of(1L, 2L)) { for (long userId : List.of(1L, 2L)) {
// The V14 trigger adds the user_game_status_change row. // 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 (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_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 (user_id, appid, api_name) values (?, 220, 'ACH_1')", userId);
jdbc.update("insert into user_game_achievement_meta (user_id, appid) values (?, 220)", 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.GameLinkRepository;
import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.service.GameEditionService;
import core_service.shared.security.Role; import core_service.shared.security.Role;
import core_service.shared.test.AbstractIntegrationTest; import core_service.shared.test.AbstractIntegrationTest;
import core_service.shared.test.ContractTokens; 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.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 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.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.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -67,6 +69,9 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
@Autowired @Autowired
private UserGameRepository userGameRepository; private UserGameRepository userGameRepository;
@Autowired
private GameEditionService gameEditionService;
@MockitoSpyBean @MockitoSpyBean
private GameLinkRepository linkRepository; private GameLinkRepository linkRepository;
@@ -351,4 +356,95 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.sources[1]").value("PSN")) .andExpect(jsonPath("$.sources[1]").value("PSN"))
.andExpect(jsonPath("$.sources", hasSize(2))); .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); 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) { private List<Long> counted(long userId) {
return jdbc.queryForList("select appid from counted_user_game where user_id = ? order by appid", Long.class, 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")); 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 @Test
void set_CountsTheMostAdvancedEdition() { void set_CountsTheMostAdvancedEdition() {
set(10, 1); set(10, 1);
set(20, 1); set(20, 1);
own(USER, 10, "BACKLOG", 0); own(USER, 10, "BACKLOG", 0);
own(USER, 20, "COMPLETED", 0); own(USER, 20, "BACKLOG", 0);
differ(20, "COMPLETED");
assertThat(counted(USER)).containsExactly(20L); assertThat(counted(USER)).containsExactly(20L);
} }
@@ -97,7 +117,8 @@ class CountedUserGameViewTest extends AbstractPersistenceTest {
set(10, 1); set(10, 1);
set(20, 1); set(20, 1);
own(USER, 10, "BACKLOG", 0); own(USER, 10, "BACKLOG", 0);
own(USER, 20, "DROPPED", 0); own(USER, 20, "BACKLOG", 0);
differ(20, "DROPPED");
assertThat(counted(USER)).containsExactly(20L); assertThat(counted(USER)).containsExactly(20L);
} }
@@ -113,6 +134,31 @@ class CountedUserGameViewTest extends AbstractPersistenceTest {
assertThat(counted(USER)).containsExactly(10L); 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 @Test
void keyInASetTheUserOwnsOnce_IsCounted_AndOtherUsersRowsHaveNoEffect() { void keyInASetTheUserOwnsOnce_IsCounted_AndOtherUsersRowsHaveNoEffect() {
set(10, 1); set(10, 1);
@@ -267,4 +267,63 @@ class GameLinkRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.editionCounts(OTHER_USER, List.of(100L))).isEmpty(); assertThat(repository.editionCounts(OTHER_USER, List.of(100L))).isEmpty();
assertThat(repository.editionCounts(USER, List.of())).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); 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 @Test
void facts_AppidFilter() { void facts_AppidFilter() {
game(USER, 20, "BACKLOG", 0); 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.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.repo.GameEditionRepository; import core_service.modules.library.repo.GameEditionRepository;
import core_service.modules.library.repo.GameLinkRepository;
import core_service.shared.test.AbstractUnitTest; import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; 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.SyncTaskExecutor;
import org.springframework.core.task.TaskExecutor; import org.springframework.core.task.TaskExecutor;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.PlatformTransactionManager;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
@@ -19,7 +21,9 @@ import java.util.Optional;
import java.util.Set; import java.util.Set;
import static org.mockito.ArgumentMatchers.any; 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.anyCollection;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
@@ -35,6 +39,12 @@ class GameEditionServiceTest extends AbstractUnitTest {
@Mock @Mock
private GameEditionRepository gameEditionRepository; private GameEditionRepository gameEditionRepository;
@Mock
private GameLinkRepository gameLinkRepository;
@Mock
private PlatformTransactionManager transactionManager;
@Spy @Spy
private TaskExecutor applicationTaskExecutor = new SyncTaskExecutor(); private TaskExecutor applicationTaskExecutor = new SyncTaskExecutor();
@@ -89,6 +99,42 @@ class GameEditionServiceTest extends AbstractUnitTest {
service.refresh(List.of(620L)); 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 USER = 7L;
private static final long GAME = 220L; private static final long GAME = 220L;
private static final long EDITION = 221L;
private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin"); private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin");
@Autowired @Autowired
@@ -222,6 +223,75 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.completedCandidates(USER)).isEmpty(); 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 @Test
void pileFirstAndLast_NeedsBothDays() { void pileFirstAndLast_NeedsBothDays() {
YearMonth month = YearMonth.of(2026, 9); 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)); 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);
}
}