Compare commits

...
8 Commits
Author SHA1 Message Date
lobo b24fd91e88 feat(rewards): pay early finish points and top-up on proof
Build, Test & Push Core Service / docker (push) Skipped
Build, Test & Push Core Service / quality-check (push) Failing after 1m49s
A linked PlayStation title no longer blocks. Its playtime never
proves a finish. An unproven Steam finish pays 10 early points,
the rest comes with proof. Back pay only from app.rewards.early-since.
2026-10-09 16:00:06 +02:00
lobo 7c4b475321 feat(rewards): add game points endpoint with reason codes
Frontend shows why a game earned no points after a status save.
Settle runs first. Rules and amounts are not changed.
2026-10-09 15:25:04 +02:00
lobo 4ed8a341af feat(library): use set total playtime in sort and stats
Sort, total playtime, most played, genres, moods and the public
profile use the set total of an entry, as the library card does.
New view user_game_entry_playtime (V45) is the one SQL rule.
2026-10-09 14:35:07 +02:00
lobo 1ff1e061c7 feat(library): show play per platform on game detail
Store PSN play per title. A PS title on a Steam row keeps its minutes.
Steam rtime_last_played now fills last played.
Dropped? questions read the newest PSN last played too.
2026-10-09 13:55:57 +02:00
lobo 413381d404 feat(library): show one entry per game in every list
Gitea #34 slice 2 of 3. Lists and stats count a set once.
V43 views map an entry to its editions and platforms.
Remove the hideDuplicates param; GameDto gets platforms[].
2026-10-09 11:31:11 +02:00
lobo 82d3b59165 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.
2026-10-09 10:57:23 +02:00
lobo 8e4c382563 feat(library): add source filter to owned games
A row matches when its own source, or the source of a listed
linked edition, is selected. The hidden view ignores the filter.
2026-10-09 10:03:05 +02:00
lobo 6cd92f3e83 feat(library): add question queue for open game decisions
Add open questions per game: Start it?, Finished?, Dropped?, Rate it?.
A "No" is saved in user_game_question (V41) and snoozes the question.
Account cleanup clears the new table too. Refs Gitea #32.
2026-10-09 09:31:48 +02:00
89 changed files with 5370 additions and 234 deletions

No files matched your search

+126 -25
View File
@@ -86,6 +86,27 @@ To Be Played is split into three repositories:
weaker hint compares the playtime with IGDB's normal time to beat (at least 90 %), only for games IGDB does not
mark as endless (sandbox, survival, 4X, simulator, MMO). "Not yet" hides a hint per game; a dismissed playtime hint
still lets the ending hint through.
- **Question queue** (Gitea #32): `GET /api/library/questions` lists one open question per game: Start it? (BACKLOG with
playtime), Finished? (BACKLOG or PLAYING, playtime at least IGDB's normal time to beat, else 10 h; never for
IGDB-endless games), Dropped? (PLAYING, no Steam playtime in the last 2 weeks and no sign of play for 90 days) and
Rate it? (FINISHED or COMPLETED without a rating). The order is the priority. `OpenQuestion.of` is a pure rule over
`QuestionFacts`, which one SQL query loads for the whole library (`counted_user_game`, so a linked set is one card).
Every rule starts from the status, so a game the sync already finished or started drops out by itself. A "No" is
stored in `user_game_question` (V41): Start it? and Finished? come back after 5 more hours of playtime, Dropped?
after another 90 idle days, a Rate it? "Skip" never. `POST /api/library/questions/{appid}`
(`{question, yes, rating?}`) checks that the question is still open (`409 QUESTION_GONE`, `404 GAME_NOT_FOUND`,
`400 INVALID_ANSWER` for a yes to RATE without a rating) and then calls `BacklogService`, the same write path as the
game page. `GET /api/library/questions/count` uses the same code as the list, so the badge equals the cards.
```mermaid
flowchart LR
Facts["counted_user_game + playtime days + status log + time to beat + user_game_question"] --> Rule["OpenQuestion.of"]
Rule --> Card["question card"]
Answer["answer"] --> Backlog["BacklogService"] --> UserGame["user_game"] --> Trigger["V14 trigger: status log"] --> Settle["RewardService.settle"]
Answer -->|"Frontend: GET /api/rewards/games/{appid}"| Settle
Sync["library sync"] --> LibraryActivityEvent --> Settle
Answer -->|no| Snooze["user_game_question"]
```
- **Hide games**: a user can hide a game, Steam or added by hand (e.g. a playtest or a tool). It leaves every list and number: library,
picker, Pile of Shame, activity, achievement summary, public profile and landing page stats. The sync still
updates its playtime, but no achievements, time to beat, moods or speedrun data are fetched for it. The library
@@ -97,8 +118,12 @@ To Be Played is split into three repositories:
status history); games and playtime per platform (`PLATFORMS`: Steam games as "Steam", manual games by platform, trimmed and ignoring case, the most used spelling is the label); the Pile of Shame trend (90 days of `backlog_snapshot`, plus "this month: added / done", where the
first sync of a Steam account is no "added", see `user_steam_sync.history_since`). Every number counts only listed games (still owned, not hidden: the view
`listed_user_game`, V24). A linked set (see "Editions of one game") counts once: its most advanced edition (status, then
playtime, then the older `first_seen_at`; `counted_user_game`, V28), so the set is on the pile only when every owned edition is `BACKLOG`. Playtime
sums, most played, genres, moods and platforms still use every listed game.
playtime, then the older `first_seen_at`; `counted_user_game`, V28), so the set is on the pile only when every owned edition is `BACKLOG`. Most played, most loyal,
genres, moods, perfect games and the longest day list one entry per set, with the playtime of all its editions
(`user_game_entry_member`, V43; its list shows that sum as `minutes`). Every playtime number (most played, genres,
moods, the total playtime and moon trips, the drill-down lists) uses the set total, the view
`user_game_entry_playtime` (V45), the same as the library card, sort and public profile. Platforms stay per edition:
a Steam + PlayStation set counts on both platforms.
- **Drill-down**: every countable number opens the games behind it. A `StatKey` (library), `CommunityKey` (landing page)
or a Pile Points track/bonus row names a number; its SQL "game set" is the same text the number counts, so the
list and the number cannot drift. 40 games per page (at most page 250), a fixed order per key. A profile visitor
@@ -109,23 +134,29 @@ To Be Played is split into three repositories:
(old and new status, time, whether the user chose it), whatever code path made it. It starts with V14; older
changes are not known.
- **Pile Points** (`modules/rewards`): users earn points for playing games off the pile, in an append-only ledger
(`reward_ledger`, V26; the balance is the sum). No code path pays: a settle (on `GET /api/rewards/wallet` and
(`reward_ledger`, V26; the balance is the sum). No code path pays: a settle (on `GET /api/rewards/wallet`, on `GET /api/rewards/games/{appid}`, after every sync (`LibraryActivityEvent`, own transaction, a failure only logs) and
nightly at 02:30, `app.rewards.settle-cron`) derives rows from the status log, playtime days, achievements and pile
snapshots, once per user, kind and game (`ref`). Rules and numbers: `RewardRules`; a Steam finish needs playtime and one
proof of fresh play (ending achievement or playtime around the finish, or half of the IGDB time played since the finish; old history pays nothing). The month bonus needs a finished game in that month. Games added by hand pay 15 for
a finish, at most 3 per ISO week. The settle takes a per-user advisory lock. V26 also pays a one-time starter grant
proof of fresh play (ending achievement or playtime around the finish, or half of the IGDB time played since the finish). Without proof a Steam finish pays 10 early points (`FINISHED_EARLY`), only for finishes on or after
`app.rewards.early-since` (older unproven finishes pay only with proof); when proof arrives later, `FINISHED_TOPUP` pays the rest up to the full amount, once per game or set. PlayStation days never prove: a game linked to a PlayStation title pays by its own rules, only
PlayStation rows pay nothing. The month bonus needs a finished game in that month. Games added by hand pay 15 for
a finish. Manual finishes and early finishes share the cap of 3 per ISO week. `COMPLETED` needs a `FINISHED` or `FINISHED_TOPUP` row.
`GET /api/rewards/expected?appids=` (read-only, at most 200) answers the full finish value per game for the Questions cards. `GET /api/rewards/games/{appid}` (game page open, after every status save) settles, then answers the paid rows of the game and its set (`bookedNow` = written by this call) and one `NoPointsReason` per missing kind (`STARTED`, `FINISHED`, `COMPLETED`); the codes live in `RewardRules`, the Frontend only maps them to texts. The settle takes a per-user advisory lock. V26 also pays a one-time starter grant
for finishes from before the status log. Shop: a whitelist of 57 looks (`ShopItem`; 45 buyable and 12 unlock-only) in eight slots, including animated completed effects, page backgrounds (CSS, SVG and two canvas engines in the Frontend),
banner backgrounds (V27 widens `reward_equipped.slot`) and three buyable skins (full themes, slot `SKIN`). The unlock-only
items (price 0, never sold): the settle books an `UNLOCK` row (ref = item code, 0 points) once a
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
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,
`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).
- **Editions and links**: editions, remasters and ports of one game stay separate library rows, but an AUDITOR can
link their keys as one game for every user (see "Editions of one game"). IGDB suggests pairs, users can ask for
one, and one queue holds both. Every link, unlink and dismissal goes to `admin_audit`
- **Editions and links**: editions, remasters and ports of one game stay separate library rows, but their keys are one
game (a set): IGDB's "same game" pairs are linked automatically, an AUDITOR can link or unlink keys for every user,
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
the actor's display name from Auth's account state push.
- **Wishlist**: synced from Steam with store price and discount.
@@ -142,7 +173,7 @@ To Be Played is split into three repositories:
sets** of the matched games: they count as achievements (platinum = perfect game) in the detail page, stats, tiles,
public profile, sync banner and follower digest. See "PlayStation link". Off without `APP_PSN_TOKEN_KEY`.
- **Public profile data**: totals, status counts, games in progress and finished games with rating, perfect games
and average completion (the numbers count a linked set once; the lists keep every edition). Readable only
and average completion (the numbers and the lists count a linked set once, as its counted edition). Readable only
with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID. `stats` holds only the stat
groups the user shares (the ticket's `profileStats` claim, derived in Auth from the profile tiles); none by
default. A section the user
@@ -191,22 +222,26 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| Method | Path | Auth |
|---|---|---|
| `GET` | `/api/rewards/wallet` (balance, catalog, owned, equipped, last 50 ledger rows with the game name, unlock `progress` per track) | JWT |
| `GET` | `/api/rewards/expected?appids=` (`{appid: points}`, the full finish value, 0 when paid; read-only, no settle; `400 TOO_MANY` over 200 appids) | JWT |
| `POST` | `/api/rewards/purchases` (`{item}`; `200` wallet, `409 ALREADY_OWNED` / `NOT_ENOUGH_POINTS` / `NOT_FOR_SALE`) | JWT |
| `GET` | `/api/rewards/progress/{track}/games?page=`, `/api/rewards/history/{kind}/{ref}/games?page=` (the games behind a track or a `MONTH` / `STREAK` bonus; `400 NOT_A_GAME_LIST`) | JWT |
| `PUT` | `/api/rewards/equipped/{slot}` (`{item}`, null = take off; `200` wallet, `409 NOT_OWNED`, `400 WRONG_SLOT`) | JWT |
| `GET` | `/api/library/games` (`hideDuplicates=true` leaves out the lesser editions of a linked set), `/api/library/games/{appid}` | JWT |
| `GET` | `/api/library/games` (one row per set, the counted edition, with `platforms[]` of all its editions: Steam, PlayStation, then manual, and `totalPlaytimeMinutes` (all platforms; `playtimeMinutes` stays the counted edition's own); `name` matches any edition; `source=STEAM,PSN` keeps rows with a platform of that source, a PlayStation title on a Steam row counts as `PSN`; ignored with `hidden=true`, which lists every hidden row; `400` on an unknown source; an old `hideDuplicates` param is ignored), `/api/library/games/{appid}` | JWT |
| `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT |
| `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`, `time_to_beat` = `{normallyMinutes, completelyMinutes}`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR |
| `GET` | `/api/library/catalog?q=&page=&size=` (every game some user still has, by name part, `PageDto` of `{appid, name, imageUrl, platforms}`; `q` at most 100 characters; size 20, 40 or 80) | 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/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 |
| `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 |
| `DELETE` | `/api/library/link-queue/{appid}/{otherAppid}` (dismiss; `204`, `404 PAIR_NOT_OPEN`) | JWT, AUDITOR |
| `POST` | `/api/library/games/{appid}/link-requests` (`{otherAppid}`, both in the caller's library; `204`, `400 SAME_GAME`, `404 GAME_NOT_FOUND`, `409 ALREADY_LINKED` / `PAIR_DISMISSED`, `429 TOO_MANY_REQUESTS`) | JWT |
| `PUT` | `/api/library/games/{appid}/status` (status only, keeps rating, notes, priority) | JWT |
| `GET` | `/api/library/questions`, `/api/library/questions/count` (`{results: [{appid, name, imageUrl, question, status, playtimeMinutes, normallyMinutes, lastActivity}]}`, `{count}`) | JWT |
| `POST` | `/api/library/questions/{appid}` (`{question, yes, rating?}`; `200 {next}`, `400 INVALID_ANSWER`, `404 GAME_NOT_FOUND`, `409 QUESTION_GONE`) | JWT |
| `PUT` | `/api/library/games/{appid}/finished-hint` (`{dismissed: ENDING\|PLAYTIME}`, "Not yet"; `204`) | JWT |
| `PUT` | `/api/library/games/{appid}/hidden` (`{hidden}`; Steam and manual games) | JWT |
| `POST` | `/api/library/imports` (`{source: TEXT\|CSV\|SEARCH\|GALAXY\|GROUVEE\|HLTB\|PLAYNITE\|BOOKMARKLET, rows: [{title, platform, playtimeMinutes, status, igdbId}]}`, 1–1000 rows; `202 {id}` in `QUEUED`, `409 IMPORT_RUNNING`, `503 IGDB_OFF`) | JWT |
@@ -217,7 +252,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `GET`, `DELETE` | `/api/library/psn-account` (`{enabled, linked, linkedAt, expiresAt, reviewImportId}`: the PSN import waiting for review, else null), unlink (`204`, deletes the stored token) | JWT |
| `GET`, `DELETE` | `/api/library/imports/{id}/undo-check` (`{games, changed}`), `/api/library/imports/{id}/games` (undo; `204`, `409 IMPORT_NOT_SAVED`) | JWT |
| `POST` | `/api/library/sync` | JWT |
| `GET` | `/api/library/games/{appid}` (detail; slice 3: `achievements[]` also hold trophies with `kind` `STEAM`\|`PSN`, `setId`, `trophyType`, `group`, `rarity`; new `achievementSets[]` = `{id, kind, platform, name, total, unlocked, perfect, visible, listed}`, Steam first, then PS5 before PS4; `hasAchievements` = any set; the top-level counts stay Steam only) | JWT |
| `GET` | `/api/library/games/{appid}` (detail; slice 3: `achievements[]` also hold trophies with `kind` `STEAM`\|`PSN`, `setId`, `trophyType`, `group`, `rarity`; new `achievementSets[]` = `{id, kind, platform, name, total, unlocked, perfect, visible, listed}`, Steam first, then PS5 before PS4; `hasAchievements` = any set; the top-level counts stay Steam only; `platforms[]` = `{source, platform, appid, playtimeMinutes, lastPlayedAt, achievementsUnlocked, achievementsTotal, perfect}` one line per platform of the entry, Steam, PS5, PS4, manual, each with its own progress, `appid` = the edition whose detail lists them; `totalPlaytimeMinutes` = their sum) | JWT |
| `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | JWT |
| `GET` | `/api/library/backlog?limit=&released=` (`{games}` in the user's order; `limit` 1-20, none = all) | JWT |
| `PUT` | `/api/library/backlog` (`{appids}` = the whole order; `204`) | JWT |
@@ -225,7 +260,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `GET` | `/api/library/stats?groups=` (`OVERVIEW`, `PILE`, `TOP_GAMES`, `TASTE`, `HABITS`, `FINISHED`, `TREND`, `PLATFORMS`; none = all) | JWT |
| `GET` | `/api/library/stats/games?key=&arg=&page=` (the games behind a stats number; `400 BAD_ARG`) | JWT |
| `GET` | `/api/library/achievements/summary` (Steam achievements and PlayStation trophies; one entry per game, platinum = perfect) | JWT |
| `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` (`canSync`: a Steam ID or a PlayStation link) | JWT |
| `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` (`canSync`: a Steam ID or a PlayStation link; `sources`: the sources of the user's games) | JWT |
| `GET` | `/api/public/profiles/library` | profile ticket |
| `GET` | `/api/public/profiles/library/stats/games?key=&arg=&page=` (`403 NOT_SHARED`, `400 BAD_ARG`) | profile ticket |
| `GET` | `/api/public/profiles/rewards` (`{equipped}`, the worn looks) | profile ticket |
@@ -314,14 +349,18 @@ flowchart LR
`user_game` and `user_game_status_change` in SQL; the sync tells `follow` through `shared.event.LibraryActivityEvent`.
**One shared schema.** All modules use one database `tbp_core`, and some read or write another module's tables in
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`, `user_game_entry_member`, `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
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
`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
a column of `user_game` drops this view first, then `listed_user_game`.
a column of `user_game` drops the views built on it first: `user_game_entry_playtime`, `user_game_entry_platform`, `user_game_entry_member`, `counted_user_game`, then `listed_user_game`.
The two entry views (V43) map an entry (a counted row) to its editions and to their platforms: `user_game_entry_member`
is the one place that does it (stats and the library read it); `user_game_entry_platform` adds each edition's source
and platform and a PlayStation row for a `LINKED` or `STEAM_OWNED` title on that key.
Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain` (JPA entities) and `repo`
(repositories). `src/test/java/core_service/architecture/ArchitectureTest.java` checks these rules on every build:
@@ -354,7 +393,8 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain`
4. The worker loads the wishlist and the owned games from Steam. It updates `steam_game` and `user_game`, adds
the playtime growth of the day to `user_game_playtime_day`, and soft-deletes Steam games the user no longer
owns. The growth counts at most Steam's playtime of the last two weeks, so a game seen for the first time brings
no old playtime, and one game gets at most 24 hours per day. A BACKLOG game with new playtime becomes PLAYING, unless the user chose its status. When Steam returns no
no old playtime, and one game gets at most 24 hours per day. Steam's `rtime_last_played` (unix seconds, 0 = never)
is stored in `user_game.last_played_at`; a missing or zero value keeps the old one. A BACKLOG game with new playtime becomes PLAYING, unless the user chose its status. When Steam returns no
games, the library stays as it is.
5. `TimeToBeatService` and `MoodService` ask IGDB for missing or old time-to-beat, mood and genre data, for Steam
games and manual games. Hidden games only get their playtime updated.
@@ -363,7 +403,8 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain`
FINISHED for a game whose ending achievement is unlocked while the user never chose its status.
The PSN step (`PsnSyncStep`, then `PsnTrophyStep`, see "PlayStation link") runs after the Steam fetch and before
the transaction; its `ADDED`, `PLAYTIME` and trophy `ACHIEVEMENTS` changes join the same `sync_result`.
the transaction; its `ADDED`, `PLAYTIME` and trophy `ACHIEVEMENTS` changes join the same `sync_result`. It also saves
the play of every title (`PsnTitleRepository.savePlays`, "save title play" in the diagram below).
The frontend polls `GET /api/library/sync-status` until the run ends.
@@ -381,6 +422,7 @@ sequenceDiagram
participant F as FollowDigestService
participant D as Db
W->>P: run (titles, trophy summaries, changed lists)
P->>D: save title play (user_psn_title)
P-->>W: ADDED, PLAYTIME, ACHIEVEMENTS (trophy gains)
W->>D: sync_result, sync_change (ADDED, STATUS, WISHLIST, PSN changes)
W-->>A: LibrarySyncedEvent
@@ -500,17 +542,73 @@ 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
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.
- `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
- **Auto link** (V42): `GameEditionService.linkSameGames` links every pair of keys with the same `same_game_id`, after each
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
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
filter `hideDuplicates=true` hides the lesser editions. Pile Points are unchanged: `STARTED` and `FINISHED` stay per row,
and the `MONTH` bonus reads `backlog_snapshot`, which counts a set once.
- Every list of the user's own games shows a set once, as its counted edition (`counted_user_game`, see "Stats"): the
library and its total and badge, the dashboard, the recent games, the public profile lists, stats and their
drill-downs. There is no "Hide duplicates" filter any more; a hidden game is its own row under `hidden=true`. The
friends row of the picker matches a friend's game to the caller's entry through `game_link`. Kept per edition on
purpose: the hidden list, the platform stat, the average achievement completion (per edition; the tile's game counts are per entry) and the
community stats of the landing page. The playtime of a card, the playtime sort and the stats is the set total (`user_game_entry_playtime`, V45, see "Detail per platform"). Pile Points pay a set once (see "Pile Points"), and the
`MONTH` bonus reads `backlog_snapshot`, which counts a set once.
The library query joins `user_game_entry_playtime` on every page (about +4 ms per page); upgrade path: join it only for the playtime sort or the PLAYING status, with a second query.
- **Detail per platform** (V44): the PSN step stores platform, minutes and last played per title in `user_psn_title`
(`LINKED` and `STEAM_OWNED`, every sync, whatever row the title is on); the view `psn_title_play` resolves a merged
key through `game_alias`. `PlatformPlay.lines` is the one rule for the detail lines: a Steam or manual row is one
line, a PlayStation row is its titles (or one line from the row before the first sync after V44), a manual row is
replaced by the titles of its own platform, and every other title linked to a Steam or manual row adds its
PlayStation line. The total is their sum; the view `user_game_entry_playtime` (V45) is the same rule in SQL, a test
compares both. The card total (`GameLinkRepository.entryPlaytimes`), the playtime sort of the library, `sumPlaytime`
and the stats read that view; it has no correlated subquery on purpose (it made a library page cost 2 s). Each line carries its own
achievement or trophy progress; the full list stays on the edition's own detail page. Still per row, not per
platform: the playtime history (PSN minutes on a Steam row are not in it) and rewards (Steam only). Until a user's next sync after V44, PSN-on-Steam
lines are missing and Steam's last played is empty.
- 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), `platforms` (`source` + `platform` per edition, for
the icons of the card) and `totalPlaytimeMinutes`.
```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"]
Counted --> Member["user_game_entry_member"]
Member --> Platform["user_game_entry_platform"]
Platform -->|"platforms[]"| Card["library card, lists"]
EntryPlaytime -->|"totalPlaytimeMinutes"| Card
EntryPlaytime --> Stats["library sort, stats, public profile"]
Member --> EntryPlaytime["user_game_entry_playtime (V45)"]
Title["user_psn_title play (V44)"] -->|"psn_title_play"| EntryPlaytime
Title -->|"platforms[] of the detail"| Detail["game detail"]
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
@@ -521,7 +619,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.
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.
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.
### Blocked users and Steam ID changes
@@ -621,6 +720,8 @@ project ships it; all values in `app.psn.*`). `PsnAccountService` in `library` h
spelling or platform that the user owns is only linked. Everything else goes to the review: a READY `PSN` import with
the matched rows is created, or the rows are appended to the one that waits (`reviewImportId`). The review page is
the import page; saving a PSN import links the checked lines and dismisses the unchecked ones for good.
- Every `LINKED` or `STEAM_OWNED` title also keeps its own platform, minutes and last played in `user_psn_title` (V44),
so the detail shows PlayStation play next to Steam's on a shared row. The Steam row's own playtime stays Steam's.
- A `LINKED` title updates its row (`source = PSN`, not removed): playtime, `last_played_at`, `PLAYTIME` changes and the
playtime history; `playtime_2weeks` is the sum of the history of the last 14 days. A game the user deleted stays
`LINKED` and is not added again. A game also owned on Steam stays one row (the Steam row wins).
@@ -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.
static final List<String> TABLES = List.of("user_game_status_change", "user_game_achievement",
// 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.
@@ -36,6 +36,7 @@ import core_service.modules.library.domain.AuditEntry;
import core_service.modules.library.domain.CatalogGame;
import core_service.modules.library.domain.CatalogGameDetail;
import core_service.modules.library.domain.CatalogHit;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.LinkPair;
import core_service.modules.library.domain.Mood;
@@ -121,7 +122,8 @@ public class LibraryController {
boolean syncStarted = syncService.syncIfStale(userId, steamId);
long count = queryService.countOwnedGames(userId);
return ResponseEntity.ok(Map.of("count", count, "hasSteamId", principal.steamId() != null,
"canSync", syncService.canSync(userId, steamId), "syncStarted", syncStarted));
"canSync", syncService.canSync(userId, steamId), "syncStarted", syncStarted,
"sources", queryService.listSources(userId)));
}
@GetMapping("/games")
@@ -134,7 +136,7 @@ public class LibraryController {
@RequestParam(required = false) String q,
@RequestParam(required = false) Integer maxMinutes,
@RequestParam(defaultValue = "false") boolean hidden,
@RequestParam(defaultValue = "false") boolean hideDuplicates
@RequestParam(required = false) List<GameSource> source
) {
Long userId = principal.getIdAsLong();
syncService.syncIfStale(userId, principal.steamIdText().orElse(null));
@@ -142,7 +144,7 @@ public class LibraryController {
int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE;
int safePage = Math.max(0, page);
Sort safeSort = parseSort(sort);
var dtoPage = queryService.listOwnedGames(userId, hidden, status, q, maxMinutes != null && maxMinutes > 0 ? maxMinutes : null, hideDuplicates, PageRequest.of(safePage, safeSize, safeSort));
var dtoPage = queryService.listOwnedGames(userId, hidden, status, q, maxMinutes != null && maxMinutes > 0 ? maxMinutes : null, hidden ? null : source, PageRequest.of(safePage, safeSize, safeSort));
return ResponseEntity.ok(PageDto.of(dtoPage));
}
@@ -296,6 +298,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) {
@@ -0,0 +1,59 @@
package core_service.modules.library.api;
import core_service.modules.library.api.dto.AnsweredDto;
import core_service.modules.library.api.dto.QuestionAnswer;
import core_service.modules.library.api.dto.QuestionDto;
import core_service.modules.library.api.dto.QuestionsDto;
import core_service.modules.library.domain.Question;
import core_service.modules.library.service.QuestionService;
import core_service.shared.api.ErrorResponse;
import core_service.shared.security.UserPrincipal;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/library/questions")
@RequiredArgsConstructor
public class QuestionController {
private final QuestionService questionService;
@GetMapping
public QuestionsDto list(@AuthenticationPrincipal UserPrincipal principal) {
return new QuestionsDto(questionService.open(principal.getIdAsLong()).stream().map(QuestionDto::from).toList());
}
@GetMapping("/count")
public Map<String, Integer> count(@AuthenticationPrincipal UserPrincipal principal) {
return Map.of("count", questionService.count(principal.getIdAsLong()));
}
@PostMapping("/{appid}")
public ResponseEntity<?> answer(@AuthenticationPrincipal UserPrincipal principal, @PathVariable Long appid,
@Valid @RequestBody QuestionAnswer answer) {
if (answer.question() == Question.RATE && answer.yes() && answer.rating() == null) {
return ResponseEntity.badRequest()
.body(new ErrorResponse("INVALID_ANSWER", "A yes to RATE needs a rating"));
}
QuestionService.Answered answered = questionService.answer(principal.getIdAsLong(), appid, answer.question(),
answer.yes(), answer.rating());
return switch (answered.result()) {
case DONE -> ResponseEntity.ok(new AnsweredDto(answered.next().map(QuestionDto::from).orElse(null)));
case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("GAME_NOT_FOUND", "Game not in user library"));
case GONE -> ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ErrorResponse("QUESTION_GONE", "The question is not open any more"));
};
}
}
@@ -0,0 +1,4 @@
package core_service.modules.library.api.dto;
/** @param next the next open question of the same game; null when there is none */
public record AnsweredDto(QuestionDto next) {}
@@ -16,7 +16,7 @@ public record GameDetailDto(
int playtime2Weeks,
Instant firstSeenAt,
Instant lastSeenAt,
// from PlayStation; null for Steam rows
// from PlayStation or Steam; null when not known
Instant lastPlayedAt,
GameStatus status,
Integer rating,
@@ -57,7 +57,11 @@ public record GameDetailDto(
// see GameDto#comingOn
LocalDate comingOn,
// Steam's set first, then the PlayStation sets, PS5 before PS4; the counts above are Steam's only
List<AchievementSetDto> achievementSets
List<AchievementSetDto> achievementSets,
// one line per platform of the entry, Steam first; empty for a hidden game
List<PlatformPlayDto> platforms,
// the sum of the platform lines; the counts above and playtimeMinutes stay this edition's own
int totalPlaytimeMinutes
) {
/**
@@ -5,6 +5,7 @@ import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import java.time.LocalDate;
import java.util.List;
/**
* @param timeToBeatMinutes IGDB's normal time to beat, null when unknown
@@ -17,6 +18,9 @@ import java.time.LocalDate;
* @param editions how many games of the user are in this game's linked set, null below 2
* @param coming the game is not released yet and nobody played it; it does not count on the pile
* @param comingOn the release date of a coming game; null for released games and for "coming soon"
* @param platforms the platforms of every edition of the entry, Steam first; empty for a hidden game
* @param totalPlaytimeMinutes the playtime of every platform of the entry; {@code playtimeMinutes} stays the counted
* edition's own
*/
public record GameDto(
Long appid,
@@ -36,5 +40,7 @@ public record GameDto(
boolean hidden,
Integer editions,
boolean coming,
LocalDate comingOn
LocalDate comingOn,
List<PlatformDto> platforms,
int totalPlaytimeMinutes
) {}
@@ -0,0 +1,8 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.GameSource;
/**
* @param platform e.g. "PS5"; null when the edition has none
*/
public record PlatformDto(GameSource source, String platform) {}
@@ -0,0 +1,18 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.GameSource;
import java.time.Instant;
/**
* One platform line of a game's detail.
*
* @param platform e.g. "PS5"; null when the edition has none
* @param appid the edition the line belongs to; its own detail page lists the achievements
* @param lastPlayedAt null when not known
* @param achievementsUnlocked null when the platform has no achievements for the user
* @param achievementsTotal null when the platform has no achievements for the user
* @param perfect every achievement unlocked, or every trophy set perfect
*/
public record PlatformPlayDto(GameSource source, String platform, long appid, int playtimeMinutes, Instant lastPlayedAt,
Integer achievementsUnlocked, Integer achievementsTotal, boolean perfect) {}
@@ -0,0 +1,12 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.Question;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
/**
* @param yes yes changes the game; no snoozes the question
* @param rating 1 to 5; needed for a yes to RATE
*/
public record QuestionAnswer(@NotNull Question question, @NotNull Boolean yes, @Min(1) @Max(5) Integer rating) {}
@@ -0,0 +1,22 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.Question;
import core_service.modules.library.service.QuestionService;
import java.time.Instant;
/**
* A game with an open question.
*
* @param lastActivity the last sign of play; only for DROPPED
*/
public record QuestionDto(long appid, String name, String imageUrl, Question question, GameStatus status,
int playtimeMinutes, Integer normallyMinutes, Instant lastActivity) {
public static QuestionDto from(QuestionService.Open open) {
return new QuestionDto(open.game().appid(), open.game().name(), open.game().imageUrl(),
open.question().question(), open.game().status(), open.game().playtimeMinutes(),
open.game().normallyMinutes(), open.question().lastActivity());
}
}
@@ -0,0 +1,5 @@
package core_service.modules.library.api.dto;
import java.util.List;
public record QuestionsDto(List<QuestionDto> results) {}
@@ -0,0 +1,8 @@
package core_service.modules.library.domain;
/**
* A platform a library entry is on, e.g. {@code PSN} + "PS5".
*
* @param platform null when the edition has none, e.g. a Steam game
*/
public record EntryPlatform(GameSource source, String platform) {}
@@ -0,0 +1,62 @@
package core_service.modules.library.domain;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Stream;
/**
* The open question of a game in the question queue. Only a question: the user decides.
*
* @param lastActivity the last sign of play, for the Dropped? text; null for the other questions
*/
public record OpenQuestion(Question question, Instant lastActivity) {
static final int SNOOZE_MINUTES = 300;
static final int FINISHED_FALLBACK_MINUTES = 600;
static final Duration IDLE = Duration.ofDays(90);
/** The first rule that matches wins. Every rule starts from the status, so an automatic status change closes it. */
public static Optional<OpenQuestion> of(QuestionFacts facts, Instant now, ZoneId zone) {
GameStatus status = facts.status();
int playtime = facts.playtimeMinutes();
if (status == GameStatus.BACKLOG && playtime > 0 && isDue(playtime, facts.startAskedAt())) {
return Optional.of(new OpenQuestion(Question.START, null));
}
if ((status == GameStatus.BACKLOG || status == GameStatus.PLAYING) && !Boolean.TRUE.equals(facts.endless())
&& playtime >= finishedMinutes(facts) && isDue(playtime, facts.finishedAskedAt())) {
return Optional.of(new OpenQuestion(Question.FINISHED, null));
}
if (status == GameStatus.PLAYING && facts.playtime2Weeks() == 0) {
Instant lastActivity = lastActivity(facts, zone);
if (lastActivity.isBefore(now.minus(IDLE))) return Optional.of(new OpenQuestion(Question.DROPPED, lastActivity));
}
if ((status == GameStatus.FINISHED || status == GameStatus.COMPLETED) && facts.rating() == null
&& !facts.rateSkipped()) {
return Optional.of(new OpenQuestion(Question.RATE, null));
}
return Optional.empty();
}
private static boolean isDue(int playtime, Integer askedAt) {
return askedAt == null || playtime >= askedAt + SNOOZE_MINUTES;
}
private static int finishedMinutes(QuestionFacts facts) {
Integer normally = facts.normallyMinutes();
return normally != null && normally > 0 ? normally : FINISHED_FALLBACK_MINUTES;
}
/** The first sighting is the floor, so a new user waits the full idle time. */
private static Instant lastActivity(QuestionFacts facts, ZoneId zone) {
Instant playDay = facts.lastPlayDay() == null ? null
: facts.lastPlayDay().plusDays(1).atStartOfDay(zone).toInstant();
return Stream.of(playDay, facts.lastPlayedAt(), facts.lastStatusChange(), facts.firstSeenAt(),
facts.droppedAnsweredAt())
.filter(Objects::nonNull)
.max(Instant::compareTo)
.orElse(Instant.EPOCH);
}
}
@@ -0,0 +1,54 @@
package core_service.modules.library.domain;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
* One platform's play of a game entry: what the detail lists and the card totals. The view
* {@code user_game_entry_playtime} (V45) follows the same rule; a test pins both.
*
* @param platform e.g. "PS5"; null when the edition has none
* @param appid the edition the line comes from
* @param lastPlayedAt null when not known
*/
public record PlatformPlay(GameSource source, String platform, long appid, int playtimeMinutes, Instant lastPlayedAt) {
private static final Comparator<PlatformPlay> ORDER = Comparator
.comparingInt((PlatformPlay line) -> line.source() == GameSource.STEAM ? 0
: line.source() == GameSource.PSN ? 1 : 2)
.thenComparing(PlatformPlay::platform, Comparator.nullsLast(Comparator.reverseOrder()))
.thenComparingLong(PlatformPlay::appid);
/**
* A Steam row is one line; a PlayStation row is its titles, or itself while it has no stored title
* play; a manual row is replaced by the titles of its own platform; every title with stored play on any row adds its own PlayStation line.
*
* @param titles the stored play of the titles linked to the rows; other keys are ignored
*/
public static List<PlatformPlay> lines(List<UserGame> rows, List<TitlePlay> titles) {
List<PlatformPlay> lines = new ArrayList<>();
for (UserGame row : rows) {
List<TitlePlay> own = titles.stream().filter(title -> row.getAppid().equals(title.appid())).toList();
boolean replaced = switch (row.getSource()) {
case PSN -> !own.isEmpty();
case MANUAL -> own.stream().anyMatch(title -> row.getPlatform() != null
&& row.getPlatform().equals(title.platform()));
default -> false;
};
if (!replaced) {
lines.add(new PlatformPlay(row.getSource(), row.getPlatform(), row.getAppid(), row.getPlaytimeMinutes(),
row.getLastPlayedAt()));
}
own.forEach(title -> lines.add(new PlatformPlay(GameSource.PSN, title.platform(), row.getAppid(),
title.playtimeMinutes(), title.lastPlayedAt())));
}
lines.sort(ORDER);
return lines;
}
public static int total(List<PlatformPlay> lines) {
return (int) Math.min(lines.stream().mapToLong(PlatformPlay::playtimeMinutes).sum(), Integer.MAX_VALUE);
}
}
@@ -6,4 +6,9 @@ package core_service.modules.library.domain;
* @param listed true when the trophy rows are stored; false until a sync has fetched them
*/
public record PsnTrophySetProgress(String id, String platform, String name, int total, int earned,
boolean platinumEarned, boolean listed) {}
boolean platinumEarned, boolean listed) {
public boolean perfect() {
return platinumEarned || earned >= total;
}
}
@@ -0,0 +1,9 @@
package core_service.modules.library.domain;
/** A question of the question queue. The declaration order is the ask order: one question per game at a time. */
public enum Question {
START,
FINISHED,
DROPPED,
RATE
}
@@ -0,0 +1,21 @@
package core_service.modules.library.domain;
import java.time.Instant;
import java.time.LocalDate;
/**
* Everything {@link OpenQuestion#of} needs to know about one game of one user.
*
* @param normallyMinutes IGDB's normal time to beat; null when unknown
* @param endless see {@link Mood#endless}; null when IGDB does not know the game
* @param lastPlayDay the last day with Steam playtime
* @param lastStatusChange the last time the status changed
* @param startAskedAt the playtime at the "No" to Start it?; null when never answered
* @param finishedAskedAt the playtime at the "No" to Finished?; null when never answered
* @param droppedAnsweredAt the time of the "No" to Dropped?; null when never answered
*/
public record QuestionFacts(long appid, String name, String imageUrl, GameStatus status, Integer rating,
int playtimeMinutes, int playtime2Weeks, Integer normallyMinutes, Boolean endless,
LocalDate lastPlayDay, Instant lastPlayedAt, Instant lastStatusChange, Instant firstSeenAt,
Integer startAskedAt, Integer finishedAskedAt, Instant droppedAnsweredAt,
boolean rateSkipped) {}
@@ -0,0 +1,15 @@
package core_service.modules.library.domain;
/**
* A library entry played in the last two weeks; the playtimes add up every edition of the entry.
*/
public interface RecentEntry {
Long getAppid();
String getStatus();
Long getPlaytimeMinutes();
Long getPlaytime2Weeks();
}
@@ -0,0 +1,12 @@
package core_service.modules.library.domain;
import java.time.Instant;
/**
* The play of one PlayStation title (V44).
*
* @param appid the game key the title is linked to; null when saving, the title is addressed by its key
* @param platform "PS5" or "PS4"
* @param lastPlayedAt may be null
*/
public record TitlePlay(String psnKey, Long appid, String platform, int playtimeMinutes, Instant lastPlayedAt) {}
@@ -91,7 +91,7 @@ public class UserGame {
@Column(name = "import_id")
private Long importId;
/** From PlayStation; null for Steam rows. */
/** From PlayStation or Steam; null when not known. */
@Column(name = "last_played_at")
private Instant lastPlayedAt;
@@ -0,0 +1,51 @@
package core_service.modules.library.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.Immutable;
import java.io.Serializable;
/**
* A platform of a library entry, per edition of the entry: read-only view, see V43. {@code platform} is empty when the
* edition has none.
*/
@Entity
@Immutable
@Table(name = "user_game_entry_platform")
@IdClass(UserGameEntryPlatform.Key.class)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserGameEntryPlatform {
public record Key(Long userId, Long appid, Long memberAppid, GameSource source, String platform) implements Serializable {}
@Id
@Column(name = "user_id")
private Long userId;
@Id
@Column(name = "appid")
private Long appid;
@Id
@Column(name = "member_appid")
private Long memberAppid;
@Id
@Enumerated(EnumType.STRING)
@Column(name = "source")
private GameSource source;
@Id
@Column(name = "platform")
private String platform;
}
@@ -0,0 +1,34 @@
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;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.Immutable;
/**
* The set total of an entry, see V45; hidden rows have none.
*/
@Entity
@Immutable
@Table(name = "user_game_entry_playtime")
@IdClass(UserGameId.class)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserGameEntryPlaytime {
@Id
@Column(name = "user_id")
private Long userId;
@Id
@Column(name = "appid")
private Long appid;
@Column(name = "playtime_minutes", nullable = false)
private int playtimeMinutes;
}
@@ -0,0 +1,35 @@
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;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.Immutable;
/**
* 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 = "user_game_set")
@IdClass(UserGameId.class)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserGameSetMember {
@Id
@Column(name = "user_id")
private Long userId;
@Id
@Column(name = "appid")
private Long appid;
@Column(name = "set_id")
private Long setId;
}
@@ -12,7 +12,8 @@ import java.util.List;
/**
* What the users the caller follows played of the caller's backlog. Plain SQL: it reads the follow module's tables
* so the privacy rules and the data come from one statement.
* so the privacy rules and the data come from one statement. A friend's game matches the caller's entry by key or
* through the global game_link set, so another edition of the same game counts.
*/
@Repository
@RequiredArgsConstructor
@@ -34,8 +35,11 @@ public class FriendPlayRepository {
join public_user p on p.user_id = f.followed_user_id
join account_name a on a.user_id = f.followed_user_id
join counted_user_game fg on fg.user_id = f.followed_user_id
join counted_user_game ug on ug.user_id = f.user_id and ug.appid = fg.appid
left join game_link lf on lf.appid = fg.appid
join counted_user_game ug on ug.user_id = f.user_id
left join game_link lu on lu.appid = ug.appid
where f.user_id = :userId
and coalesce(lf.set_id::text, 'a' || fg.appid) = coalesce(lu.set_id::text, 'a' || ug.appid)
and fg.status <> 'DROPPED'
and not exists (select 1 from blocked_user b where b.user_id = f.followed_user_id)
and not exists (select 1 from deleted_user d where d.user_id = f.followed_user_id)
@@ -2,6 +2,7 @@ package core_service.modules.library.repo;
import core_service.modules.library.domain.CatalogGame;
import core_service.modules.library.domain.Edition;
import core_service.modules.library.domain.EntryPlatform;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.LinkPair;
@@ -11,6 +12,7 @@ import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
@@ -19,8 +21,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 +57,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 +135,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 +268,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 +296,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),
@@ -237,6 +307,48 @@ public class GameLinkRepository {
return counts;
}
/**
* @return per given entry key, the platforms of its editions (see V43): Steam, PlayStation, then manual, by
* platform name; a PlayStation platform without a name is left out when the entry has a named PlayStation one, or its own member row has a manual platform
*/
public Map<Long, List<EntryPlatform>> entryPlatforms(long userId, Collection<Long> appids) {
Map<Long, List<EntryPlatform>> platforms = new HashMap<>();
if (appids.isEmpty()) {
return platforms;
}
jdbc.query("""
select appid, source, platform
from user_game_entry_platform p
where user_id = :user and appid in (:appids)
and not (source = 'PSN' and platform = '' and exists (
select 1 from user_game_entry_platform n
where n.user_id = p.user_id and n.appid = p.appid and n.platform <> ''
and (n.source = 'PSN' or (n.source = 'MANUAL' and n.member_appid = p.member_appid))))
group by appid, source, platform
order by appid, case source when 'STEAM' then 0 when 'PSN' then 1 else 2 end, platform
""", new MapSqlParameterSource("user", userId).addValue("appids", appids),
rs -> {
String platform = rs.getString("platform");
platforms.computeIfAbsent(rs.getLong("appid"), k -> new ArrayList<>()).add(new EntryPlatform(
GameSource.valueOf(rs.getString("source")), platform.isEmpty() ? null : platform));
});
return platforms;
}
/**
* @return per given entry key, the playtime of all its editions: the view of V45, same rule as {@code PlatformPlay#total}
*/
public Map<Long, Integer> entryPlaytimes(long userId, Collection<Long> appids) {
Map<Long, Integer> playtimes = new HashMap<>();
if (appids.isEmpty()) {
return playtimes;
}
jdbc.query("select appid, playtime_minutes from user_game_entry_playtime where user_id = :user "
+ "and appid in (:appids)", new MapSqlParameterSource("user", userId).addValue("appids", appids),
rs -> { playtimes.put(rs.getLong("appid"), rs.getInt("playtime_minutes")); });
return playtimes;
}
private static MapSqlParameterSource keys(long appid, long otherAppid) {
return new MapSqlParameterSource("a", appid).addValue("b", otherAppid);
}
@@ -30,8 +30,9 @@ public class GameMergeRepository {
private static final List<String> CATALOG_TABLES = List.of("game_mood", "game_speedrun", "game_time_to_beat",
"game_correction", "broken_image", "game_edition", "game_release", "game_credit", "game_credit_check", "steam_game");
// The paid game kinds; a merge moves their ref, an undo moves it back.
private static final String GAME_KINDS = "('STARTED', 'FINISHED', 'FINISHED_MANUAL', 'COMPLETED')";
// The paid game kinds; a merge moves their ref, an undo moves it back. Mirrors RewardKind.GAME_KINDS.
private static final String GAME_KINDS =
"('STARTED', 'FINISHED', 'FINISHED_MANUAL', 'FINISHED_EARLY', 'FINISHED_TOPUP', 'COMPLETED')";
// The ledger rows of a game kind the merge moves: on from, unless the user holds the kind on to.
private static final String LEDGER_MOVED = "l.ref = cast(:from as text) and l.kind in " + GAME_KINDS
@@ -1,13 +1,17 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.PsnTitle;
import core_service.modules.library.domain.TitlePlay;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
@@ -65,4 +69,40 @@ public class PsnTitleRepository {
psnKeys.stream().map(key -> new MapSqlParameterSource("user", userId).addValue("key", key))
.toArray(MapSqlParameterSource[]::new));
}
/**
* Stores the play of the LINKED and STEAM_OWNED titles among {@code plays}, addressed by {@code psnKey}; other
* rows are left alone.
*/
public void savePlays(long userId, List<TitlePlay> plays) {
if (plays.isEmpty()) {
return;
}
jdbc.batchUpdate("""
update user_psn_title set platform = :platform, playtime_minutes = :minutes, last_played_at = :played
where user_id = :user and psn_key = :key and state in ('LINKED', 'STEAM_OWNED')""",
plays.stream().map(play -> new MapSqlParameterSource("user", userId).addValue("key", play.psnKey())
.addValue("platform", play.platform()).addValue("minutes", play.playtimeMinutes())
.addValue("played", play.lastPlayedAt() == null ? null : Timestamp.from(play.lastPlayedAt())))
.toArray(MapSqlParameterSource[]::new));
}
/**
* The stored play of the titles linked to these keys (V44), a merged key resolved to its kept key.
*/
public List<TitlePlay> findPlays(long userId, Collection<Long> appids) {
if (appids.isEmpty()) {
return List.of();
}
return jdbc.query("""
select psn_key, appid, platform, playtime_minutes, last_played_at from psn_title_play
where user_id = :user and appid in (:appids) order by psn_key""",
new MapSqlParameterSource("user", userId).addValue("appids", appids),
(rs, row) -> new TitlePlay(rs.getString("psn_key"), rs.getLong("appid"), rs.getString("platform"),
rs.getInt("playtime_minutes"), instant(rs.getTimestamp("last_played_at"))));
}
private static Instant instant(Timestamp timestamp) {
return timestamp == null ? null : timestamp.toInstant();
}
}
@@ -0,0 +1,87 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.Question;
import core_service.modules.library.domain.QuestionFacts;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
/** The facts behind the question queue, and the "No" answers it stores. Play activity covers every listed edition of a linked set. */
@Repository
@RequiredArgsConstructor
public class QuestionRepository {
private static final RowMapper<QuestionFacts> FACTS = (rs, i) -> new QuestionFacts(
rs.getLong("appid"), rs.getString("name"), rs.getString("image_url"),
GameStatus.valueOf(rs.getString("status")), (Integer) rs.getObject("rating"),
rs.getInt("playtime_minutes"), rs.getInt("playtime_2weeks"), (Integer) rs.getObject("normally_minutes"),
(Boolean) rs.getObject("endless"), rs.getObject("last_day", LocalDate.class),
instant(rs.getTimestamp("last_played_at")), instant(rs.getTimestamp("last_change")),
instant(rs.getTimestamp("first_seen_at")), (Integer) rs.getObject("start_asked"),
(Integer) rs.getObject("finished_asked"), instant(rs.getTimestamp("dropped_answered")),
rs.getBoolean("rate_skipped"));
private final NamedParameterJdbcTemplate jdbc;
/** @param appid one game; null for all games of the user */
public List<QuestionFacts> facts(long userId, Long appid) {
return jdbc.query("""
with members as (
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 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
)
select ug.appid, sg.name, sg.image_url, ug.status, ug.rating, ug.playtime_minutes, a.playtime_2weeks,
a.last_played_at, ug.first_seen_at, t.normally_minutes, m.endless, p.last_day, c.last_change,
q.start_asked, q.finished_asked, q.dropped_answered, coalesce(q.rate_skipped, false) as rate_skipped
from counted_user_game ug
left join steam_game sg on sg.appid = ug.appid
left join game_time_to_beat t on t.appid = ug.appid
left join game_mood m on m.appid = ug.appid
left join (select m.counted as appid, max(og.playtime_2weeks) as playtime_2weeks,
greatest(max(og.last_played_at), max(p.last_played_at)) as last_played_at
from members m join listed_user_game og on og.user_id = :userId and og.appid = m.member
left join psn_title_play p on p.user_id = :userId and p.appid = m.member
group by m.counted) a on a.appid = ug.appid
left join (select m.counted as appid, max(d.day) as last_day
from members m join user_game_playtime_day d on d.user_id = :userId and d.appid = m.member
group by m.counted) p on p.appid = ug.appid
left join (select m.counted as appid, max(s.changed_at) as last_change
from members m join user_game_status_change s on s.user_id = :userId and s.appid = m.member
group by m.counted) c on c.appid = ug.appid
left join (select appid,
max(playtime_minutes) filter (where question = 'START') as start_asked,
max(playtime_minutes) filter (where question = 'FINISHED') as finished_asked,
max(answered_at) filter (where question = 'DROPPED') as dropped_answered,
bool_or(question = 'RATE') as rate_skipped
from user_game_question where user_id = :userId group by appid) q on q.appid = ug.appid
where ug.user_id = :userId and (cast(:appid as bigint) is null or ug.appid = :appid)
""", new MapSqlParameterSource("userId", userId).addValue("appid", appid), FACTS);
}
public void snooze(long userId, long appid, Question question, int playtimeMinutes) {
jdbc.update("""
insert into user_game_question (user_id, appid, question, playtime_minutes)
values (:userId, :appid, :question, :playtime)
on conflict (user_id, appid, question)
do update set playtime_minutes = excluded.playtime_minutes, answered_at = now()
""", new MapSqlParameterSource("userId", userId).addValue("appid", appid)
.addValue("question", question.name()).addValue("playtime", playtimeMinutes));
}
private static Instant instant(Timestamp timestamp) {
return timestamp == null ? null : timestamp.toInstant();
}
}
@@ -2,8 +2,10 @@ package core_service.modules.library.repo;
import core_service.modules.library.domain.AchievementProgress;
import core_service.modules.library.domain.FinishedWithoutDate;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.Mood;
import core_service.modules.library.domain.RecentEntry;
import core_service.modules.library.domain.StatusCount;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
@@ -40,6 +42,29 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
*/
String LISTED = "ug.userId = :userId and ug.removedAt is null and ug.hiddenAt is null ";
/**
* The filters of {@link #findOwnedGames}, shared by its value and count query. Needs the aliases {@code ug} and {@code sg}.
*/
String OWNED_WHERE = "where ug.userId = :userId and ug.removedAt is null " +
"and ((:hidden = true and ug.hiddenAt is not null) or (:hidden = false and ug.hiddenAt is null)) " +
"and (:status is null or ug.status = :status) " +
"and (:namePattern is null or (:hidden = true and lower(sg.name) like :namePattern escape '\\') " +
" or (:hidden = false and exists (select 1 from UserGameEntryPlatform p " +
" join SteamGame mg on mg.appid = p.memberAppid " +
" where p.userId = ug.userId and p.appid = ug.appid and lower(mg.name) like :namePattern escape '\\'))) " +
"and (:maxMinutes is null or exists (select 1 from GameTimeToBeat t " +
" where t.appid = ug.appid and t.normallyMinutes <= :maxMinutes)) " +
"and (:hidden = true or exists (select 1 from CountedUserGame c " +
" where c.userId = ug.userId and c.appid = ug.appid)) " +
"and (:hidden = true or exists (select 1 from UserGameEntryPlatform p " +
" where p.userId = ug.userId and p.appid = ug.appid and p.source in :sources))";
/**
* The sort text of the set total: the own minutes for a hidden row. The query needs
* {@code left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid}.
*/
String ENTRY_PLAYTIME_ORDER = "coalesce(ep.playtimeMinutes, ug.playtimeMinutes)";
@Query("select ug from UserGame ug where " + LISTED + "and ug.source = core_service.modules.library.domain.GameSource.STEAM")
List<UserGame> findListedSteamGames(@Param("userId") Long userId);
@@ -62,18 +87,38 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
@Query("select ug.appid from UserGame ug where ug.userId = :userId and ug.removedAt is null and ug.hiddenAt is not null")
List<Long> findHiddenAppids(@Param("userId") Long userId);
@Query("select count(ug) from UserGame ug where " + LISTED)
/**
* The entries of the user's lists: a set counts once, see V43.
*/
@Query("select count(c) from CountedUserGame c where c.userId = :userId")
long countListed(@Param("userId") Long userId);
@Query("select coalesce(sum(ug.playtimeMinutes), 0) from UserGame ug where " + LISTED)
/**
* The sum of the set totals of the listed entries, see V45.
*/
@Query("select coalesce(sum(ep.playtimeMinutes), 0) from UserGameEntryPlaytime ep where ep.userId = :userId")
long sumPlaytime(@Param("userId") Long userId);
@Query("select ug from UserGame ug where " + LISTED + "and ug.status in :statuses")
@Query("select ug from UserGame ug left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid " +
"where " + LISTED + "and ug.status in :statuses " +
"and exists (select 1 from CountedUserGame c where c.userId = ug.userId and c.appid = ug.appid)")
List<UserGame> findListedByStatusIn(@Param("userId") Long userId, @Param("statuses") Collection<GameStatus> statuses,
Pageable pageable);
@Query("select ug from UserGame ug where " + LISTED + "and ug.playtime2Weeks > 0 order by ug.playtime2Weeks desc")
List<UserGame> findListedPlayedInTwoWeeks(@Param("userId") Long userId, Pageable pageable);
/**
* The entries played in the last two weeks, the most played in them first: the counted edition, the minutes of the
* last two weeks of every edition of the set added up (Steam only), and the set total as playtime, see V45.
*/
@Query(value = "select c.appid as appid, c.status as status, ep.playtime_minutes as playtimeMinutes, " +
"sum(m.playtime_2weeks) as playtime2Weeks " +
"from counted_user_game c " +
"join user_game_entry_playtime ep on ep.user_id = c.user_id and ep.appid = c.appid " +
"join user_game_entry_member e on e.user_id = c.user_id and e.appid = c.appid " +
"join listed_user_game m on m.user_id = e.user_id and m.appid = e.member_appid " +
"where c.user_id = :userId group by c.appid, c.status, ep.playtime_minutes " +
"having sum(m.playtime_2weeks) > 0 order by sum(m.playtime_2weeks) desc, c.appid limit :limit",
nativeQuery = true)
List<RecentEntry> findEntriesPlayedInTwoWeeks(@Param("userId") Long userId, @Param("limit") int limit);
@Query("select count(c) from CountedUserGame c where c.userId = :userId and c.playtimeMinutes = 0")
long countListedNeverPlayed(@Param("userId") Long userId);
@@ -315,29 +360,32 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
}
/**
* One row per entry (see V43): a listed game is the counted edition of its set; a hidden game is a row of its own.
*
* @param hidden true for only the hidden games, false for only the listed ones
* @param status null for every status
* @param namePattern a {@link #containsPattern} result, null for every name
* @param namePattern a {@link #containsPattern} result, null for every name; a listed entry matches by the name of
* any of its editions
* @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 one platform of the entry must have (a PlayStation title on a Steam row counts as
* PSN); never empty, and ignored for the hidden games
*/
@Query("select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " +
"where ug.userId = :userId and ug.removedAt is null " +
"and ((:hidden = true and ug.hiddenAt is not null) or (:hidden = false and ug.hiddenAt is null)) " +
"and (:status is null or ug.status = :status) " +
"and (:namePattern is null or lower(sg.name) like :namePattern escape '\\') " +
"and (:maxMinutes is null or exists (select 1 from GameTimeToBeat t " +
" 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))")
// ponytail: the view is joined on every page, not only for the playtime sort: about +4 ms per page. Upgrade path:
// join it only for the playtime sort or the PLAYING status, with a second query.
@Query(value = "select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " +
"left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid " + OWNED_WHERE,
countQuery = "select count(ug) from UserGame ug join SteamGame sg on sg.appid = ug.appid " + OWNED_WHERE)
Page<UserGame> findOwnedGames(@Param("userId") Long userId,
@Param("hidden") boolean hidden,
@Param("status") GameStatus status,
@Param("namePattern") String namePattern,
@Param("maxMinutes") Integer maxMinutes,
@Param("hideDuplicates") boolean hideDuplicates,
@Param("sources") Collection<GameSource> sources,
Pageable pageable);
@Query("select distinct ug.source from UserGame ug where ug.userId = :userId and ug.removedAt is null")
List<GameSource> findSources(@Param("userId") Long userId);
/**
* Normal time to beat of the user's backlog, only games IGDB has data for.
*/
@@ -357,13 +405,15 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
/**
* Makes a {@link #findOwnedGames} sort stable and meaningful: games without a rating or priority come last
* in both directions, priority sorts by HIGH, MEDIUM, LOW (the enum names would sort alphabetically), and
* appid breaks every tie so pages do not repeat or skip games.
* appid breaks every tie so pages do not repeat or skip games. Playtime sorts by the set total.
*/
static Sort querySort(Sort requested) {
Sort result = Sort.unsorted();
for (Sort.Order order : requested) {
result = result.and(switch (order.getProperty()) {
case "rating" -> Sort.by(order.nullsLast());
case "playtimeMinutes" -> Sort.by(JpaSort.unsafe(order.getDirection(), ENTRY_PLAYTIME_ORDER)
.iterator().next());
case "priority" -> Sort.by(JpaSort.unsafe(order.getDirection(),
"(case ug.priority when 'HIGH' then 3 when 'MEDIUM' then 2 when 'LOW' then 1 end)")
.iterator().next().nullsLast());
@@ -39,11 +39,16 @@ public class UserStatsRepository {
where sc.user_id = :userId and %s
group by sc.appid
""".formatted(FINISH_CHANGE);
private static final String PLAYTIME_DAYS_NOT_HIDDEN = """
from user_game_playtime_day p
private static final String DAY_NOT_HIDDEN = """
where p.user_id = :userId and not exists (select 1 from user_game ug where ug.user_id = p.user_id
and ug.appid = p.appid and ug.hidden_at is not null)
""";
private static final String PLAYTIME_DAYS_NOT_HIDDEN = "from user_game_playtime_day p\n" + DAY_NOT_HIDDEN;
/** The playtime days like above, with the entry (see V43) of each day; a removed game is its own entry. */
private static final String ENTRY_DAYS = """
from user_game_playtime_day p
left join user_game_entry_member e on e.user_id = p.user_id and e.member_appid = p.appid
""" + DAY_NOT_HIDDEN;
/** The columns of a game set a set has no value for; the list query reads the same columns from every set. */
private static final String NO_MINUTES = "cast(null as bigint) as minutes";
@@ -55,10 +60,13 @@ public class UserStatsRepository {
from counted_user_game ug join game_time_to_beat t on t.appid = ug.appid
where %s and ug.status = 'BACKLOG' and %s
""".formatted(OF_USER, UserGameRepository.NOT_COMING);
/** Per entry, the set total (V45); the user's other rows are not in it. */
private static final String ENTRY_PLAYTIME = """
(select ep.appid, ep.playtime_minutes from user_game_entry_playtime ep where ep.user_id = :userId)""";
private static final String MOST_PLAYED_FROM = """
from listed_user_game ug join steam_game sg on sg.appid = ug.appid
where %s and ug.playtime_minutes > 0
""".formatted(OF_USER);
from %s ug join steam_game sg on sg.appid = ug.appid
where ug.playtime_minutes > 0
""".formatted(ENTRY_PLAYTIME);
private static final String PLATFORM_FROM = """
from (select ug.appid, case when ug.source = 'STEAM' then 'Steam' else trim(ug.platform) end as name,
ug.playtime_minutes
@@ -95,8 +103,8 @@ public class UserStatsRepository {
private static final String PILE_TTB_SET = "select ug.appid, t.normally_minutes as minutes, " + NO_EVENT + ", "
+ NO_MOVE + " " + PILE_TTB_FROM + "and t.normally_minutes is not null";
private static final String LONGEST_DAY_SET = "select p.appid, sum(p.minutes) as minutes, " + NO_EVENT + ", "
+ NO_MOVE + " " + PLAYTIME_DAYS_NOT_HIDDEN + "and p.day = :arg group by p.appid";
private static final String LONGEST_DAY_SET = "select coalesce(e.appid, p.appid) as appid, sum(p.minutes) as minutes, "
+ NO_EVENT + ", " + NO_MOVE + " " + ENTRY_DAYS + "and p.day = :arg group by coalesce(e.appid, p.appid)";
private static final String FINISHED_YEAR_SET = "select ug.appid, " + NO_MINUTES + ", " + FINISHED_AT
+ " as event_at, " + NO_MOVE + " " + FINISHED_FROM + "and " + FINISHED_YEAR + " = :arg";
private static final String MOVES_SET = "select sc.appid, " + NO_MINUTES + ", sc.changed_at as event_at, "
@@ -113,13 +121,14 @@ public class UserStatsRepository {
Map.entry(StatKey.UNDER_ONE_HOUR, counted(UNDER_ONE_HOUR)),
Map.entry(StatKey.FINISHED_UNTRACKED, counted(UNTRACKED)),
Map.entry(StatKey.PILE_TIME_TO_BEAT, PILE_TTB_SET),
Map.entry(StatKey.MOST_PLAYED, "select ug.appid, " + NO_STAT + " " + MOST_PLAYED_FROM),
Map.entry(StatKey.MOST_PLAYED, "select ug.appid, ug.playtime_minutes as minutes, " + NO_EVENT + ", " + NO_MOVE
+ " " + MOST_PLAYED_FROM),
Map.entry(StatKey.GENRE, tagSet("genres")),
Map.entry(StatKey.MOOD, tagSet("moods")),
Map.entry(StatKey.PLATFORM, "select g.appid, " + NO_STAT + " " + PLATFORM_FROM
+ "and lower(g.name) = lower(trim(:arg))"),
Map.entry(StatKey.PERFECT, "select ug.appid, " + NO_STAT + " from listed_user_game ug where " + OF_USER
+ "and ug.appid in (:arg)"),
Map.entry(StatKey.PERFECT, "select distinct e.appid, " + NO_STAT + " from user_game_entry_member e "
+ "where e.user_id = :userId and e.member_appid in (:arg)"),
Map.entry(StatKey.LONGEST_DAY, LONGEST_DAY_SET),
Map.entry(StatKey.FINISHED_YEAR, FINISHED_YEAR_SET),
Map.entry(StatKey.TREND_ADDED, movedSet(ADDED)),
@@ -127,11 +136,13 @@ public class UserStatsRepository {
Map.entry(StatKey.TREND_MOVES, MOVES_SET));
/** The list order; a key not here goes by playtime. */
private static final Map<StatKey, String> ORDERS = Map.of(
StatKey.NEVER_STARTED, BY_NAME, StatKey.PILE, BY_NAME, StatKey.FINISHED_UNTRACKED, BY_NAME,
StatKey.PILE_TIME_TO_BEAT, BY_MINUTES, StatKey.LONGEST_DAY, BY_MINUTES,
StatKey.FINISHED_YEAR, BY_DATE, StatKey.TREND_ADDED, BY_DATE, StatKey.TREND_DONE, BY_DATE,
StatKey.TREND_MOVES, BY_DATE);
private static final Map<StatKey, String> ORDERS = Map.ofEntries(
Map.entry(StatKey.NEVER_STARTED, BY_NAME), Map.entry(StatKey.PILE, BY_NAME),
Map.entry(StatKey.FINISHED_UNTRACKED, BY_NAME), Map.entry(StatKey.PILE_TIME_TO_BEAT, BY_MINUTES),
Map.entry(StatKey.LONGEST_DAY, BY_MINUTES), Map.entry(StatKey.MOST_PLAYED, BY_MINUTES),
Map.entry(StatKey.GENRE, BY_MINUTES), Map.entry(StatKey.MOOD, BY_MINUTES),
Map.entry(StatKey.FINISHED_YEAR, BY_DATE), Map.entry(StatKey.TREND_ADDED, BY_DATE),
Map.entry(StatKey.TREND_DONE, BY_DATE), Map.entry(StatKey.TREND_MOVES, BY_DATE));
private static final RowMapper<UserStats.TagCount> TAG_COUNT =
(rs, i) -> new UserStats.TagCount(rs.getString("name"), rs.getLong("games"), rs.getLong("minutes"));
@@ -175,17 +186,18 @@ public class UserStatsRepository {
}
/**
* The listed game with playtime in the most months; more minutes break a tie.
* The listed entry with playtime in the most months, all editions of its set together; more minutes break a tie.
*/
public Optional<UserStats.LoyalGame> mostLoyal(long userId) {
return jdbc.query("""
select l.appid, sg.name, sg.image_url, l.months, l.minutes
from (select p.appid, count(distinct date_trunc('month', p.day)) as months, sum(p.minutes) as minutes
%s group by p.appid) l
from (select coalesce(e.appid, p.appid) as appid, count(distinct date_trunc('month', p.day)) as months,
sum(p.minutes) as minutes
%s group by coalesce(e.appid, p.appid)) l
join listed_user_game ug on ug.user_id = :userId and ug.appid = l.appid
join steam_game sg on sg.appid = l.appid
order by l.months desc, l.minutes desc, l.appid limit 1
""".formatted(PLAYTIME_DAYS_NOT_HIDDEN), user(userId),
""".formatted(ENTRY_DAYS), user(userId),
(rs, i) -> new UserStats.LoyalGame(rs.getLong("appid"), rs.getString("name"),
rs.getString("image_url"), rs.getLong("months"), rs.getLong("minutes")))
.stream().findFirst();
@@ -201,13 +213,14 @@ public class UserStatsRepository {
private static String tagFrom(String column) {
return """
from listed_user_game ug join game_mood m on m.appid = ug.appid cross join unnest(m.%s) as tag(name)
where %s""".formatted(column, OF_USER);
from counted_user_game ug join %s t on t.appid = ug.appid
join game_mood m on m.appid = ug.appid cross join unnest(m.%s) as tag(name)
where %s""".formatted(ENTRY_PLAYTIME, column, OF_USER);
}
private List<UserStats.TagCount> tagCounts(long userId, String column, int limit) {
return jdbc.query("""
select tag.name, count(*) as games, coalesce(sum(ug.playtime_minutes), 0) as minutes
select tag.name, count(*) as games, coalesce(sum(t.playtime_minutes), 0) as minutes
%s
group by tag.name order by minutes desc, games desc, tag.name limit :limit
""".formatted(tagFrom(column)), user(userId).addValue("limit", limit), TAG_COUNT);
@@ -285,12 +298,13 @@ public class UserStatsRepository {
*/
public List<UserStats.StatGame> games(long userId, StatKey key, Object arg, ZoneId zone, int offset, int limit) {
return jdbc.query("""
select s.appid, sg.name, sg.image_url, ug.status, coalesce(ug.playtime_minutes, 0) as playtime,
select s.appid, sg.name, sg.image_url, ug.status, coalesce(ep.playtime_minutes, ug.playtime_minutes, 0) as playtime,
s.minutes, s.event_at, s.from_status, s.to_status,
cg.appid is not null as coming, cg.release_date as coming_on
from (%s) s left join steam_game sg on sg.appid = s.appid
left join coming_game cg on cg.appid = s.appid
left join user_game ug on ug.user_id = :userId and ug.appid = s.appid
left join user_game_entry_playtime ep on ep.user_id = :userId and ep.appid = s.appid
order by %s, s.appid limit :limit offset :offset
""".formatted(SETS.get(key), ORDERS.getOrDefault(key, BY_PLAYTIME)),
gameParams(userId, arg, zone).addValue("limit", limit).addValue("offset", offset),
@@ -312,7 +326,8 @@ public class UserStatsRepository {
}
private static String tagSet(String column) {
return "select ug.appid, " + NO_STAT + " " + tagFrom(column) + " and tag.name = :arg";
return "select ug.appid, t.playtime_minutes as minutes, " + NO_EVENT + ", " + NO_MOVE + " " + tagFrom(column)
+ " and tag.name = :arg";
}
/** A game once, with its last change. */
@@ -65,6 +65,14 @@ public class BacklogService {
});
}
/** Sets only the rating, like a {@link #patch} that sends nothing else. */
@Transactional
public Optional<BacklogEntryDto> rate(Long userId, Long appid, int rating) {
BacklogPatch patch = new BacklogPatch();
patch.setRating(Optional.of(rating));
return patch(userId, appid, patch);
}
/**
* Sets the user's backlog order. Appids not in the backlog are ignored; backlog games not sent go after the sent
* ones in their old order.
@@ -6,19 +6,23 @@ import core_service.modules.library.api.dto.CorrectionMarkDto;
import core_service.modules.library.api.dto.EditionDto;
import core_service.modules.library.api.dto.FinishedHintDto;
import core_service.modules.library.api.dto.GameDetailDto;
import core_service.modules.library.api.dto.PlatformPlayDto;
import core_service.modules.library.domain.AchievementKind;
import core_service.modules.library.domain.AchievementProgress;
import core_service.modules.library.domain.ComingGame;
import core_service.modules.library.domain.Edition;
import core_service.modules.library.domain.GameAchievementSchema;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.FinishedHint;
import core_service.modules.library.domain.GameMoods;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.PlatformPlay;
import core_service.modules.library.domain.PsnTrophy;
import core_service.modules.library.domain.PsnTrophySetProgress;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameSpeedrun;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.TitlePlay;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievement;
import core_service.modules.library.domain.UserGameAchievementMeta;
@@ -32,6 +36,7 @@ import core_service.modules.library.repo.GameMoodsRepository;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.GameAchievementSchemaRepository;
import core_service.modules.library.repo.GameSpeedrunRepository;
import core_service.modules.library.repo.PsnTitleRepository;
import core_service.modules.library.repo.PsnTrophyRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
@@ -81,6 +86,7 @@ public class GameDetailService {
private final GameLinkRepository gameLinkRepository;
private final GameMergeRepository mergeRepository;
private final PsnTrophyRepository psnTrophyRepository;
private final PsnTitleRepository psnTitleRepository;
@Value("${app.library.achievement-ttl-hours:1}")
private long achievementTtlHours;
@@ -207,7 +213,7 @@ public class GameDetailService {
return psnTrophyRepository.findSets(userGame.getUserId(), userGame.getAppid()).stream()
.map(set -> new AchievementSetDto(set.id(), AchievementKind.PSN, set.platform(), set.name(), set.total(),
AchievementProgress.capped(set.earned(), set.total()),
set.platinumEarned() || set.earned() >= set.total(), true, set.listed()))
set.perfect(), true, set.listed()))
.toList();
}
@@ -241,6 +247,8 @@ public class GameDetailService {
.orElse(null);
Optional<ComingGame> coming = comingGameRepository.findById(userGame.getAppid());
boolean hasAchievements = total > 0 || !psnSets.isEmpty();
List<Edition> linked = gameLinkRepository.linkedEditions(userGame.getUserId(), userGame.getAppid());
List<PlatformPlay> plays = platformPlays(userGame, linked);
List<AchievementSetDto> sets = new ArrayList<>();
if (total > 0) {
sets.add(new AchievementSetDto("steam", AchievementKind.STEAM, null, null, total, unlocked,
@@ -284,12 +292,76 @@ public class GameDetailService {
gameCorrectionRepository.findByAppidOrderByField(userGame.getAppid()).stream()
.map(c -> new CorrectionMarkDto(c.getField(), c.getCorrectedAt())).toList(),
gameLinkRepository.setOf(userGame.getAppid()).isPresent(),
gameLinkRepository.linkedEditions(userGame.getUserId(), userGame.getAppid()).stream()
linked.stream()
.map(e -> new EditionDto(e.appid(), e.name(), e.platform(), e.source(), e.status(), e.imageUrl()))
.toList(),
coming.isPresent(),
coming.map(ComingGame::getReleaseDate).orElse(null),
sets
sets,
plays.stream().map(line -> toDto(line, userGame, total, unlocked, statsVisible)).toList(),
PlatformPlay.total(plays)
);
}
// A hidden game shows only itself.
private List<PlatformPlay> platformPlays(UserGame userGame, List<Edition> linked) {
Long userId = userGame.getUserId();
List<UserGame> rows = new ArrayList<>(List.of(userGame));
if (userGame.getHiddenAt() == null) {
rows.addAll(userGameRepository.findAllById(
linked.stream().map(e -> new UserGameId(userId, e.appid())).toList()));
}
List<TitlePlay> titles = psnTitleRepository.findPlays(userId, rows.stream().map(UserGame::getAppid).toList());
return PlatformPlay.lines(rows, titles);
}
// total, unlocked and statsVisible are the current game's Steam values, already read.
private PlatformPlayDto toDto(PlatformPlay line, UserGame current, int total, int unlocked, boolean statsVisible) {
Long userId = current.getUserId();
Progress progress = switch (line.source()) {
case STEAM -> line.appid() == current.getAppid()
? steamProgress(total, unlocked, statsVisible) : steamProgress(userId, line.appid());
case PSN -> psnProgress(userId, line);
case MANUAL -> Progress.NONE;
};
return new PlatformPlayDto(line.source(), line.platform(), line.appid(), line.playtimeMinutes(),
line.lastPlayedAt(), progress.unlocked(), progress.total(), progress.perfect());
}
/**
* @param unlocked null when Steam hides the stats
*/
private record Progress(Integer unlocked, Integer total, boolean perfect) {
static final Progress NONE = new Progress(null, null, false);
}
private static Progress steamProgress(int total, int unlocked, boolean statsVisible) {
if (total <= 0) {
return Progress.NONE;
}
return new Progress(statsVisible ? unlocked : null, total, statsVisible && unlocked >= total);
}
private Progress steamProgress(Long userId, long appid) {
GameAchievementSchemaMeta schema = schemaMetaRepository.findById(appid).orElse(null);
if (schema == null || !schema.isHasSchema() || schema.getAchievementsTotal() <= 0) {
return Progress.NONE;
}
int total = schema.getAchievementsTotal();
UserGameAchievementMeta meta = userAchievementMetaRepository.findByUserIdAndAppid(userId, appid).orElse(null);
return steamProgress(total, meta == null ? 0 : AchievementProgress.capped(meta.getAchievementsUnlocked(), total),
meta == null || meta.isStatsVisible());
}
// The sets of the line's platform; every set of the key when the line has none.
private Progress psnProgress(Long userId, PlatformPlay line) {
List<PsnTrophySetProgress> sets = psnTrophyRepository.findSets(userId, line.appid()).stream()
.filter(set -> line.platform() == null || line.platform().equals(set.platform())).toList();
if (sets.isEmpty()) {
return Progress.NONE;
}
return new Progress(sets.stream().mapToInt(set -> AchievementProgress.capped(set.earned(), set.total())).sum(),
sets.stream().mapToInt(PsnTrophySetProgress::total).sum(),
sets.stream().allMatch(PsnTrophySetProgress::perfect));
}
}
@@ -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)));
}
}
@@ -9,12 +9,14 @@ import core_service.modules.library.api.dto.PickReasonDto;
import core_service.modules.library.api.dto.PickedGameDto;
import core_service.modules.library.api.dto.PickerCategoryDto;
import core_service.modules.library.api.dto.PickerDto;
import core_service.modules.library.api.dto.PlatformDto;
import core_service.modules.library.api.dto.PublicLibraryDto;
import core_service.modules.library.api.dto.RecentGameDto;
import core_service.modules.library.api.dto.WishlistDto;
import core_service.modules.library.domain.AchievementProgress;
import core_service.modules.library.domain.ComingGame;
import core_service.modules.library.domain.DayMinutes;
import core_service.modules.library.domain.EntryPlatform;
import core_service.modules.library.domain.FriendPlay;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.GameSource;
@@ -23,7 +25,9 @@ import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.Mood;
import core_service.modules.library.domain.PickSource;
import core_service.modules.library.domain.ProfileSection;
import core_service.modules.library.domain.RecentEntry;
import core_service.modules.library.domain.StatGroup;
import core_service.modules.library.domain.StatKey;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievementMeta;
@@ -40,6 +44,7 @@ import core_service.modules.library.repo.PsnTrophyRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.repo.UserStatsRepository;
import core_service.modules.library.repo.UserSteamSyncRepository;
import core_service.modules.library.repo.WishlistGameRepository;
import core_service.shared.security.Public;
@@ -52,6 +57,7 @@ import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.JpaSort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -61,6 +67,7 @@ import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
@@ -95,6 +102,7 @@ public class LibraryQueryService {
private final GameAchievementSchemaMetaRepository schemaMetaRepository;
private final UserGameAchievementMetaRepository userAchievementMetaRepository;
private final UserStatsService userStatsService;
private final UserStatsRepository userStatsRepository;
private final MoodService moodService;
private final ReleaseDateService releaseDateService;
private final SpeedrunService speedrunService;
@@ -110,12 +118,18 @@ public class LibraryQueryService {
return userGameRepository.countListed(userId);
}
public List<GameSource> listSources(Long userId) {
return userGameRepository.findSources(userId).stream().sorted().toList();
}
public Page<GameDto> listOwnedGames(Long userId, boolean hidden, GameStatus status, String nameQuery,
Integer maxMinutes, boolean hideDuplicates, Pageable pageable) {
Integer maxMinutes, Collection<GameSource> sources,
Pageable pageable) {
Pageable stable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(),
UserGameRepository.querySort(pageable.getSort()));
Page<UserGame> page = userGameRepository.findOwnedGames(
userId, hidden, status, UserGameRepository.containsPattern(nameQuery), maxMinutes, hideDuplicates, stable);
userId, hidden, status, UserGameRepository.containsPattern(nameQuery), maxMinutes,
sources == null || sources.isEmpty() ? EnumSet.allOf(GameSource.class) : sources, stable);
List<GameDto> games = toDtos(page.getContent());
return new PageImpl<>(games, page.getPageable(), page.getTotalElements());
}
@@ -274,6 +288,8 @@ public class LibraryQueryService {
.findByUserIdAndAppidIn(games.getFirst().getUserId(), appids).stream()
.collect(Collectors.toMap(UserGameAchievementMeta::getAppid, m -> m));
Map<Long, Integer> editions = gameLinkRepository.editionCounts(games.getFirst().getUserId(), appids);
Map<Long, List<EntryPlatform>> platforms = gameLinkRepository.entryPlatforms(games.getFirst().getUserId(), appids);
Map<Long, Integer> totals = gameLinkRepository.entryPlaytimes(games.getFirst().getUserId(), appids);
Map<Long, ComingGame> coming = comingOf(appids);
Map<Long, AchievementProgress> trophies = AchievementProgress
.perGame(psnTrophyRepository.findProgress(games.getFirst().getUserId())).stream()
@@ -281,7 +297,8 @@ public class LibraryQueryService {
return games.stream()
.map(ug -> toDto(ug, meta.get(ug.getAppid()), times.get(ug.getAppid()),
schemas.get(ug.getAppid()), unlocks.get(ug.getAppid()), editions.get(ug.getAppid()),
coming.get(ug.getAppid()), trophies.get(ug.getAppid())))
platforms.getOrDefault(ug.getAppid(), List.of()), coming.get(ug.getAppid()),
trophies.get(ug.getAppid()), totals.getOrDefault(ug.getAppid(), ug.getPlaytimeMinutes())))
.toList();
}
@@ -305,8 +322,8 @@ public class LibraryQueryService {
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
return new AchievementSummaryDto(
started.stream().mapToInt(AchievementProgress::unlockedCapped).sum(),
perGame.size(),
AchievementProgress.perfectAppids(started).size(),
entries(userId, perGame.stream().map(AchievementProgress::appid).toList()),
entries(userId, AchievementProgress.perfectAppids(started)),
averageCompletion(perGame),
(int) games.stream().filter(g -> !g.known()).count(),
(int) games.stream().filter(g -> g.hasAchievements() && g.unlocked() != null && !g.visible()).count(),
@@ -318,6 +335,11 @@ public class LibraryQueryService {
.toList());
}
// Library entries of the given games: an entry whose editions are all in the list counts once.
private int entries(Long userId, List<Long> appids) {
return appids.isEmpty() ? 0 : (int) userStatsRepository.countGames(userId, StatKey.PERFECT, appids, zone);
}
// Steam achievements and PlayStation trophies; one entry per set.
private List<AchievementProgress> progress(Long userId) {
return Stream.concat(userGameRepository.findAchievementProgress(userId).stream(),
@@ -357,16 +379,16 @@ public class LibraryQueryService {
.toList();
long total = perDay.stream().mapToLong(DayMinutes::minutes).sum();
List<UserGame> recent = userGameRepository.findListedPlayedInTwoWeeks(userId, PageRequest.of(0, RECENT_GAMES));
Map<Long, SteamGame> meta = steamGameRepository.findAllById(recent.stream().map(UserGame::getAppid).toList())
List<RecentEntry> recent = userGameRepository.findEntriesPlayedInTwoWeeks(userId, RECENT_GAMES);
Map<Long, SteamGame> meta = steamGameRepository.findAllById(recent.stream().map(RecentEntry::getAppid).toList())
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
List<RecentGameDto> recentGames = recent.stream()
.map(ug -> new RecentGameDto(
ug.getAppid(),
meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getName() : null,
ug.getPlaytimeMinutes(),
ug.getPlaytime2Weeks(),
ug.getStatus(),
ug.getPlaytimeMinutes().intValue(),
ug.getPlaytime2Weeks().intValue(),
GameStatus.valueOf(ug.getStatus()),
meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getImageUrl() : null))
.toList();
return new ActivityDto(perDay, total, recentGames);
@@ -384,7 +406,8 @@ public class LibraryQueryService {
? userGameRepository.countEveryStatus(userId) : null;
List<UserGame> playing = sections.contains(ProfileSection.PLAYING)
? userGameRepository.findListedByStatusIn(userId, List.of(GameStatus.PLAYING),
PageRequest.of(0, PUBLIC_PLAYING, Sort.by(Sort.Direction.DESC, "playtimeMinutes").and(Sort.by("appid"))))
PageRequest.of(0, PUBLIC_PLAYING, JpaSort.unsafe(Sort.Direction.DESC,
UserGameRepository.ENTRY_PLAYTIME_ORDER).and(Sort.by("appid"))))
: null;
List<UserGame> finished = sections.contains(ProfileSection.FINISHED)
? userGameRepository.findListedByStatusIn(userId, List.of(GameStatus.FINISHED, GameStatus.COMPLETED),
@@ -395,6 +418,7 @@ public class LibraryQueryService {
Map<Long, SteamGame> meta = steamGameRepository.findAllById(shownAppids)
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
Map<Long, ComingGame> coming = comingOf(shownAppids);
Map<Long, Integer> totals = gameLinkRepository.entryPlaytimes(userId, shownAppids);
List<AchievementProgress> started = summary ? AchievementProgress.started(progress(userId)) : null;
Set<StatGroup> shared = sections.contains(ProfileSection.STATS) ? StatGroup.shared(profileStats) : Set.of();
return new PublicLibraryDto(
@@ -402,8 +426,8 @@ public class LibraryQueryService {
summary ? userGameRepository.sumPlaytime(userId) : null,
summary ? byStatus.get(GameStatus.FINISHED) + byStatus.get(GameStatus.COMPLETED) : null,
sections.contains(ProfileSection.STATUS) ? byStatus : null,
toPublicGames(playing, meta, coming), toPublicGames(finished, meta, coming),
summary ? AchievementProgress.perfectAppids(started).size() : null,
toPublicGames(playing, meta, coming, totals), toPublicGames(finished, meta, coming, totals),
summary ? entries(userId, AchievementProgress.perfectAppids(started)) : null,
summary ? averageCompletion(AchievementProgress.perGame(started)) : null,
shared.isEmpty() ? null : userStatsService.getStats(userId, shared));
}
@@ -412,15 +436,16 @@ public class LibraryQueryService {
* @return null for a hidden section
*/
private static List<PublicLibraryDto.Game> toPublicGames(List<UserGame> games, Map<Long, SteamGame> meta,
Map<Long, ComingGame> coming) {
Map<Long, ComingGame> coming,
Map<Long, Integer> totals) {
if (games == null) {
return null;
}
return games.stream()
.map(ug -> new PublicLibraryDto.Game(ug.getAppid(),
meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getName() : null,
ug.getPlaytimeMinutes(), ug.getStatus(), ug.getRating(), ug.getPlatform(),
meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getImageUrl() : null,
totals.getOrDefault(ug.getAppid(), ug.getPlaytimeMinutes()), ug.getStatus(), ug.getRating(),
ug.getPlatform(), meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getImageUrl() : null,
coming.containsKey(ug.getAppid()),
coming.containsKey(ug.getAppid()) ? coming.get(ug.getAppid()).getReleaseDate() : null))
.toList();
@@ -435,7 +460,8 @@ public class LibraryQueryService {
private static GameDto toDto(UserGame ug, SteamGame meta, GameTimeToBeat time,
GameAchievementSchemaMeta schema, UserGameAchievementMeta unlocks,
Integer editions, ComingGame coming, AchievementProgress trophies) {
Integer editions, List<EntryPlatform> platforms, ComingGame coming,
AchievementProgress trophies, int totalPlaytimeMinutes) {
Integer achievementsTotal = ug.getSource() == GameSource.STEAM && schema != null && schema.isHasSchema()
&& schema.getAchievementsTotal() > 0 ? schema.getAchievementsTotal() : null;
Integer achievementsUnlocked = achievementsTotal != null && unlocks != null && unlocks.isStatsVisible()
@@ -464,7 +490,9 @@ public class LibraryQueryService {
ug.getHiddenAt() != null,
editions,
coming != null,
coming != null ? coming.getReleaseDate() : null);
coming != null ? coming.getReleaseDate() : null,
platforms.stream().map(p -> new PlatformDto(p.source(), p.platform())).toList(),
totalPlaytimeMinutes);
}
// The tile shows what counts for the game: its best set, Steam's on a tie.
@@ -310,9 +310,12 @@ public class LibrarySyncWorker {
if (existing == null || existing.getRemovedAt() != null) {
changes.add(SyncChange.added(appid));
}
Instant lastPlayed = g.rtimeLastPlayed() != null && g.rtimeLastPlayed() > 0
? Instant.ofEpochSecond(g.rtimeLastPlayed()) : null;
if (existing == null) {
UserGame created = new UserGame(userId, appid, playtime, now, now);
created.setPlaytime2Weeks(playtime2Weeks);
created.setLastPlayedAt(lastPlayed);
userGameRepository.save(created);
} else {
existing.setPlaytimeMinutes(playtime);
@@ -321,6 +324,9 @@ public class LibrarySyncWorker {
existing.setRemovedAt(null);
existing.setSource(GameSource.STEAM);
existing.setPlatform(null);
if (lastPlayed != null) {
existing.setLastPlayedAt(lastPlayed);
}
}
if (playedOn == null || takesOverManualGame) {
return false;
@@ -6,6 +6,7 @@ import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.IgdbGame;
import core_service.modules.library.domain.PsnTitle;
import core_service.modules.library.domain.SyncChange;
import core_service.modules.library.domain.TitlePlay;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.GameImportRepository;
@@ -112,6 +113,8 @@ public class PsnSyncStep {
}
}
saveReview(userId, review);
psnTitleRepository.savePlays(userId, lines.stream().map(line -> new TitlePlay(line.psnKey(), null,
line.platform(), line.playtimeMinutes(), lastPlayed.get(line.psnKey()))).toList());
int added = changes.size();
changes.addAll(trophyStep.run(userId, found.get().accessToken(), titles));
log.info("PSN sync user={} added {} titles, {} wait for review, {} games gained trophies", userId, added,
@@ -0,0 +1,91 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.OpenQuestion;
import core_service.modules.library.domain.Question;
import core_service.modules.library.domain.QuestionFacts;
import core_service.modules.library.repo.QuestionRepository;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.ZoneId;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
/** The question queue: games with a decision the user has not made yet. */
@Service
@RequiresRole(Role.USER)
@RequiredArgsConstructor
public class QuestionService {
public record Open(QuestionFacts game, OpenQuestion question) {}
public enum AnswerResult { DONE, NOT_FOUND, GONE }
/** @param next the open question of the game after the answer, for example Rate it? after Finished? */
public record Answered(AnswerResult result, Optional<Open> next) {}
private static final Comparator<Open> ORDER = Comparator
.comparing((Open open) -> open.question().question())
.thenComparing(open -> open.game().playtimeMinutes(), Comparator.reverseOrder())
.thenComparingLong(open -> open.game().appid());
private final QuestionRepository questionRepository;
private final BacklogService backlogService;
@Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone;
public List<Open> open(long userId) {
return questionRepository.facts(userId, null).stream()
.flatMap(facts -> toOpen(facts).stream())
.sorted(ORDER)
.toList();
}
public int count(long userId) {
return open(userId).size();
}
/**
* Not transactional on purpose: {@link BacklogService} commits its own change, so the read of the next question
* sees it. "Yes" writes like the detail page does; "No" snoozes the question.
*/
public Answered answer(long userId, long appid, Question question, boolean yes, Integer rating) {
Optional<QuestionFacts> facts = factsOf(userId, appid);
if (facts.isEmpty()) return new Answered(AnswerResult.NOT_FOUND, Optional.empty());
Optional<Open> current = toOpen(facts.get());
if (current.isEmpty() || current.get().question().question() != question) {
return new Answered(AnswerResult.GONE, Optional.empty());
}
if (yes) {
if (write(userId, appid, question, rating).isEmpty()) {
return new Answered(AnswerResult.NOT_FOUND, Optional.empty());
}
} else {
questionRepository.snooze(userId, appid, question, facts.get().playtimeMinutes());
}
return new Answered(AnswerResult.DONE, factsOf(userId, appid).flatMap(this::toOpen));
}
private Optional<?> write(long userId, long appid, Question question, Integer rating) {
return switch (question) {
case START -> backlogService.updateStatus(userId, appid, GameStatus.PLAYING);
case FINISHED -> backlogService.updateStatus(userId, appid, GameStatus.FINISHED);
case DROPPED -> backlogService.updateStatus(userId, appid, GameStatus.DROPPED);
case RATE -> backlogService.rate(userId, appid, rating);
};
}
private Optional<QuestionFacts> factsOf(long userId, long appid) {
return questionRepository.facts(userId, appid).stream().findFirst();
}
private Optional<Open> toOpen(QuestionFacts facts) {
return OpenQuestion.of(facts, Instant.now(), zone).map(question -> new Open(facts, question));
}
}
@@ -1,6 +1,7 @@
package core_service.modules.rewards.api;
import core_service.modules.rewards.api.dto.EquipRequest;
import core_service.modules.rewards.api.dto.GamePointsDto;
import core_service.modules.rewards.api.dto.PurchaseRequest;
import core_service.modules.rewards.api.dto.RewardGamesDto;
import core_service.modules.rewards.api.dto.WalletDto;
@@ -24,6 +25,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Optional;
@RestController
@@ -31,6 +33,8 @@ import java.util.Optional;
@RequiredArgsConstructor
public class RewardController {
private static final int MAX_EXPECTED = 200;
private final RewardService rewardService;
/**
@@ -41,6 +45,29 @@ public class RewardController {
return ResponseEntity.ok(WalletDto.of(rewardService.wallet(principal.getIdAsLong(), principal.role())));
}
/**
* Settles first, like the wallet.
*/
@GetMapping("/games/{appid}")
public ResponseEntity<?> getGamePoints(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long appid) {
return rewardService.gamePoints(principal.getIdAsLong(), appid)
.<ResponseEntity<?>>map(points -> ResponseEntity.ok(GamePointsDto.of(points)))
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("GAME_NOT_FOUND", "The game is not in your library")));
}
/**
* Read-only, no settle: the full finish value per listed game, 0 when it is paid already.
*/
@GetMapping("/expected")
public ResponseEntity<?> getExpected(@AuthenticationPrincipal UserPrincipal principal,
@RequestParam List<Long> appids) {
if (appids.size() > MAX_EXPECTED) {
return ResponseEntity.badRequest().body(new ErrorResponse("TOO_MANY", "At most " + MAX_EXPECTED + " games"));
}
return ResponseEntity.ok(rewardService.expectedFinish(principal.getIdAsLong(), appids));
}
@GetMapping("/progress/{track}/games")
public ResponseEntity<?> getProgressGames(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable Track track,
@@ -0,0 +1,32 @@
package core_service.modules.rewards.api.dto;
import core_service.modules.rewards.domain.NoPointsReason;
import core_service.modules.rewards.domain.PaidRow;
import core_service.modules.rewards.domain.RewardKind;
import core_service.modules.rewards.service.RewardService;
import java.time.Instant;
import java.util.List;
import java.util.Map;
/**
* @param paid the game rows of the game and the other editions of its set
* @param missing why a kind is not paid; STARTED, FINISHED (both finish kinds) and COMPLETED
*/
public record GamePointsDto(long balance, List<PaidDto> paid, Map<RewardKind, NoPointsReason> missing) {
/**
* @param appid the edition the row was paid for
* @param bookedNow written by this call
*/
public record PaidDto(RewardKind kind, long appid, int points, Instant paidAt, boolean bookedNow) {
static PaidDto of(PaidRow row) {
return new PaidDto(row.kind(), row.appid(), row.points(), row.paidAt(), row.bookedNow());
}
}
public static GamePointsDto of(RewardService.GamePoints points) {
return new GamePointsDto(points.balance(), points.paid().stream().map(PaidDto::of).toList(), points.missing());
}
}
@@ -11,6 +11,8 @@ import java.time.Instant;
* @param playedAround a playtime day within 30 days before the finish or any time after
* @param allAchievements all achievements of a game with some are unlocked
* @param finishedAt the first status change to FINISHED or COMPLETED
* @param earlyPaid the game or its set has a FINISHED_EARLY row
*/
public record FinishFacts(long appid, boolean manual, boolean completed, int playtimeMinutes, int playedSinceMinutes, Integer normallyMinutes,
boolean endingUnlocked, boolean playedAround, boolean allAchievements, Instant finishedAt) {}
boolean endingUnlocked, boolean playedAround, boolean allAchievements, Instant finishedAt,
boolean earlyPaid) {}
@@ -0,0 +1,13 @@
package core_service.modules.rewards.domain;
/**
* What the library says about one game of a user, to explain a missing payment.
*
* @param finished status FINISHED or COMPLETED
* @param completed status COMPLETED
* @param startLogged a BACKLOG to PLAYING change is in the status log
* @param finishLogged a change to FINISHED or COMPLETED is in the status log
* @param linkedTitle a PlayStation title is linked to the game, so its playtime days prove nothing
*/
public record GameRewardFacts(boolean manual, boolean finished, boolean completed, boolean hidden, boolean playstation,
boolean startLogged, boolean finishLogged, boolean linkedTitle) {}
@@ -0,0 +1,37 @@
package core_service.modules.rewards.domain;
/**
* Why a kind of game points is not paid. The Frontend maps a code to a text and holds no rule.
*/
public enum NoPointsReason {
/** The game never went from BACKLOG to PLAYING. The Frontend shows no line. */
NOT_STARTED,
/** The game is not FINISHED or COMPLETED. The Frontend shows no line. */
NOT_FINISHED,
/** The game is not COMPLETED. The Frontend shows no line. */
NOT_COMPLETED,
/** A hidden game pays nothing. */
HIDDEN,
/** A PlayStation game pays nothing. */
PLAYSTATION,
/** The game was finished before the status history began (starter points cover it). */
BEFORE_HISTORY,
/** A game added by hand has no playtime to prove a start or a completion. */
MANUAL_GAME,
/** A start needs one playtime day on or after the start. */
NO_PLAY_AFTER_START,
/** A PlayStation title is linked to the game: its play mixes in, so only the Steam ending achievement proves. */
PLAY_NOT_COUNTED,
/** A Steam finish needs playtime. */
NO_PLAYTIME,
/** A Steam finish needs proof of fresh play. */
NO_PROOF,
/** Three manual finishes are paid in that week already. */
WEEKLY_LIMIT,
/** The weekly cap is used up, so no early points; proof still pays the full finish. */
WEEKLY_LIMIT_NO_PROOF,
/** COMPLETED needs a FINISHED or FINISHED_TOPUP row first. */
NEEDS_PROVEN_FINISH,
/** COMPLETED needs all achievements. */
NOT_ALL_ACHIEVEMENTS
}
@@ -0,0 +1,9 @@
package core_service.modules.rewards.domain;
import java.time.Instant;
/**
* @param paidAt when the row was written
* @param bookedNow the row was written in the current transaction
*/
public record PaidRow(RewardKind kind, long appid, int points, Instant paidAt, boolean bookedNow) {}
@@ -3,7 +3,16 @@ package core_service.modules.rewards.domain;
import java.util.List;
public enum RewardKind {
STARTED, FINISHED, FINISHED_MANUAL, COMPLETED, MONTH, STREAK, STARTER, BUY,
STARTED, FINISHED, FINISHED_MANUAL,
/**
* 10 points for a Steam finish without proof yet; ref = appid.
*/
FINISHED_EARLY,
/**
* The rest up to the full finish once proof arrives; ref = appid.
*/
FINISHED_TOPUP,
COMPLETED, MONTH, STREAK, STARTER, BUY,
/**
* An unlock-only item reached by progress: ref = item code, points 0. Not in {@link #GAME_KINDS}, so a merge never
* moves it.
@@ -13,10 +22,16 @@ public enum RewardKind {
/**
* The kinds whose ref is a game (an appid).
*/
public static final List<RewardKind> GAME_KINDS = List.of(STARTED, FINISHED, FINISHED_MANUAL, COMPLETED);
public static final List<RewardKind> GAME_KINDS = List.of(STARTED, FINISHED, FINISHED_MANUAL, FINISHED_EARLY, FINISHED_TOPUP,
COMPLETED);
/**
* The kinds that count as "finished a game" for the weekly streak.
*/
public static final List<RewardKind> FINISH_KINDS = List.of(FINISHED, FINISHED_MANUAL);
public static final List<RewardKind> FINISH_KINDS = List.of(FINISHED, FINISHED_MANUAL, FINISHED_EARLY);
/**
* The finish kinds that share the weekly cap: a manual finish and an unproven Steam finish.
*/
public static final List<RewardKind> CAPPED_KINDS = List.of(FINISHED_MANUAL, FINISHED_EARLY);
}
@@ -1,6 +1,7 @@
package core_service.modules.rewards.domain;
import java.time.DateTimeException;
import java.time.Instant;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.temporal.ChronoField;
@@ -20,6 +21,7 @@ public final class RewardRules {
public static final int MID_HOURS = 10;
public static final int LONG_HOURS = 30;
public static final int COMPLETED_BONUS = 40;
public static final int EARLY_FINISH = 10;
public static final int MANUAL_FINISH = 15;
public static final int MANUAL_PER_WEEK = 3;
public static final int MONTH_BONUS = 40;
@@ -47,19 +49,57 @@ public final class RewardRules {
}
/**
* A game added by hand pays {@link #MANUAL_FINISH} (the weekly cap is the caller's job). A Steam game pays by its
* length and needs playtime plus one proof: the ending achievement, playtime around the finish, or playtime of
* at least {@link #PROOF_SHARE} of the normal time.
* A game added by hand pays {@link #MANUAL_FINISH}. A Steam game pays by its length once it has playtime plus one
* proof: the ending achievement, playtime around the finish, or playtime of at least {@link #PROOF_SHARE} of the
* normal time. Without proof it pays {@link #EARLY_FINISH} now, and once proof arrives the rest as a top-up. The
* weekly cap of {@link RewardKind#CAPPED_KINDS} is the caller's job. The top-up assumes the early row paid
* {@link #EARLY_FINISH}. A finish before earlySince gets no early row and pays only with proof.
*/
public static Optional<RewardEntry> finish(FinishFacts f) {
public static Optional<RewardEntry> finish(FinishFacts f, Instant earlySince) {
String ref = String.valueOf(f.appid());
if (f.manual()) {
return Optional.of(new RewardEntry(RewardKind.FINISHED_MANUAL, ref, MANUAL_FINISH, f.finishedAt()));
}
if (f.playtimeMinutes() <= 0 || !hasProof(f)) {
return Optional.empty();
boolean proven = whyNoFinish(f).isEmpty();
int full = finishPoints(f.normallyMinutes());
if (f.earlyPaid()) {
return proven ? Optional.of(new RewardEntry(RewardKind.FINISHED_TOPUP, ref, full - EARLY_FINISH, f.finishedAt()))
: Optional.empty();
}
return Optional.of(new RewardEntry(RewardKind.FINISHED, ref, finishPoints(f.normallyMinutes()), f.finishedAt()));
if (proven) {
return Optional.of(new RewardEntry(RewardKind.FINISHED, ref, full, f.finishedAt()));
}
return earlyAllowed(f, earlySince)
? Optional.of(new RewardEntry(RewardKind.FINISHED_EARLY, ref, EARLY_FINISH, f.finishedAt()))
: Optional.empty();
}
private static boolean earlyAllowed(FinishFacts f, Instant earlySince) {
return !f.finishedAt().isBefore(earlySince);
}
/**
* True when the finish would use the weekly cap: a manual finish, or an unproven Steam finish that may pay early.
*/
public static boolean capped(FinishFacts f, Instant earlySince) {
return f.manual() || (whyNoFinish(f).isPresent() && earlyAllowed(f, earlySince));
}
/**
* The full finish value of a game: what its finish pays in all, early row and top-up together.
*/
public static int expectedFinish(boolean manual, Integer normallyMinutes) {
return manual ? MANUAL_FINISH : finishPoints(normallyMinutes);
}
/**
* Why a Steam finish pays nothing; empty when it pays.
*/
public static Optional<NoPointsReason> whyNoFinish(FinishFacts f) {
if (f.playtimeMinutes() <= 0) {
return Optional.of(NoPointsReason.NO_PLAYTIME);
}
return hasProof(f) ? Optional.empty() : Optional.of(NoPointsReason.NO_PROOF);
}
private static boolean hasProof(FinishFacts f) {
@@ -81,6 +121,92 @@ public final class RewardRules {
return Optional.of(new RewardEntry(RewardKind.COMPLETED, String.valueOf(f.appid()), COMPLETED_BONUS, f.finishedAt()));
}
/**
* @return why nothing is paid for the game at all, null when nothing blocks it
*/
public static NoPointsReason blocked(GameRewardFacts g) {
if (g.hidden()) {
return NoPointsReason.HIDDEN;
}
return g.playstation() ? NoPointsReason.PLAYSTATION : null;
}
/**
* @param s the start candidate of the game, null when it is none
* @return why no STARTED row is paid, null when nothing stops it
*/
public static NoPointsReason noStartReason(GameRewardFacts g, StartFacts s) {
if (!g.startLogged()) {
return NoPointsReason.NOT_STARTED;
}
if (g.manual()) {
return NoPointsReason.MANUAL_GAME;
}
if (s == null || s.playedAfter()) {
return null;
}
return g.linkedTitle() ? NoPointsReason.PLAY_NOT_COUNTED : NoPointsReason.NO_PLAY_AFTER_START;
}
/**
* @param f the finish candidate of the game, null when it is none
* @param weekFull the weekly cap is used up in the week of the finish
* @return why no finish row is paid, null when nothing stops it
*/
public static NoPointsReason noFinishReason(GameRewardFacts g, FinishFacts f, boolean weekFull, Instant earlySince) {
if (!g.finished()) {
return NoPointsReason.NOT_FINISHED;
}
if (!g.finishLogged()) {
return NoPointsReason.BEFORE_HISTORY;
}
if (f == null) {
return null;
}
if (f.manual()) {
return weekFull ? NoPointsReason.WEEKLY_LIMIT : null;
}
Optional<NoPointsReason> unproven = whyNoFinish(f);
if (unproven.isEmpty()) {
return null;
}
if (!earlyAllowed(f, earlySince)) {
return playCounts(g, unproven.get());
}
return weekFull ? NoPointsReason.WEEKLY_LIMIT_NO_PROOF : null;
}
/**
* @param f the finish candidate of the game, null when it is none
* @return why the rest of an early finish is not paid yet, null when nothing stops it
*/
public static NoPointsReason noTopUpReason(GameRewardFacts g, FinishFacts f) {
return f == null ? null : whyNoFinish(f).map(reason -> playCounts(g, reason)).orElse(null);
}
// Play cannot fix a missing proof when a linked PlayStation title mixes its minutes in.
private static NoPointsReason playCounts(GameRewardFacts g, NoPointsReason reason) {
return reason == NoPointsReason.NO_PROOF && g.linkedTitle() ? NoPointsReason.PLAY_NOT_COUNTED : reason;
}
/**
* @param c the completed candidate of the game, null when it is none
* @param provenFinishPaid a FINISHED or FINISHED_TOPUP row is paid for the game or its set
* @return why no COMPLETED row is paid, null when nothing stops it
*/
public static NoPointsReason noCompletedReason(GameRewardFacts g, FinishFacts c, boolean provenFinishPaid) {
if (!g.completed()) {
return NoPointsReason.NOT_COMPLETED;
}
if (g.manual()) {
return NoPointsReason.MANUAL_GAME;
}
if (!provenFinishPaid) {
return NoPointsReason.NEEDS_PROVEN_FINISH;
}
return c != null && !c.allAchievements() ? NoPointsReason.NOT_ALL_ACHIEVEMENTS : null;
}
public static String monthRef(YearMonth month) {
return month.toString();
}
@@ -1,6 +1,9 @@
package core_service.modules.rewards.repo;
import core_service.modules.rewards.domain.FinishFacts;
import core_service.modules.rewards.domain.GameRewardFacts;
import core_service.modules.rewards.domain.PaidRow;
import core_service.modules.rewards.domain.RewardKind;
import core_service.modules.rewards.domain.RewardRules;
import core_service.modules.rewards.domain.StartFacts;
import lombok.RequiredArgsConstructor;
@@ -12,16 +15,18 @@ import org.springframework.stereotype.Repository;
import java.sql.Date;
import java.time.YearMonth;
import java.time.ZoneId;
import java.util.Collection;
import java.util.HashMap;
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
* library module owns the entities and a module may not depend on another. Rewards keep Steam-only proof: PlayStation
* rows (their playtime is PlayStation's number, and a title can be linked to a game later) give no facts.
* library module owns the entities and a module may not depend on another. PlayStation rows give no
* facts. A PlayStation title linked to a game does not block it, but its playtime days never prove a finish.
*/
@Repository
@RequiredArgsConstructor
@@ -30,8 +35,39 @@ public class RewardFactsRepository {
private static final String NOT_DELETED_OR_BLOCKED =
"user_id not in (select user_id from deleted_user) and user_id not in (select user_id from blocked_user)";
// The playtime days of a game with a linked PlayStation title can be PlayStation's: they never prove anything.
private static final String NO_PSN_DAYS = """
and not exists (select 1 from user_psn_title t where t.user_id = d.user_id and t.appid = d.appid
and t.state = 'LINKED')""";
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,24 +81,32 @@ 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 startCandidates(userId, zone, null);
}
/**
* @param appid only this game, null for all
*/
public List<StartFacts> startCandidates(long userId, ZoneId zone, Long appid) {
return jdbc.query("""
select c.appid, ug.source = 'MANUAL' as manual, c.started_at,
exists (select 1 from user_game_playtime_day d where d.user_id = c.user_id and d.appid = c.appid
and d.day >= (c.started_at at time zone :zone)::date) as played_after
and d.day >= (c.started_at at time zone :zone)::date %2$s) as played_after
from (select user_id, appid, min(changed_at) as started_at from user_game_status_change
where user_id = :userId and from_status = 'BACKLOG' and to_status = 'PLAYING'
and (cast(:appid as bigint) is null or appid = :appid)
group by user_id, appid) c
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid 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')
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'"), NO_PSN_DAYS),
new MapSqlParameterSource("userId", userId).addValue("zone", zone.getId()).addValue("appid", appid),
(rs, i) -> new StartFacts(rs.getLong("appid"), rs.getBoolean("manual"),
rs.getTimestamp("started_at").toInstant(), rs.getBoolean("played_after")));
}
@@ -71,9 +115,17 @@ 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 finishCandidates(userId, zone, null);
}
/**
* @param appid only this game, null for all
*/
public List<FinishFacts> finishCandidates(long userId, ZoneId zone, Long appid) {
return jdbc.query("""
select c.appid, ug.source = 'MANUAL' as manual, ug.status = 'COMPLETED' as completed,
ug.playtime_minutes, t.normally_minutes,
@@ -81,57 +133,141 @@ public class RewardFactsRepository {
as ending_unlocked,
(select coalesce(sum(d.minutes), 0) from user_game_playtime_day d
where d.user_id = c.user_id and d.appid = c.appid
and d.day >= (c.finished_at at time zone :zone)::date) as played_since,
and d.day >= (c.finished_at at time zone :zone)::date %2$s) as played_since,
exists (select 1 from user_game_playtime_day d where d.user_id = c.user_id and d.appid = c.appid
and d.day >= (c.finished_at at time zone :zone)::date - :aroundDays) as played_around,
and d.day >= (c.finished_at at time zone :zone)::date - :aroundDays %2$s)
as played_around,
coalesce(s.achievements_total > 0 and m.achievements_unlocked >= s.achievements_total, false)
as all_achievements,
c.finished_at
c.finished_at, %3$s as early_paid
from (select user_id, appid, min(changed_at) as finished_at from user_game_status_change
where user_id = :userId and to_status in ('FINISHED', 'COMPLETED')
and (cast(:appid as bigint) is null or appid = :appid)
group by user_id, appid) c
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid and ug.removed_at is null
and ug.status in ('FINISHED', 'COMPLETED') 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 user_psn_title t where t.user_id = ug.user_id and t.appid = ug.appid
and t.state = 'LINKED')
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())
.addValue("aroundDays", RewardRules.PLAYED_AROUND_DAYS),
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', 'FINISHED_TOPUP'"), NO_PSN_DAYS,
paidInSet("c", "'FINISHED_EARLY'")),
new MapSqlParameterSource("userId", userId).addValue("zone", zone.getId())
.addValue("aroundDays", RewardRules.PLAYED_AROUND_DAYS).addValue("appid", appid),
(rs, i) -> new FinishFacts(rs.getLong("appid"), rs.getBoolean("manual"), rs.getBoolean("completed"),
rs.getInt("playtime_minutes"), rs.getInt("played_since"),
rs.getObject("normally_minutes", Integer.class),
rs.getBoolean("ending_unlocked"), rs.getBoolean("played_around"),
rs.getBoolean("all_achievements"), rs.getTimestamp("finished_at").toInstant()));
rs.getBoolean("all_achievements"), rs.getTimestamp("finished_at").toInstant(),
rs.getBoolean("early_paid")));
}
/**
* 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 completedCandidates(userId, null);
}
/**
* @param appid only this game, null for all
*/
public List<FinishFacts> completedCandidates(long userId, Long appid) {
return jdbc.query("""
select ug.appid, ug.playtime_minutes, f.event_at,
coalesce(s.achievements_total > 0 and m.achievements_unlocked >= s.achievements_total, false)
as all_achievements
from user_game ug
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 in ('FINISHED', 'FINISHED_TOPUP')
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 (cast(:appid as bigint) is null or ug.appid = :appid)
and not %1$s
order by f.event_at, ug.appid
""".formatted(paidInSet("ug", "'COMPLETED'")),
new MapSqlParameterSource("userId", userId).addValue("appid", appid),
(rs, i) -> new FinishFacts(rs.getLong("appid"), false, true, rs.getInt("playtime_minutes"), 0, null,
false, false, rs.getBoolean("all_achievements"), rs.getTimestamp("event_at").toInstant()));
false, false, rs.getBoolean("all_achievements"), rs.getTimestamp("event_at").toInstant(),
false));
}
/**
* The flags that explain a missing payment of one game; empty when the user has no such game.
*/
public Optional<GameRewardFacts> game(long userId, long appid) {
return jdbc.query("""
select ug.source = 'MANUAL' as manual, ug.status in ('FINISHED', 'COMPLETED') as finished,
ug.status = 'COMPLETED' as completed, ug.hidden_at is not null as hidden,
ug.source = 'PSN' as playstation,
exists (select 1 from user_game_status_change c where c.user_id = ug.user_id
and c.appid = ug.appid and c.from_status = 'BACKLOG' and c.to_status = 'PLAYING')
as start_logged,
exists (select 1 from user_game_status_change c where c.user_id = ug.user_id
and c.appid = ug.appid and c.to_status in ('FINISHED', 'COMPLETED')) as finish_logged,
exists (select 1 from user_psn_title t where t.user_id = ug.user_id and t.appid = ug.appid
and t.state = 'LINKED') as linked_title
from user_game ug
where ug.user_id = :userId and ug.appid = :appid and ug.removed_at is null
""", new MapSqlParameterSource("userId", userId).addValue("appid", appid),
(rs, i) -> new GameRewardFacts(rs.getBoolean("manual"), rs.getBoolean("finished"),
rs.getBoolean("completed"), rs.getBoolean("hidden"), rs.getBoolean("playstation"),
rs.getBoolean("start_logged"), rs.getBoolean("finish_logged"), rs.getBoolean("linked_title")))
.stream().findFirst();
}
/**
* The full finish value of each listed game of the user that can pay (listed, not hidden, not PlayStation): 0 when
* the game or its set has a finish row. Other games are left out.
*/
public Map<Long, Integer> expectedFinish(long userId, Collection<Long> appids) {
if (appids.isEmpty()) {
return Map.of();
}
Map<Long, Integer> result = new HashMap<>();
jdbc.query("""
select ug.appid, ug.source = 'MANUAL' as manual, t.normally_minutes, %s as paid
from user_game ug
left join game_time_to_beat t on t.appid = ug.appid
left join user_game_set st on st.user_id = ug.user_id and st.appid = ug.appid
where ug.user_id = :userId and ug.appid in (:appids) and ug.removed_at is null
and ug.hidden_at is null and ug.source <> 'PSN'
""".formatted(paidInSet("ug", "'FINISHED', 'FINISHED_MANUAL', 'FINISHED_EARLY'")),
new MapSqlParameterSource("userId", userId).addValue("appids", appids),
(RowCallbackHandler) rs -> result.put(rs.getLong("appid"), rs.getBoolean("paid") ? 0
: RewardRules.expectedFinish(rs.getBoolean("manual"),
rs.getObject("normally_minutes", Integer.class))));
return result;
}
/**
* The game rows of the ledger for the game and the other editions of its set. bookedNow: written in the current
* transaction (the database clock of a transaction is its start).
*/
public List<PaidRow> paidRows(long userId, long appid) {
return jdbc.query("""
select l.kind, l.ref, l.points, l.created_at, l.created_at = now() as booked_now
from reward_ledger l
where l.user_id = :userId and l.kind in (:kinds)
and (l.ref = cast(:appid as text)
or l.ref in (select o.appid::text from user_game_set o
join user_game_set me on me.user_id = o.user_id and me.set_id = o.set_id
where me.user_id = :userId and me.appid = :appid))
order by l.created_at, l.id
""", new MapSqlParameterSource("userId", userId).addValue("appid", appid)
.addValue("kinds", RewardKind.GAME_KINDS.stream().map(Enum::name).toList()),
(rs, i) -> new PaidRow(RewardKind.valueOf(rs.getString("kind")), Long.parseLong(rs.getString("ref")),
rs.getInt("points"), rs.getTimestamp("created_at").toInstant(), rs.getBoolean("booked_now")));
}
/**
@@ -1,18 +1,23 @@
package core_service.modules.rewards.service;
import core_service.modules.rewards.domain.FinishFacts;
import core_service.modules.rewards.domain.GameRewardFacts;
import core_service.modules.rewards.domain.HistoryEntry;
import core_service.modules.rewards.domain.LookSlot;
import core_service.modules.rewards.domain.Looks;
import core_service.modules.rewards.domain.NoPointsReason;
import core_service.modules.rewards.domain.PaidRow;
import core_service.modules.rewards.domain.RewardEntry;
import core_service.modules.rewards.domain.RewardGame;
import core_service.modules.rewards.domain.RewardKind;
import core_service.modules.rewards.domain.RewardRules;
import core_service.modules.rewards.domain.ShopItem;
import core_service.modules.rewards.domain.StartFacts;
import core_service.modules.rewards.domain.Track;
import core_service.modules.rewards.domain.Unlocks;
import core_service.modules.rewards.repo.RewardFactsRepository;
import core_service.modules.rewards.repo.RewardLedgerRepository;
import core_service.shared.event.LibraryActivityEvent;
import core_service.shared.security.Public;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
@@ -21,7 +26,9 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionalEventListener;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.DateTimeException;
@@ -60,6 +67,9 @@ public class RewardService {
@Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone;
@Value("${app.rewards.early-since}")
private LocalDate earlySince;
/**
* @param owned the codes the user may wear: bought or unlocked, or all for a platform admin
* @param progress the value of each track, see {@link Unlocks}
@@ -211,6 +221,108 @@ public class RewardService {
}
}
/**
* Settles the user after a sync, so points do not wait for the next app start. The settle runs in its own
* transaction and a failure only logs: the sync that published the event is already done.
*/
@TransactionalEventListener(fallbackExecution = true)
public void onLibraryActivity(LibraryActivityEvent event) {
TransactionTemplate own = new TransactionTemplate(transactionTemplate.getTransactionManager());
own.setPropagationBehavior(Propagation.REQUIRES_NEW.value());
try {
own.executeWithoutResult(status -> settleLocked(event.userId()));
} catch (Exception e) {
log.error("Reward settle of user {} after a sync failed", event.userId(), e);
}
}
/**
* The full finish value of each listed game of the user, read without a settle; see
* {@link RewardFactsRepository#expectedFinish}.
*/
@Transactional(readOnly = true)
public Map<Long, Integer> expectedFinish(long userId, List<Long> appids) {
return facts.expectedFinish(userId, appids);
}
/**
* @param paid the game rows of the game and its set
* @param missing why a kind is not paid; FINISHED stands for both finish kinds
*/
public record GamePoints(long balance, List<PaidRow> paid, Map<RewardKind, NoPointsReason> missing) {}
/**
* Settles first, like the wallet; {@link PaidRow#bookedNow()} relies on settle and read sharing this one
* transaction.
*
* @return empty when the user has no such game; nothing is settled then
*/
@Transactional
public Optional<GamePoints> gamePoints(long userId, long appid) {
Optional<GameRewardFacts> found = facts.game(userId, appid);
if (found.isEmpty()) {
return Optional.empty();
}
settleLocked(userId);
List<PaidRow> paid = facts.paidRows(userId, appid);
return Optional.of(new GamePoints(ledger.balance(userId), paid, missing(userId, appid, found.get(), paid)));
}
private Map<RewardKind, NoPointsReason> missing(long userId, long appid, GameRewardFacts game, List<PaidRow> paid) {
Map<RewardKind, NoPointsReason> missing = new EnumMap<>(RewardKind.class);
NoPointsReason blocked = RewardRules.blocked(game);
if (blocked != null) {
if (game.startLogged() && noRow(paid, List.of(RewardKind.STARTED))) {
missing.put(RewardKind.STARTED, blocked);
}
if (noRow(paid, RewardKind.FINISH_KINDS)) {
missing.put(RewardKind.FINISHED, blocked);
}
if (topUpOpen(paid)) {
missing.put(RewardKind.FINISHED_TOPUP, blocked);
}
if (game.completed() && noRow(paid, List.of(RewardKind.COMPLETED))) {
missing.put(RewardKind.COMPLETED, blocked);
}
return missing;
}
if (noRow(paid, List.of(RewardKind.STARTED))) {
StartFacts start = facts.startCandidates(userId, zone, appid).stream().findFirst().orElse(null);
putReason(missing, RewardKind.STARTED, RewardRules.noStartReason(game, start));
}
if (noRow(paid, RewardKind.FINISH_KINDS)) {
FinishFacts finish = facts.finishCandidates(userId, zone, appid).stream().findFirst().orElse(null);
boolean weekFull = finish != null && RewardRules.capped(finish, earlySince())
&& weekFull(userId, finish.finishedAt());
putReason(missing, RewardKind.FINISHED, RewardRules.noFinishReason(game, finish, weekFull, earlySince()));
}
if (topUpOpen(paid)) {
FinishFacts finish = facts.finishCandidates(userId, zone, appid).stream().findFirst().orElse(null);
putReason(missing, RewardKind.FINISHED_TOPUP, RewardRules.noTopUpReason(game, finish));
}
if (noRow(paid, List.of(RewardKind.COMPLETED))) {
FinishFacts completed = facts.completedCandidates(userId, appid).stream().findFirst().orElse(null);
putReason(missing, RewardKind.COMPLETED, RewardRules.noCompletedReason(game, completed,
!noRow(paid, List.of(RewardKind.FINISHED, RewardKind.FINISHED_TOPUP))));
}
return missing;
}
// An early row is paid and the rest of the finish is not.
private static boolean topUpOpen(List<PaidRow> paid) {
return !noRow(paid, List.of(RewardKind.FINISHED_EARLY)) && noRow(paid, List.of(RewardKind.FINISHED_TOPUP));
}
private static boolean noRow(List<PaidRow> paid, List<RewardKind> kinds) {
return paid.stream().noneMatch(row -> kinds.contains(row.kind()));
}
private static void putReason(Map<RewardKind, NoPointsReason> missing, RewardKind kind, NoPointsReason reason) {
if (reason != null) {
missing.put(kind, reason);
}
}
private Map<Track, Integer> settleLocked(long userId) {
if (!facts.isActive(userId)) {
return Map.of();
@@ -247,35 +359,46 @@ 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 = switch (entry.kind()) {
case FINISHED, FINISHED_MANUAL, FINISHED_EARLY -> RewardKind.FINISH_KINDS;
case FINISHED_TOPUP -> List.of(RewardKind.FINISHED, RewardKind.FINISHED_MANUAL, RewardKind.FINISHED_TOPUP);
default -> 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) {
int paid = 0;
for (FinishFacts f : facts.finishCandidates(userId, zone)) {
RewardEntry entry = RewardRules.finish(f).orElse(null);
RewardEntry entry = RewardRules.finish(f, earlySince()).orElse(null);
if (entry == null) {
continue;
}
if (entry.kind() == RewardKind.FINISHED_MANUAL) {
LocalDate monday = weekStart(entry.eventAt());
// The rows booked earlier in this run are in the count already.
int used = ledger.countKindInRange(userId, List.of(RewardKind.FINISHED_MANUAL),
startOf(monday), startOf(monday.plusWeeks(1)));
if (used >= RewardRules.MANUAL_PER_WEEK) {
continue;
}
if (RewardKind.CAPPED_KINDS.contains(entry.kind()) && weekFull(userId, entry.eventAt())) {
continue;
}
paid += book(userId, entry);
paid += bookOnePerSet(userId, entry);
}
return paid;
}
// The rows booked earlier in this run are in the count already.
private boolean weekFull(long userId, Instant at) {
LocalDate monday = weekStart(at);
return ledger.countKindInRange(userId, RewardKind.CAPPED_KINDS, startOf(monday),
startOf(monday.plusWeeks(1))) >= RewardRules.MANUAL_PER_WEEK;
}
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.
@@ -318,6 +441,10 @@ public class RewardService {
return at.atZone(zone).toLocalDate().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
}
private Instant earlySince() {
return startOf(earlySince);
}
private Instant startOf(LocalDate day) {
return day.atStartOfDay(zone).toInstant();
}
@@ -21,6 +21,8 @@ public record SteamOwnedGamesResponse(
@JsonProperty("playtime_forever") Integer playtimeForever,
@JsonProperty("img_icon_url") String imgIconUrl,
@JsonProperty("has_community_visible_stats") Boolean hasCommunityVisibleStats,
@JsonProperty("playtime_2weeks") Integer playtime2Weeks
@JsonProperty("playtime_2weeks") Integer playtime2Weeks,
// unix seconds; 0 or absent when never played
@JsonProperty("rtime_last_played") Long rtimeLastPlayed
) {}
}
+3
View File
@@ -93,6 +93,9 @@ app:
rewards:
# Pile Points of every user, after the nightly sync (23:55) and the image repair (01:30).
settle-cron: "0 30 2 * * *"
# Unproven Steam finishes from this day on pay 10 early points (#37); older ones pay only with proof.
# Fixed once at the slice 2 deploy: deploy date - 30 days. CHECK THE DATE BEFORE DEPLOY.
early-since: "2026-09-10"
follow:
# The hand changes of a followed user are one batch after this quiet time.
digest-ms: 600000
@@ -0,0 +1,11 @@
-- "No" / "Skip" answers of the question queue (Gitea #32), one row per user, game and question.
CREATE TABLE user_game_question (
user_id BIGINT NOT NULL,
appid BIGINT NOT NULL,
question VARCHAR(16) NOT NULL, -- START, FINISHED, DROPPED, RATE
playtime_minutes INTEGER NOT NULL, -- user_game.playtime_minutes at the answer
answered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, appid, question),
-- A merge moves or drops user_game rows (V21 pattern); the answers follow.
FOREIGN KEY (user_id, appid) REFERENCES user_game (user_id, appid) ON DELETE CASCADE ON UPDATE CASCADE
);
@@ -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;
@@ -0,0 +1,25 @@
-- One entry per game across platforms (Gitea #34, slice 2). An entry is a counted_user_game row (V42); these two views
-- map it to its editions, the one place that does. Built on counted_user_game and listed_user_game: a migration that
-- changes a column of user_game drops these two views first (before counted_user_game), then creates them again.
-- Every counted row with itself and the other listed rows of its user_game_set.
CREATE VIEW user_game_entry_member AS
SELECT c.user_id, c.appid, c.appid AS member_appid
FROM counted_user_game c
UNION
SELECT c.user_id, c.appid, o.appid
FROM counted_user_game c
JOIN user_game_set me ON me.user_id = c.user_id AND me.appid = c.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 og ON og.user_id = o.user_id AND og.appid = o.appid;
-- The platforms of an entry, per member: its own source and platform, and a PlayStation row for each title the user
-- has on that key as LINKED or STEAM_OWNED (the Steam + PlayStation one-row case). UNION drops the duplicates.
CREATE VIEW user_game_entry_platform AS
SELECT m.user_id, m.appid, m.member_appid, ug.source, COALESCE(ug.platform, '') AS platform
FROM user_game_entry_member m
JOIN user_game ug ON ug.user_id = m.user_id AND ug.appid = m.member_appid
UNION
SELECT m.user_id, m.appid, m.member_appid, 'PSN', ''
FROM user_game_entry_member m
JOIN user_psn_title t ON t.user_id = m.user_id AND t.appid = m.member_appid AND t.state IN ('LINKED', 'STEAM_OWNED');
@@ -0,0 +1,27 @@
-- One entry per game across platforms (Gitea #34, slice 3). PlayStation play per title, so a title linked to a Steam
-- or manual row keeps its own platform, minutes and last played. Null until the next sync of the user.
ALTER TABLE user_psn_title
ADD COLUMN platform VARCHAR(8),
ADD COLUMN playtime_minutes INT,
ADD COLUMN last_played_at TIMESTAMPTZ;
-- The play of the user's linked titles per game key. A merge does not move user_psn_title.appid (readers map it
-- through game_alias), so the view resolves the alias. user_game columns are unchanged: no view rebuild.
CREATE VIEW psn_title_play AS
SELECT t.user_id, t.psn_key, COALESCE(a.to_appid, t.appid) AS appid, t.platform, t.playtime_minutes, t.last_played_at
FROM user_psn_title t
LEFT JOIN game_alias a ON a.from_appid = t.appid
WHERE t.state IN ('LINKED', 'STEAM_OWNED') AND t.playtime_minutes IS NOT NULL;
-- The PlayStation row of an entry platform resolves the alias too (V43 joined user_psn_title.appid directly, so a
-- title on a merged key lost its icon). Same columns as V43: replace in place.
CREATE OR REPLACE VIEW user_game_entry_platform AS
SELECT m.user_id, m.appid, m.member_appid, ug.source, COALESCE(ug.platform, '') AS platform
FROM user_game_entry_member m
JOIN user_game ug ON ug.user_id = m.user_id AND ug.appid = m.member_appid
UNION
SELECT m.user_id, m.appid, m.member_appid, 'PSN', ''
FROM user_game_entry_member m
JOIN user_psn_title t ON t.user_id = m.user_id AND t.state IN ('LINKED', 'STEAM_OWNED')
LEFT JOIN game_alias a ON a.from_appid = t.appid
WHERE COALESCE(a.to_appid, t.appid) = m.member_appid;
@@ -0,0 +1,15 @@
-- The set total of an entry (Gitea #36), the SQL form of PlatformPlay#total: a row's own minutes plus the minutes of
-- the PlayStation titles on it; a PlayStation row with title play, or a manual row with a title of its platform, counts
-- its titles only. Capped at the int range. Hidden and removed rows have no entry.
-- A migration that changes a user_game column drops this view first (before user_game_entry_platform), then creates
-- it again. No correlated subquery here: it made a library page cost 2 s.
CREATE VIEW user_game_entry_playtime AS
SELECT e.user_id, e.appid,
CAST(LEAST(SUM(CASE WHEN m.source = 'PSN' AND t.minutes IS NOT NULL
OR m.source = 'MANUAL' AND m.platform = ANY (t.platforms) THEN 0
ELSE m.playtime_minutes END + COALESCE(t.minutes, 0)), 2147483647) AS INTEGER) AS playtime_minutes
FROM user_game_entry_member e
JOIN listed_user_game m ON m.user_id = e.user_id AND m.appid = e.member_appid
LEFT JOIN (SELECT user_id, appid, SUM(playtime_minutes) AS minutes, ARRAY_AGG(platform) AS platforms
FROM psn_title_play GROUP BY user_id, appid) t ON t.user_id = m.user_id AND t.appid = m.appid
GROUP BY e.user_id, e.appid;
@@ -15,9 +15,9 @@ import static org.assertj.core.api.Assertions.assertThat;
@Import(AccountCleanupRepository.class)
class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
private static final List<String> USER_TABLES = List.of("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",
"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,10 @@ 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);
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, current_date, 220, 30)", 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;
@@ -75,7 +80,7 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
@BeforeEach
void seed() {
for (String table : List.of("game_link_request", "game_link_pair", "game_link", "game_edition", "admin_audit")) {
for (String table : List.of("user_psn_title", "game_link_request", "game_link_pair", "game_link", "game_edition", "admin_audit")) {
jdbc.update("delete from " + table);
}
userGameRepository.deleteAll();
@@ -274,12 +279,197 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
}
@Test
void hideDuplicates_ListsOnlyTheEditionTheSetCounts() throws Exception {
void games_ASetIsOneRowWithThePlatformsOfAllItsEditions() throws Exception {
jdbc.update("update user_game set source = 'PSN', platform = 'PS5' where user_id = ? and appid = ?", USER_A, GAME_2);
linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent());
mockMvc.perform(as(get("/api/library/games?hideDuplicates=true"), USER_A, Role.USER)).andExpect(status().isOk())
.andExpect(jsonPath("$.totalElements").value(1));
mockMvc.perform(as(get("/api/library/games"), USER_A, Role.USER)).andExpect(status().isOk())
.andExpect(jsonPath("$.totalElements").value(2));
.andExpect(jsonPath("$.totalElements").value(1))
.andExpect(jsonPath("$.content[0].editions").value(2))
.andExpect(jsonPath("$.content[0].platforms", hasSize(2)))
.andExpect(jsonPath("$.content[0].platforms[0].source").value("PSN"))
.andExpect(jsonPath("$.content[0].platforms[0].platform").value("PS5"))
.andExpect(jsonPath("$.content[0].platforms[1].source").value("MANUAL"))
.andExpect(jsonPath("$.content[0].platforms[1].platform").value("Switch"));
}
@Test
void games_ASetRowCarriesTheTotalPlaytimeOfAllItsPlatforms() throws Exception {
jdbc.update("update user_game set playtime_minutes = 500 where user_id = ? and appid = ?", USER_A, GAME);
jdbc.update("update user_game set source = 'PSN', platform = 'PS5', playtime_minutes = 300 where user_id = ? and appid = ?",
USER_A, GAME_2);
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes) "
+ "values (?, 'c9', 'LINKED', ?, 'PS5', 300)", USER_A, GAME_2);
linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent());
mockMvc.perform(as(get("/api/library/games"), USER_A, Role.USER)).andExpect(status().isOk())
.andExpect(jsonPath("$.content[0].totalPlaytimeMinutes").value(800));
}
@Test
void games_AnOldClientsHideDuplicatesParamChangesNothing() throws Exception {
linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent());
mockMvc.perform(as(get("/api/library/games?hideDuplicates=false"), USER_A, Role.USER)).andExpect(status().isOk())
.andExpect(jsonPath("$.totalElements").value(1));
}
private void setSource(long appid, GameSource source) {
jdbc.update("update user_game set source = ? where user_id = ? and appid = ?", source.name(), USER_A, appid);
}
private ResultActions games(String query) throws Exception {
return mockMvc.perform(as(get("/api/library/games" + query), USER_A, Role.USER)).andExpect(status().isOk());
}
@Test
void sourceFilter_ALinkedSetMatchesByAnyListedEdition() throws Exception {
setSource(GAME_2, GameSource.PSN);
linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent());
games("?source=PSN").andExpect(jsonPath("$.totalElements").value(1));
games("?source=STEAM").andExpect(jsonPath("$.totalElements").value(0));
games("?source=MANUAL,STEAM").andExpect(jsonPath("$.totalElements").value(1));
}
@Test
void sourceFilter_WithoutALinkOnlyTheOwnSourceMatches() throws Exception {
setSource(GAME_2, GameSource.PSN);
games("?source=PSN").andExpect(jsonPath("$.totalElements").value(1))
.andExpect(jsonPath("$.content[0].appid").value(GAME_2));
}
@Test
void sourceFilter_AHiddenEditionDoesNotCount() throws Exception {
setSource(GAME_2, GameSource.PSN);
linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent());
jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = ?", USER_A, GAME_2);
games("?source=PSN").andExpect(jsonPath("$.totalElements").value(0));
games("?source=MANUAL").andExpect(jsonPath("$.totalElements").value(1));
}
@Test
void sourceFilter_CombinesWithStatus() throws Exception {
setSource(GAME_2, GameSource.PSN);
jdbc.update("update user_game set status = 'FINISHED' where user_id = ? and appid = ?", USER_A, GAME);
games("?source=MANUAL&status=FINISHED").andExpect(jsonPath("$.totalElements").value(1));
games("?source=PSN&status=FINISHED").andExpect(jsonPath("$.totalElements").value(0));
games("?source=PSN&status=BACKLOG").andExpect(jsonPath("$.totalElements").value(1));
}
@Test
void sourceFilter_HiddenViewIgnoresIt() throws Exception {
jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = ?", USER_A, GAME);
games("?hidden=true&source=PSN").andExpect(jsonPath("$.totalElements").value(1));
}
@Test
void sourceFilter_UnknownValueIs400() throws Exception {
mockMvc.perform(as(get("/api/library/games?source=XBOX"), USER_A, Role.USER)).andExpect(status().isBadRequest());
}
@Test
void gamesCount_AnswersTheSources() throws Exception {
setSource(GAME_2, GameSource.PSN);
mockMvc.perform(as(get("/api/library/games-count"), USER_A, Role.USER)).andExpect(status().isOk())
.andExpect(jsonPath("$.sources[0]").value("MANUAL"))
.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(1));
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();
}
}
@@ -246,6 +246,20 @@ class GameMergeFlowTest extends AbstractIntegrationTest {
"STARTED " + MANUAL, "STARTED " + STEAM);
}
@Test
void merge_MovesEarlyAndTopUpRows() throws Exception {
manualRow(AUDITOR_ID, MANUAL, GameStatus.FINISHED);
for (String kind : List.of("FINISHED_EARLY", "FINISHED_TOPUP")) {
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, ?, ?, 5, now())",
AUDITOR_ID, kind, String.valueOf(MANUAL));
}
merge(Role.AUDITOR, MANUAL, STEAM).andExpect(status().isOk());
assertThat(jdbc.queryForList("select kind || ' ' || ref from reward_ledger where user_id = ? ",
String.class, AUDITOR_ID)).containsExactlyInAnyOrder("FINISHED_EARLY " + STEAM, "FINISHED_TOPUP " + STEAM);
}
@Test
void merge_BothInBacklog_KeptGameTakesTheBetterPosition() throws Exception {
manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG);
@@ -75,6 +75,7 @@ class LibraryControllerAchievementsTest extends AbstractMockedAdaptersTest {
userAchievementMetaRepository.deleteAll();
jdbc.update("delete from user_psn_trophy_set");
jdbc.update("delete from psn_trophy_set");
jdbc.update("delete from game_link");
}
private void trophies(long appid, String setId, String platform, int total, int earned, boolean platinum) {
@@ -146,6 +147,19 @@ class LibraryControllerAchievementsTest extends AbstractMockedAdaptersTest {
.andExpect(jsonPath("$.closest[1].imageUrl").doesNotExist());
}
@Test
void summary_TwoPerfectEditionsOfASetCountAsOnePerfectGame() throws Exception {
game(10L, 10, 10, true);
game(20L, 4, 4, true);
game(30L, 4, 4, true);
jdbc.update("insert into game_link (appid, set_id) values (10, 7001), (20, 7001)");
mockMvc.perform(get("/api/library/achievements/summary"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.startedGames").value(2))
.andExpect(jsonPath("$.perfectGames").value(2));
}
@Test
void summary_TrophiesCount_APlatinumSetIsPerfectAndOneGameCountsOnce() throws Exception {
game(10L, null, null, true);
@@ -203,7 +217,7 @@ class LibraryControllerAchievementsTest extends AbstractMockedAdaptersTest {
@Test
void librarySync_StartsTheAchievementSyncAfterCommit() {
when(steamApiService.getOwnedGames("76561198000000001"))
.thenReturn(List.of(new SteamOwnedGamesResponse.Game(620, "Portal 2", 90, null, true, 0)));
.thenReturn(List.of(new SteamOwnedGamesResponse.Game(620, "Portal 2", 90, null, true, 0, null)));
when(steamApiService.getWishlist(anyString())).thenReturn(Optional.of(List.of()));
when(steamApiService.getSchemaForGame(anyLong())).thenReturn(Optional.empty());
@@ -53,6 +53,7 @@ class LibraryControllerActivityTest extends AbstractIntegrationTest {
void seed() {
userGameRepository.deleteAll();
jdbc.update("delete from user_game_playtime_day");
jdbc.update("delete from game_link where set_id = 980001");
SteamGame repairedPortal = new SteamGame(620L, "Portal 2", null, REPAIRED_COVER, Instant.now());
steamGameRepository.saveAll(List.of(
new SteamGame(220L, "Half-Life 2", null, Instant.now()),
@@ -90,6 +91,19 @@ class LibraryControllerActivityTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.recentGames[1].imageUrl").doesNotExist());
}
@Test
void activity_RecentGamesAreOneEntryPerSetWithTheSumOfItsEditions() throws Exception {
jdbc.update("update user_game set playtime_2weeks = 50 where user_id = ? and appid = 700", USER_ID);
jdbc.update("insert into game_link (appid, set_id) values (220, 980001), (700, 980001)");
mockMvc.perform(get("/api/library/activity").param("days", "7"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.recentGames", hasSize(2)))
.andExpect(jsonPath("$.recentGames[0].appid").value(620))
.andExpect(jsonPath("$.recentGames[1].appid").value(220))
.andExpect(jsonPath("$.recentGames[1].playtime2Weeks").value(80));
}
@Test
void activity_DefaultsTo30DaysAndClampsTheRange() throws Exception {
mockMvc.perform(get("/api/library/activity")).andExpect(jsonPath("$.days", hasSize(30)));
@@ -76,6 +76,7 @@ class LibraryControllerPickerFriendsTest extends AbstractMockedLibraryServicesTe
jdbc.update("delete from account_name where user_id = ?", id);
}
jdbc.update("delete from follow where user_id = ?", USER_ID);
jdbc.update("delete from game_link where set_id = 960001");
gameReleaseRepository.deleteAll();
achievementMetaRepository.deleteAll();
userGameRepository.deleteAll();
@@ -124,6 +125,17 @@ class LibraryControllerPickerFriendsTest extends AbstractMockedLibraryServicesTe
return ROW + ".games[?(@.appid == " + appid + ")].reason." + field;
}
@Test
void friends_ThePlayStationEditionOfAFriendMatchesTheSteamEditionOfTheCaller() throws Exception {
mine(10L, GameStatus.BACKLOG, null);
friend(MAX, "Max", null);
friendGame(MAX, -10L, GameStatus.FINISHED, 100);
jdbc.update("insert into game_link (appid, set_id) values (10, 960001), (-10, 960001)");
pick().andExpect(jsonPath(ROW + ".games[*].appid", contains(10)))
.andExpect(jsonPath(reason(10, "friendName"), contains("Max")));
}
@Test
void friends_FinishedAndPlayedGames_WithReason() throws Exception {
mine(10L, GameStatus.BACKLOG, null);
@@ -3,6 +3,7 @@ package core_service.modules.library.api;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.GamePriority;
import core_service.modules.library.domain.PsnTrophySet;
import core_service.modules.library.domain.StatGroup;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
@@ -13,6 +14,7 @@ import core_service.modules.library.repo.PsnTrophyRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.service.UserStatsService;
import core_service.shared.test.AbstractIntegrationTest;
import core_service.shared.test.ContractTokens;
import org.junit.jupiter.api.BeforeEach;
@@ -28,10 +30,12 @@ import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import java.util.Set;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.nullValue;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@@ -86,6 +90,9 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
@Autowired
private JdbcTemplate jdbc;
@Autowired
private UserStatsService userStatsService;
private void platinum(long appid) {
psnTrophyRepository.saveSet(USER_ID, appid, new PsnTrophySet("NPWR1_00", "trophy2", "01.00", "Set", null, "PS5",
50, 0, 0, 0, 30, true, Instant.now()));
@@ -93,6 +100,7 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
@BeforeEach
void seed() {
jdbc.update("delete from game_link where set_id = 980002");
gameReleaseRepository.deleteAll();
userGameRepository.deleteAll();
schemaMetaRepository.deleteAll();
@@ -114,6 +122,17 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
new UserGame(99L, 220L, 5000, Instant.now(), Instant.now())));
}
@Test
void publicLibrary_ListsASetOnce() throws Exception {
jdbc.update("insert into game_link (appid, set_id) values (220, 980002), (700, 980002)");
mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket(USER_ID)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalGames").value(2))
.andExpect(jsonPath("$.playing", hasSize(0)))
.andExpect(jsonPath("$.finished", hasSize(2)));
}
@Test
void publicLibrary_ShowsStatsPlayingAndFinishedGamesButNoNotesOrPriority() throws Exception {
mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket(USER_ID)))
@@ -199,6 +218,25 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.stats").value(nullValue()));
}
@Test
void publicProfile_PlaytimeNumbersEqualTheOwnersStats() throws Exception {
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes) "
+ "values (?, 'c1', 'STEAM_OWNED', 220, 'PS5', 700)", USER_ID);
jdbc.update("update user_game set status = 'PLAYING' where user_id = ? and appid = 620", USER_ID);
var own = userStatsService.getStats(USER_ID, Set.of(StatGroup.OVERVIEW, StatGroup.TOP_GAMES));
assertEquals(1000L + 900 + 60, own.overview().totalPlaytimeMinutes());
mockMvc.perform(get("/api/public/profiles/library")
.header("X-Profile-Ticket", ticket(USER_ID, List.of("TOP_GAMES", "OVERVIEW"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalPlaytimeMinutes").value(own.overview().totalPlaytimeMinutes()))
.andExpect(jsonPath("$.playing", hasSize(2)))
.andExpect(jsonPath("$.playing[0].name").value("Half-Life 2"))
.andExpect(jsonPath("$.playing[0].playtimeMinutes").value(1000))
.andExpect(jsonPath("$.stats.topGames.mostPlayed[0].appid").value(220))
.andExpect(jsonPath("$.stats.topGames.mostPlayed[0].minutes").value(1000));
}
@Test
void publicLibrary_OnlyTheSharedStatGroups() throws Exception {
mockMvc.perform(get("/api/public/profiles/library")
@@ -0,0 +1,186 @@
package core_service.modules.library.api;
import core_service.configuration.security.ClientHeaderFilter;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
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.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/** The dev profile authenticates every request as user 1. */
@AutoConfigureMockMvc
class QuestionFlowTest extends AbstractIntegrationTest {
private static final long USER_ID = 1L;
private static final long BACKLOG_GAME = 11L;
private static final long PLAYING_GAME = 12L;
private static final long FINISHED_GAME = 13L;
private static final long HIDDEN_GAME = 14L;
private static final long COMPLETED_GAME = 15L;
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
for (String table : List.of("user_game_question", "user_game_playtime_day", "game_time_to_beat", "game_mood",
"game_link", "user_game")) {
jdbc.update("delete from " + table);
}
for (long appid : List.of(BACKLOG_GAME, PLAYING_GAME, FINISHED_GAME, HIDDEN_GAME, COMPLETED_GAME)) {
jdbc.update("insert into steam_game (appid, name) values (?, ?) on conflict do nothing", appid, "Game " + appid);
}
game(BACKLOG_GAME, "BACKLOG", 60);
game(PLAYING_GAME, "PLAYING", 700);
game(FINISHED_GAME, "FINISHED", 100);
game(COMPLETED_GAME, "COMPLETED", 100);
game(HIDDEN_GAME, "BACKLOG", 60);
jdbc.update("update user_game set hidden_at = now() where appid = ?", HIDDEN_GAME);
}
private void game(long appid, String status, int minutes) {
jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes) values (?, ?, ?, ?)",
USER_ID, appid, status, minutes);
}
private ResultActions answer(long appid, String body) throws Exception {
return mockMvc.perform(post("/api/library/questions/" + appid).header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content(body));
}
private String statusOf(long appid) {
return jdbc.queryForObject("select status from user_game where appid = ?", String.class, appid);
}
@Test
void list_HasOneCardPerOpenGameInAskOrder_AndTheCountMatches() throws Exception {
mockMvc.perform(get("/api/library/questions"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.results.length()").value(4))
.andExpect(jsonPath("$.results[0].appid").value(BACKLOG_GAME))
.andExpect(jsonPath("$.results[0].question").value("START"))
.andExpect(jsonPath("$.results[1].appid").value(PLAYING_GAME))
.andExpect(jsonPath("$.results[1].question").value("FINISHED"))
.andExpect(jsonPath("$.results[2].question").value("RATE"));
mockMvc.perform(get("/api/library/questions/count")).andExpect(jsonPath("$.count").value(4));
}
@Test
void finishedYes_SetsTheStatusLikeTheDetailPage_AndAsksToRateNext() throws Exception {
answer(PLAYING_GAME, "{\"question\":\"FINISHED\",\"yes\":true}")
.andExpect(status().isOk())
.andExpect(jsonPath("$.next.question").value("RATE"));
assertThat(statusOf(PLAYING_GAME)).isEqualTo("FINISHED");
assertThat(jdbc.queryForObject("select status_set_by_user from user_game where appid = ?", Boolean.class,
PLAYING_GAME)).isTrue();
assertThat(jdbc.queryForObject("""
select count(*) from user_game_status_change
where appid = ? and to_status = 'FINISHED' and by_user""", Integer.class, PLAYING_GAME)).isEqualTo(1);
}
@Test
void startYes_SetsPlaying_AndHasNoNextQuestion() throws Exception {
answer(BACKLOG_GAME, "{\"question\":\"START\",\"yes\":true}")
.andExpect(status().isOk())
.andExpect(jsonPath("$.next").doesNotExist());
assertThat(statusOf(BACKLOG_GAME)).isEqualTo("PLAYING");
}
@Test
void startNo_RemovesTheCard_AndItComesBackAfterFiveMoreHours() throws Exception {
answer(BACKLOG_GAME, "{\"question\":\"START\",\"yes\":false}").andExpect(status().isOk());
mockMvc.perform(get("/api/library/questions/count")).andExpect(jsonPath("$.count").value(3));
jdbc.update("update user_game set playtime_minutes = 359 where appid = ?", BACKLOG_GAME);
mockMvc.perform(get("/api/library/questions/count")).andExpect(jsonPath("$.count").value(3));
jdbc.update("update user_game set playtime_minutes = 360 where appid = ?", BACKLOG_GAME);
mockMvc.perform(get("/api/library/questions/count")).andExpect(jsonPath("$.count").value(4));
assertThat(statusOf(BACKLOG_GAME)).isEqualTo("BACKLOG");
}
@Test
void rateYes_SavesTheRating_AlsoForACompletedGame() throws Exception {
answer(FINISHED_GAME, "{\"question\":\"RATE\",\"yes\":true,\"rating\":4}").andExpect(status().isOk());
answer(COMPLETED_GAME, "{\"question\":\"RATE\",\"yes\":true,\"rating\":5}").andExpect(status().isOk());
assertThat(jdbc.queryForObject("select rating from user_game where appid = ?", Integer.class, FINISHED_GAME)).isEqualTo(4);
assertThat(jdbc.queryForObject("select rating from user_game where appid = ?", Integer.class, COMPLETED_GAME)).isEqualTo(5);
assertThat(statusOf(FINISHED_GAME)).isEqualTo("FINISHED");
}
@Test
void rateSkip_NeverAsksAgain() throws Exception {
answer(FINISHED_GAME, "{\"question\":\"RATE\",\"yes\":false}").andExpect(status().isOk());
mockMvc.perform(get("/api/library/questions/count")).andExpect(jsonPath("$.count").value(3));
}
@Test
void rateYesWithoutRating_Is400() throws Exception {
answer(FINISHED_GAME, "{\"question\":\"RATE\",\"yes\":true}")
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_ANSWER"));
}
@Test
void badBody_Is400() throws Exception {
answer(PLAYING_GAME, "{\"question\":\"FINISHED\"}").andExpect(status().isBadRequest());
answer(PLAYING_GAME, "{\"yes\":true}").andExpect(status().isBadRequest());
answer(FINISHED_GAME, "{\"question\":\"RATE\",\"yes\":true,\"rating\":6}").andExpect(status().isBadRequest());
assertThat(statusOf(PLAYING_GAME)).isEqualTo("PLAYING");
}
@Test
void staleQuestion_Is409_AndChangesNothing() throws Exception {
answer(PLAYING_GAME, "{\"question\":\"START\",\"yes\":true}")
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("QUESTION_GONE"));
answer(PLAYING_GAME, "{\"question\":\"FINISHED\",\"yes\":true}").andExpect(status().isOk());
answer(PLAYING_GAME, "{\"question\":\"FINISHED\",\"yes\":true}").andExpect(status().isConflict());
assertThat(statusOf(PLAYING_GAME)).isEqualTo("FINISHED");
}
@Test
void linkedSetPlayedYesterdayOnTheOtherEdition_IsNotAskedDropped() throws Exception {
jdbc.update("delete from user_game");
for (long appid : List.of(21L, 22L)) {
jdbc.update("insert into steam_game (appid, name) values (?, ?) on conflict do nothing", appid, "Game " + appid);
}
jdbc.update("insert into game_time_to_beat (appid, normally_minutes, fetched_at) values (21, 6000, now()), (22, 6000, now())");
game(21L, "PLAYING", 1800);
game(22L, "PLAYING", 300);
jdbc.update("update user_game set first_seen_at = now() - interval '1 year' where appid in (21, 22)");
jdbc.update("update user_game set last_played_at = now() - interval '1 day' where appid = 22");
jdbc.update("update user_game_status_change set changed_at = now() - interval '1 year' where appid in (21, 22)");
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, current_date - 200, 21, 60)", USER_ID);
jdbc.update("insert into game_link (appid, set_id) values (21, 1), (22, 1)");
mockMvc.perform(get("/api/library/questions/count")).andExpect(jsonPath("$.count").value(0));
}
@Test
void hiddenOrUnknownGame_Is404() throws Exception {
answer(HIDDEN_GAME, "{\"question\":\"START\",\"yes\":true}").andExpect(status().isNotFound());
answer(999L, "{\"question\":\"START\",\"yes\":true}").andExpect(status().isNotFound());
assertThat(statusOf(HIDDEN_GAME)).isEqualTo("BACKLOG");
}
}
@@ -92,9 +92,9 @@ class StatGamesFlowTest extends AbstractMockedLibraryServicesTest {
count("PILE", null, "/pile/backlogGames"),
new Case("PILE_TIME_TO_BEAT", null, s -> s.at("/pile/gamesWithTimeToBeat").asLong(), "minutes",
s -> s.at("/pile/normalMinutes").asLong()),
new Case("GENRE", "RPG", s -> tagValue(s.at("/taste/genres"), "RPG", "games"), "playtimeMinutes",
new Case("GENRE", "RPG", s -> tagValue(s.at("/taste/genres"), "RPG", "games"), "minutes",
s -> tagValue(s.at("/taste/genres"), "RPG", "minutes")),
new Case("MOOD", "CHILL", s -> tagValue(s.at("/taste/moods"), "CHILL", "games"), "playtimeMinutes",
new Case("MOOD", "CHILL", s -> tagValue(s.at("/taste/moods"), "CHILL", "games"), "minutes",
s -> tagValue(s.at("/taste/moods"), "CHILL", "minutes")),
new Case("LONGEST_DAY", null, null, "minutes", s -> s.at("/habits/longestDay/minutes").asLong()),
count("UNDER_ONE_HOUR", null, "/habits/underOneHourGames"),
@@ -60,7 +60,7 @@ class SyncChangesFlowTest extends AbstractMockedAdaptersTest {
}
private static SteamOwnedGamesResponse.Game owned(int appid, int playtime) {
return new SteamOwnedGamesResponse.Game(appid, "Game " + appid, playtime, "icon", true, playtime);
return new SteamOwnedGamesResponse.Game(appid, "Game " + appid, playtime, "icon", true, playtime, null);
}
private void sync(SyncTrigger trigger, SteamOwnedGamesResponse.Game... games) {
@@ -0,0 +1,185 @@
package core_service.modules.library.domain;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.function.UnaryOperator;
import static org.assertj.core.api.Assertions.assertThat;
class OpenQuestionTest {
private static final ZoneId ZONE = ZoneId.of("Europe/Berlin");
private static final Instant NOW = Instant.parse("2026-10-09T12:00:00Z");
private static final Instant LONG_AGO = NOW.minus(400, ChronoUnit.DAYS);
/** A game with a calm default: playing 1 h, played lately, rated. */
private static final class Game {
GameStatus status = GameStatus.PLAYING;
Integer rating = 3;
int playtime = 60;
int playtime2Weeks = 30;
Integer normally = null;
Boolean endless = null;
LocalDate lastPlayDay = null;
Instant lastPlayedAt = null;
Instant lastStatusChange = LONG_AGO;
Instant firstSeenAt = LONG_AGO;
Integer startAskedAt = null;
Integer finishedAskedAt = null;
Instant droppedAnsweredAt = null;
boolean rateSkipped = false;
QuestionFacts facts() {
return new QuestionFacts(1L, "Game", null, status, rating, playtime, playtime2Weeks, normally, endless,
lastPlayDay, lastPlayedAt, lastStatusChange, firstSeenAt, startAskedAt, finishedAskedAt,
droppedAnsweredAt, rateSkipped);
}
}
private static Question askIdle(UnaryOperator<Game> setup) {
return ask(g -> {
g.playtime2Weeks = 0;
return setup.apply(g);
});
}
private static Question ask(UnaryOperator<Game> setup) {
return OpenQuestion.of(setup.apply(new Game()).facts(), NOW, ZONE).map(OpenQuestion::question).orElse(null);
}
@Test
void start_PlayedBacklogGame() {
assertThat(ask(g -> { g.status = GameStatus.BACKLOG; g.playtime = 1; return g; })).isEqualTo(Question.START);
}
@Test
void start_NotAskedForAnUnplayedGame() {
assertThat(ask(g -> { g.status = GameStatus.BACKLOG; g.playtime = 0; return g; })).isNull();
}
@Test
void start_SnoozedUntilFiveMoreHours() {
assertThat(ask(g -> { g.status = GameStatus.BACKLOG; g.playtime = 100; g.startAskedAt = 100 - 299; return g; }))
.isNull();
assertThat(ask(g -> { g.status = GameStatus.BACKLOG; g.playtime = 400; g.startAskedAt = 100; return g; }))
.isEqualTo(Question.START);
assertThat(ask(g -> { g.status = GameStatus.BACKLOG; g.playtime = 399; g.startAskedAt = 100; return g; }))
.isNull();
}
@Test
void finished_UsesTimeToBeatWhenKnown() {
assertThat(ask(g -> { g.playtime = 1999; g.normally = 2000; return g; })).isNull();
assertThat(ask(g -> { g.playtime = 2000; g.normally = 2000; return g; })).isEqualTo(Question.FINISHED);
}
@Test
void finished_FallsBackToTenHoursWithoutTimeToBeat() {
assertThat(ask(g -> { g.playtime = 599; return g; })).isNull();
assertThat(ask(g -> { g.playtime = 600; return g; })).isEqualTo(Question.FINISHED);
assertThat(ask(g -> { g.playtime = 600; g.normally = 0; return g; })).isEqualTo(Question.FINISHED);
}
@Test
void finished_AlsoForBacklogGame_ButNeverForFinishedDroppedOrCompleted() {
assertThat(ask(g -> { g.status = GameStatus.BACKLOG; g.playtime = 700; g.startAskedAt = 600; return g; }))
.isEqualTo(Question.FINISHED);
assertThat(ask(g -> { g.status = GameStatus.DROPPED; g.playtime = 700; return g; })).isNull();
assertThat(ask(g -> { g.status = GameStatus.COMPLETED; g.playtime = 700; g.rating = 5; return g; })).isNull();
}
@Test
void finished_EndlessGameNeverAsks_ButUnknownOrFalseDoes() {
assertThat(ask(g -> { g.playtime = 700; g.endless = true; return g; })).isNull();
assertThat(ask(g -> { g.playtime = 700; g.endless = false; return g; })).isEqualTo(Question.FINISHED);
assertThat(ask(g -> { g.playtime = 700; g.endless = null; return g; })).isEqualTo(Question.FINISHED);
}
@Test
void finished_SnoozedUntilFiveMoreHours() {
assertThat(ask(g -> { g.playtime = 899; g.finishedAskedAt = 600; return g; })).isNull();
assertThat(ask(g -> { g.playtime = 900; g.finishedAskedAt = 600; return g; })).isEqualTo(Question.FINISHED);
}
@Test
void endlessGame_StillGetsDropped() {
assertThat(askIdle(g -> { g.playtime = 700; g.endless = true; return g; })).isEqualTo(Question.DROPPED);
}
@Test
void dropped_AfterNinetyIdleDays() {
assertThat(askIdle(g -> { g.lastPlayedAt = NOW.minus(89, ChronoUnit.DAYS); return g; })).isNull();
assertThat(askIdle(g -> { g.lastPlayedAt = NOW.minus(91, ChronoUnit.DAYS); return g; })).isEqualTo(Question.DROPPED);
}
@Test
void dropped_PlaytimeInTheLastTwoWeeksBlocks() {
assertThat(ask(g -> g)).isNull();
assertThat(askIdle(g -> g)).isEqualTo(Question.DROPPED);
}
@Test
void dropped_EachActivitySourceCounts() {
Instant recent = NOW.minus(10, ChronoUnit.DAYS);
assertThat(askIdle(g -> { g.lastPlayDay = LocalDate.of(2026, 10, 1); return g; })).isNull();
assertThat(askIdle(g -> { g.lastPlayedAt = recent; return g; })).isNull();
assertThat(askIdle(g -> { g.lastStatusChange = recent; return g; })).isNull();
assertThat(askIdle(g -> { g.firstSeenAt = recent; return g; })).isNull();
assertThat(askIdle(g -> { g.droppedAnsweredAt = recent; return g; })).isNull();
}
@Test
void dropped_AnswerNoRestartsTheNinetyDays() {
assertThat(askIdle(g -> { g.droppedAnsweredAt = NOW.minus(91, ChronoUnit.DAYS); return g; })).isEqualTo(Question.DROPPED);
assertThat(askIdle(g -> { g.droppedAnsweredAt = NOW.minus(89, ChronoUnit.DAYS); return g; })).isNull();
}
@Test
void dropped_ResultCarriesTheLastActivity() {
Game game = new Game();
game.playtime2Weeks = 0;
game.lastPlayedAt = NOW.minus(120, ChronoUnit.DAYS);
QuestionFacts facts = game.facts();
assertThat(OpenQuestion.of(facts, NOW, ZONE).orElseThrow().lastActivity()).isEqualTo(facts.lastPlayedAt());
}
@Test
void dropped_OnlyForPlayingGames() {
assertThat(ask(g -> { g.status = GameStatus.BACKLOG; g.playtime = 0; return g; })).isNull();
}
@ParameterizedTest
@EnumSource(value = GameStatus.class, names = {"FINISHED", "COMPLETED"})
void rate_UnratedFinishedGame(GameStatus status) {
assertThat(ask(g -> { g.status = status; g.rating = null; return g; })).isEqualTo(Question.RATE);
}
@Test
void rate_NotWhenRatedOrSkipped() {
assertThat(ask(g -> { g.status = GameStatus.FINISHED; return g; })).isNull();
assertThat(ask(g -> { g.status = GameStatus.FINISHED; g.rating = null; g.rateSkipped = true; return g; })).isNull();
}
@Test
void priority_StartBeforeFinishedBeforeDroppedBeforeRate() {
assertThat(ask(g -> { g.status = GameStatus.BACKLOG; g.playtime = 700; return g; })).isEqualTo(Question.START);
assertThat(ask(g -> { g.playtime = 700; return g; })).isEqualTo(Question.FINISHED);
assertThat(ask(g -> { g.status = GameStatus.FINISHED; g.rating = null; g.playtime = 700; return g; }))
.isEqualTo(Question.RATE);
}
@Test
void manualGameWithoutPlaytime_GetsOnlyDroppedOrRate() {
assertThat(askIdle(g -> { g.playtime = 0; return g; })).isEqualTo(Question.DROPPED);
assertThat(ask(g -> { g.status = GameStatus.FINISHED; g.rating = null; g.playtime = 0; return g; }))
.isEqualTo(Question.RATE);
assertThat(ask(g -> { g.status = GameStatus.BACKLOG; g.playtime = 0; return g; })).isNull();
}
}
@@ -0,0 +1,102 @@
package core_service.modules.library.domain;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class PlatformPlayTest {
private static final long USER = 1L;
private static final Instant STEAM_PLAYED = Instant.parse("2026-10-01T10:00:00Z");
private static final Instant PSN_PLAYED = Instant.parse("2026-10-05T18:00:00Z");
private static UserGame row(long appid, GameSource source, String platform, int minutes, Instant lastPlayedAt) {
UserGame game = UserGame.manual(USER, appid, source, platform, GameStatus.PLAYING, minutes, Instant.EPOCH);
game.setLastPlayedAt(lastPlayedAt);
return game;
}
private static TitlePlay title(String key, long appid, String platform, int minutes) {
return new TitlePlay(key, appid, platform, minutes, PSN_PLAYED);
}
@Test
void lines_ASteamRowWithAnOwnedPlayStationTitleIsTwoLines_AndTheTotalIsTheirSum() {
List<PlatformPlay> lines = PlatformPlay.lines(List.of(row(100, GameSource.STEAM, null, 500, STEAM_PLAYED)),
List.of(title("c1", 100, "PS5", 120)));
assertThat(lines).containsExactly(
new PlatformPlay(GameSource.STEAM, null, 100, 500, STEAM_PLAYED),
new PlatformPlay(GameSource.PSN, "PS5", 100, 120, PSN_PLAYED));
assertThat(PlatformPlay.total(lines)).isEqualTo(620);
}
@Test
void lines_APlayStationRowWithTwoTitlesIsTheTitleLines_TheRowSumIsNotAddedOnTop() {
List<PlatformPlay> lines = PlatformPlay.lines(List.of(row(200, GameSource.PSN, "PS5", 300, PSN_PLAYED)),
List.of(title("c4", 200, "PS4", 100), title("c5", 200, "PS5", 200)));
assertThat(lines).extracting(PlatformPlay::platform).containsExactly("PS5", "PS4");
assertThat(lines).extracting(PlatformPlay::playtimeMinutes).containsExactly(200, 100);
assertThat(PlatformPlay.total(lines)).isEqualTo(300);
}
@Test
void lines_APlayStationRowWithoutStoredTitlePlayIsOneLineFromTheRow() {
List<PlatformPlay> lines = PlatformPlay.lines(List.of(row(200, GameSource.PSN, "PS5", 300, PSN_PLAYED)), List.of());
assertThat(lines).containsExactly(new PlatformPlay(GameSource.PSN, "PS5", 200, 300, PSN_PLAYED));
assertThat(PlatformPlay.total(lines)).isEqualTo(300);
}
@Test
void lines_AManualRowWithALinkedTitleIsTheRowLineAndTheTitleLine() {
List<PlatformPlay> lines = PlatformPlay.lines(List.of(row(-7, GameSource.MANUAL, "Switch", 60, null)),
List.of(title("c1", -7, "PS4", 30)));
assertThat(lines).containsExactly(
new PlatformPlay(GameSource.PSN, "PS4", -7, 30, PSN_PLAYED),
new PlatformPlay(GameSource.MANUAL, "Switch", -7, 60, null));
}
@Test
void lines_ARowTheSteamSyncTookOverIsASteamLineAndTheTitleLine() {
List<PlatformPlay> lines = PlatformPlay.lines(List.of(row(100, GameSource.STEAM, null, 40, STEAM_PLAYED)),
List.of(title("c1", 100, "PS5", 900)));
assertThat(lines).extracting(PlatformPlay::source).containsExactly(GameSource.STEAM, GameSource.PSN);
assertThat(PlatformPlay.total(lines)).isEqualTo(940);
}
@Test
void lines_OrderIsSteamThenPs5ThenPs4ThenManual_AndTitlesOfOtherRowsAreIgnored() {
List<PlatformPlay> lines = PlatformPlay.lines(List.of(
row(-7, GameSource.MANUAL, "Switch", 60, null),
row(200, GameSource.PSN, "PS4", 10, null),
row(100, GameSource.STEAM, null, 500, STEAM_PLAYED)),
List.of(title("c1", 200, "PS4", 10), title("c2", 100, "PS5", 20), title("c3", 999, "PS5", 7777)));
assertThat(lines).extracting(l -> l.source() + ":" + l.platform())
.containsExactly("STEAM:null", "PSN:PS5", "PSN:PS4", "MANUAL:Switch");
}
@Test
void total_IsCappedAtIntegerMax() {
List<PlatformPlay> lines = List.of(new PlatformPlay(GameSource.STEAM, null, 1, Integer.MAX_VALUE, null),
new PlatformPlay(GameSource.PSN, "PS5", 1, 5, null));
assertThat(PlatformPlay.total(lines)).isEqualTo(Integer.MAX_VALUE);
}
@Test
void lines_AManualRowWithALinkedTitleOfItsPlatformIsOneLine() {
List<PlatformPlay> lines = PlatformPlay.lines(List.of(row(-7, GameSource.MANUAL, "PS5", 1200, null)),
List.of(title("c1", -7, "PS5", 1300)));
assertThat(lines).containsExactly(new PlatformPlay(GameSource.PSN, "PS5", -7, 1300, PSN_PLAYED));
assertThat(PlatformPlay.total(lines)).isEqualTo(1300);
}
}
@@ -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);
@@ -0,0 +1,120 @@
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 static org.assertj.core.api.Assertions.assertThat;
class GameEntryViewTest extends AbstractPersistenceTest {
private static final long USER = 1L;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
jdbc.update("delete from user_psn_title");
jdbc.update("delete from game_link");
jdbc.update("delete from user_game");
for (long appid : List.of(-10L, 10L, 20L, 30L)) {
jdbc.update("insert into steam_game (appid, name) values (?, ?) on conflict do nothing", appid, "Game " + appid);
}
}
private void set(long appid, long setId) {
jdbc.update("insert into game_link (appid, set_id) values (?, ?)", appid, setId);
}
private void own(long appid, String source, String platform, int playtime) {
jdbc.update("insert into user_game (user_id, appid, source, platform, playtime_minutes, status) values (?, ?, ?, ?, ?, 'BACKLOG')",
USER, appid, source, platform, playtime);
}
private void title(String key, String state, long appid) {
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, ?, ?, ?)", USER, key, state, appid);
}
private List<String> members() {
return jdbc.queryForList("select appid || '>' || member_appid from user_game_entry_member where user_id = ? "
+ "order by appid, member_appid", String.class, USER);
}
private List<String> platforms(long appid) {
return jdbc.queryForList("select member_appid || ':' || source || ':' || platform from user_game_entry_platform "
+ "where user_id = ? and appid = ? order by member_appid, source", String.class, USER, appid);
}
@Test
void member_EntryHasItselfAndItsListedEditions() {
own(10, "STEAM", null, 600);
own(-10, "PSN", "PS5", 60);
own(20, "STEAM", null, 5);
set(10, 1);
set(-10, 1);
assertThat(members()).containsExactly("10>-10", "10>10", "20>20");
}
@Test
void member_LeavesOutAHiddenEdition() {
own(10, "STEAM", null, 600);
own(-10, "PSN", "PS5", 60);
set(10, 1);
set(-10, 1);
jdbc.update("update user_game set hidden_at = now() where appid = -10");
assertThat(members()).containsExactly("10>10");
}
@Test
void member_ASplitEditionIsItsOwnEntry() {
own(10, "STEAM", null, 600);
own(-10, "PSN", "PS5", 60);
set(10, 1);
set(-10, 1);
jdbc.update("insert into user_game_split (user_id, appid) values (?, -10)", USER);
assertThat(members()).containsExactly("-10>-10", "10>10");
}
@Test
void platform_ASteamOwnedPsnTitleGivesAPsnPlatformOnTheSteamRow() {
own(10, "STEAM", null, 600);
title("c1", "STEAM_OWNED", 10);
assertThat(platforms(10)).containsExactly("10:PSN:", "10:STEAM:");
}
@Test
void platform_TwoTitlesOnOneKeyGiveOnePsnRow() {
own(10, "STEAM", null, 600);
title("c1", "STEAM_OWNED", 10);
title("c2", "STEAM_OWNED", 10);
assertThat(platforms(10)).containsExactly("10:PSN:", "10:STEAM:");
}
@Test
void platform_ASetListsEveryEditionWithItsPlatform() {
own(10, "STEAM", null, 600);
own(-10, "PSN", "PS5", 60);
set(10, 1);
set(-10, 1);
assertThat(platforms(10)).containsExactly("-10:PSN:PS5", "10:STEAM:");
}
@Test
void platform_ADismissedTitleGivesNoPlatform() {
own(10, "STEAM", null, 600);
title("c1", "DISMISSED", 10);
assertThat(platforms(10)).containsExactly("10:STEAM:");
}
}
@@ -1,8 +1,13 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.Edition;
import core_service.modules.library.domain.EntryPlatform;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.LinkPair;
import core_service.modules.library.domain.PlatformPlay;
import core_service.modules.library.domain.TitlePlay;
import core_service.modules.library.domain.UserGame;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -10,6 +15,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
@@ -30,7 +36,7 @@ class GameLinkRepositoryTest extends AbstractPersistenceTest {
@BeforeEach
void seed() {
for (String table : List.of("game_link_request", "game_link_pair", "game_link", "game_edition", "user_game")) {
for (String table : List.of("user_psn_title", "game_link_request", "game_link_pair", "game_link", "game_edition", "user_game")) {
jdbc.update("delete from " + table);
}
for (long appid : List.of(100L, 200L, -7L, -8L, 300L)) {
@@ -254,6 +260,122 @@ class GameLinkRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.linkedEditions(USER, 100)).isEmpty();
}
private void ownOn(long appid, String source, String platform) {
jdbc.update("insert into user_game (user_id, appid, status, source, platform) values (?, ?, 'BACKLOG', ?, ?)",
USER, appid, source, platform);
}
private void playtime(long appid, int minutes) {
jdbc.update("update user_game set playtime_minutes = ? where user_id = ? and appid = ?", minutes, USER, appid);
}
private void titlePlay(String key, long appid, String state, String platform, int minutes) {
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes) "
+ "values (?, ?, ?, ?, ?, ?)", USER, key, state, appid, platform, minutes);
}
private static UserGame row(long appid, GameSource source, String platform, int minutes) {
return UserGame.manual(USER, appid, source, platform, GameStatus.BACKLOG, minutes, Instant.EPOCH);
}
@Test
void entryPlaytimes_ASteamAndAnOwnedTitleSet_EqualsThePlatformPlayTotal() {
ownOn(100, "STEAM", null);
playtime(100, 500);
titlePlay("c1", 100, "STEAM_OWNED", "PS5", 120);
var playtimes = repository.entryPlaytimes(USER, List.of(100L));
int expected = PlatformPlay.total(PlatformPlay.lines(List.of(row(100, GameSource.STEAM, null, 500)),
List.of(new TitlePlay("c1", 100L, "PS5", 120, null))));
assertThat(expected).isEqualTo(620);
assertThat(playtimes).containsOnly(Map.entry(100L, expected));
}
@Test
void entryPlaytimes_ASteamAndAPlayStationRowSet_EqualsThePlatformPlayTotal_TheRowSumIsNotAddedOnTop() {
repository.link(100, 200);
ownOn(100, "STEAM", null);
ownOn(200, "PSN", "PS5");
playtime(100, 500);
playtime(200, 300);
titlePlay("c4", 200, "LINKED", "PS4", 100);
titlePlay("c5", 200, "LINKED", "PS5", 200);
// A title without stored play counts nothing.
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c6', 'LINKED', 200)", USER);
var playtimes = repository.entryPlaytimes(USER, List.of(100L, 200L));
int expected = PlatformPlay.total(PlatformPlay.lines(
List.of(row(100, GameSource.STEAM, null, 500), row(200, GameSource.PSN, "PS5", 300)),
List.of(new TitlePlay("c4", 200L, "PS4", 100, null), new TitlePlay("c5", 200L, "PS5", 200, null))));
assertThat(expected).isEqualTo(800);
assertThat(playtimes.values()).containsExactly(expected);
}
@Test
void entryPlaytimes_AManualRowWithATitleOfItsPlatform_EqualsThePlatformPlayTotal() {
repository.link(100, -7);
ownOn(100, "STEAM", null);
ownOn(-7, "MANUAL", "PS5");
playtime(100, 500);
playtime(-7, 1200);
titlePlay("c1", -7, "LINKED", "PS5", 1300);
titlePlay("c2", -7, "LINKED", "PS4", 60);
int expected = PlatformPlay.total(PlatformPlay.lines(
List.of(row(100, GameSource.STEAM, null, 500), row(-7, GameSource.MANUAL, "PS5", 1200)),
List.of(new TitlePlay("c1", -7L, "PS5", 1300, null), new TitlePlay("c2", -7L, "PS4", 60, null))));
assertThat(expected).isEqualTo(1860);
assertThat(repository.entryPlaytimes(USER, List.of(100L, -7L)).values()).containsExactly(expected);
}
@Test
void entryPlaytimes_APlayStationRowWithoutTitlePlay_IsItsOwnMinutes() {
ownOn(200, "PSN", "PS5");
playtime(200, 300);
assertThat(repository.entryPlaytimes(USER, List.of(200L))).containsOnly(Map.entry(200L, 300));
assertThat(repository.entryPlaytimes(USER, List.of())).isEmpty();
}
@Test
void entryPlatforms_SteamFirstThenPlayStationThenManual_AndAPlayStationTitleWithoutNameIsDropped() {
repository.link(100, 200);
repository.link(100, -7);
ownOn(100, "STEAM", null);
ownOn(200, "PSN", "PS5");
ownOn(-7, "MANUAL", "Switch");
jdbc.update("update user_game set playtime_minutes = 500 where appid = 100");
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'STEAM_OWNED', 100)", USER);
var platforms = repository.entryPlatforms(USER, List.of(100L, 300L));
assertThat(platforms.keySet()).containsExactly(100L);
assertThat(platforms.get(100L)).extracting(p -> p.source() + ":" + p.platform())
.containsExactly("STEAM:null", "PSN:PS5", "MANUAL:Switch");
assertThat(repository.entryPlatforms(USER, List.of())).isEmpty();
}
@Test
void entryPlatforms_ASteamRowWithAPlayStationTitleHasBothPlatforms() {
ownOn(100, "STEAM", null);
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'STEAM_OWNED', 100)", USER);
assertThat(repository.entryPlatforms(USER, List.of(100L)).get(100L))
.extracting(p -> p.source() + ":" + p.platform()).containsExactly("STEAM:null", "PSN:null");
}
@Test
void entryPlatforms_AManualPlayStationRowWithALinkedTitleIsOnePlatform() {
ownOn(-7, "MANUAL", "PS5");
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', -7)", USER);
assertThat(repository.entryPlatforms(USER, List.of(-7L)).get(-7L))
.extracting(p -> p.source() + ":" + p.platform()).containsExactly("MANUAL:PS5");
}
@Test
void editionCounts_GivesTwoForBothKeysAndNothingForASingleOwnedKey() {
repository.link(100, 200);
@@ -267,4 +389,76 @@ 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));
}
@Test
void entryPlatforms_ATitleOnAMergedKeyStillGivesThePlayStationIcon() {
jdbc.update("insert into steam_game (appid, name) values (-9, 'Old key') on conflict do nothing");
jdbc.update("insert into game_alias (from_appid, to_appid) values (-9, 100) on conflict do nothing");
ownOn(100, "STEAM", null);
playtime(100, 500);
titlePlay("c1", -9, "STEAM_OWNED", "PS5", 120);
assertThat(repository.entryPlaytimes(USER, List.of(100L))).containsOnly(Map.entry(100L, 620));
assertThat(repository.entryPlatforms(USER, List.of(100L)).get(100L)).extracting(EntryPlatform::source)
.contains(GameSource.PSN);
}
}
@@ -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);
}
}
@@ -1,16 +1,19 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.PsnTitle;
import core_service.modules.library.domain.TitlePlay;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.tuple;
@Import(PsnTitleRepository.class)
class PsnTitleRepositoryTest extends AbstractPersistenceTest {
@@ -72,4 +75,42 @@ class PsnTitleRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.findAll(3L).get("c1").appid()).isEqualTo(-1L);
assertThat(repository.findAll(5L)).isEmpty();
}
@Test
void savePlays_UpdatesLinkedAndSteamOwnedTitles_NotADismissedOne() {
Instant played = Instant.parse("2026-10-05T18:00:00Z");
repository.link(6L, "c1", -1L);
repository.linkOwned(6L, "c2", -2L);
repository.dismiss(6L, List.of("c3"));
repository.savePlays(6L, List.of(new TitlePlay("c1", null, "PS5", 120, played),
new TitlePlay("c2", null, "PS4", 30, null), new TitlePlay("c3", null, "PS5", 999, played)));
assertThat(jdbc.queryForObject("select count(*) from user_psn_title where user_id = 6 and playtime_minutes is not null",
Integer.class)).isEqualTo(2);
assertThat(jdbc.queryForObject("select playtime_minutes from user_psn_title where user_id = 6 and psn_key = 'c1'",
Integer.class)).isEqualTo(120);
assertThat(jdbc.queryForObject("select platform from user_psn_title where user_id = 6 and psn_key = 'c2'",
String.class)).isEqualTo("PS4");
assertThat(jdbc.queryForObject("select playtime_minutes from user_psn_title where user_id = 6 and psn_key = 'c3'",
Integer.class)).isNull();
}
@Test
void findPlays_MapsAMergedKeyThroughTheAlias_AndLeavesOutTitlesWithoutPlay() {
game(7L, -1L, "STEAM");
jdbc.update("insert into steam_game (appid, name) values (-9, 'Old') on conflict do nothing");
jdbc.update("insert into game_alias (from_appid, to_appid) values (-9, -1) on conflict do nothing");
repository.link(7L, "c1", -9L);
repository.linkOwned(7L, "c2", -1L);
repository.link(7L, "c3", -1L);
repository.savePlays(7L, List.of(new TitlePlay("c1", null, "PS5", 120, null),
new TitlePlay("c2", null, "PS4", 30, null)));
List<TitlePlay> plays = repository.findPlays(7L, List.of(-1L));
assertThat(plays).extracting(TitlePlay::psnKey, TitlePlay::appid, TitlePlay::platform, TitlePlay::playtimeMinutes)
.containsExactlyInAnyOrder(tuple("c1", -1L, "PS5", 120), tuple("c2", -1L, "PS4", 30));
assertThat(repository.findPlays(7L, List.of())).isEmpty();
}
}
@@ -0,0 +1,167 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.Question;
import core_service.modules.library.domain.QuestionFacts;
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.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.LocalDate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@Import(QuestionRepository.class)
class QuestionRepositoryTest extends AbstractPersistenceTest {
private static final long USER = 1L;
private static final long OTHER_USER = 2L;
@Autowired
private QuestionRepository repository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
for (String table : List.of("user_game_question", "user_game_playtime_day", "game_link", "game_mood",
"game_time_to_beat", "user_game")) {
jdbc.update("delete from " + table);
}
for (long appid : List.of(10L, 20L, 30L, 40L, 50L)) {
jdbc.update("insert into steam_game (appid, name, image_url) values (?, ?, ?) on conflict do nothing",
appid, "Game " + appid, "img" + appid);
}
game(USER, 10, "PLAYING", 120);
}
private void game(long userId, long appid, String status, int minutes) {
jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes) values (?, ?, ?, ?)",
userId, appid, status, minutes);
}
private QuestionFacts only(long appid) {
List<QuestionFacts> facts = repository.facts(USER, appid);
assertThat(facts).hasSize(1);
return facts.get(0);
}
@Test
void facts_ReadTheGameItsPlaytimeDayAndTheStatusChange() {
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, '2026-05-01', 10, 30)", USER);
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, '2026-06-02', 10, 30)", USER);
QuestionFacts facts = only(10);
assertThat(facts.name()).isEqualTo("Game 10");
assertThat(facts.imageUrl()).isEqualTo("img10");
assertThat(facts.status()).isEqualTo(GameStatus.PLAYING);
assertThat(facts.playtimeMinutes()).isEqualTo(120);
assertThat(facts.lastPlayDay()).isEqualTo(LocalDate.of(2026, 6, 2));
assertThat(facts.lastStatusChange()).isNotNull();
assertThat(facts.firstSeenAt()).isNotNull();
assertThat(facts.normallyMinutes()).isNull();
assertThat(facts.endless()).isNull();
assertThat(facts.rateSkipped()).isFalse();
}
@Test
void facts_ReadTimeToBeatAndEndless() {
jdbc.update("insert into game_time_to_beat (appid, normally_minutes, fetched_at) values (10, 900, now())");
jdbc.update("insert into game_mood (appid, moods, checked_at, endless) values (10, '{}', now(), true)");
QuestionFacts facts = only(10);
assertThat(facts.normallyMinutes()).isEqualTo(900);
assertThat(facts.endless()).isTrue();
}
@Test
void facts_ReadTheSnoozes() {
repository.snooze(USER, 10, Question.START, 100);
repository.snooze(USER, 10, Question.FINISHED, 700);
repository.snooze(USER, 10, Question.DROPPED, 120);
repository.snooze(USER, 10, Question.RATE, 120);
QuestionFacts facts = only(10);
assertThat(facts.startAskedAt()).isEqualTo(100);
assertThat(facts.finishedAskedAt()).isEqualTo(700);
assertThat(facts.droppedAnsweredAt()).isNotNull();
assertThat(facts.rateSkipped()).isTrue();
}
@Test
void facts_OnlyTheCountedGamesOfTheUser() {
game(USER, 20, "BACKLOG", 0);
game(USER, 30, "BACKLOG", 0);
game(OTHER_USER, 40, "BACKLOG", 0);
jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = 20", USER);
jdbc.update("update user_game set removed_at = now() where user_id = ? and appid = 30", USER);
assertThat(repository.facts(USER, null)).extracting(QuestionFacts::appid).containsExactly(10L);
}
@Test
void facts_LinkedSetGivesOnlyTheCountedEdition() {
game(USER, 20, "BACKLOG", 0);
jdbc.update("insert into game_link (appid, set_id) values (10, 1), (20, 1)");
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);
assertThat(repository.facts(USER, 20L)).extracting(QuestionFacts::appid).containsExactly(20L);
assertThat(repository.facts(USER, null)).hasSize(2);
assertThat(repository.facts(USER, 99L)).isEmpty();
}
@Test
void snooze_OverwritesPlaytimeAndTime() {
repository.snooze(USER, 10, Question.START, 100);
jdbc.update("update user_game_question set answered_at = now() - interval '1 day'");
repository.snooze(USER, 10, Question.START, 400);
assertThat(jdbc.queryForList("select playtime_minutes from user_game_question", Integer.class)).containsExactly(400);
assertThat(jdbc.queryForObject("select answered_at > now() - interval '1 hour' from user_game_question", Boolean.class))
.isTrue();
}
@Test
void snooze_GoesWithTheGameRow() {
repository.snooze(USER, 10, Question.START, 100);
jdbc.update("update user_game set appid = 50 where appid = 10");
assertThat(jdbc.queryForList("select appid from user_game_question", Long.class)).containsExactly(50L);
jdbc.update("delete from user_game where appid = 50");
assertThat(jdbc.queryForObject("select count(*) from user_game_question", Integer.class)).isZero();
}
@Test
void facts_ThePlayStationLastPlayedOfATitleOnASteamRowCounts() {
jdbc.update("delete from user_psn_title");
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes, last_played_at) "
+ "values (?, 'c1', 'STEAM_OWNED', 10, 'PS5', 600, '2026-10-08T20:00:00Z')", USER);
assertThat(only(10).lastPlayedAt()).isEqualTo(java.time.Instant.parse("2026-10-08T20:00:00Z"));
}
}
@@ -5,6 +5,7 @@ import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.Mood;
import core_service.modules.library.domain.RecentEntry;
import core_service.modules.library.domain.StatusCount;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
@@ -14,6 +15,7 @@ import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -26,22 +28,30 @@ import org.springframework.transaction.support.TransactionTemplate;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.tuple;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Import(GameLinkRepository.class)
class UserGameRepositoryTest extends AbstractPersistenceTest {
private static final long USER_ID = 1L;
private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin");
private static final Set<GameSource> ALL_SOURCES = EnumSet.allOf(GameSource.class);
private static final PageRequest PAGE = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "playtimeMinutes"));
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private GameLinkRepository gameLinkRepository;
@Autowired
private JdbcTemplate jdbc;
@@ -79,7 +89,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
}
private List<Long> appids(GameStatus status, String query) {
return userGameRepository.findOwnedGames(USER_ID, false, status, UserGameRepository.containsPattern(query), null, false, PAGE)
return userGameRepository.findOwnedGames(USER_ID, false, status, UserGameRepository.containsPattern(query), null, ALL_SOURCES, PAGE)
.map(UserGame::getAppid).getContent();
}
@@ -208,15 +218,37 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
@Test
void findOwnedGames_CountsAllMatchesAcrossPages() {
var page = userGameRepository.findOwnedGames(USER_ID, false, null, UserGameRepository.containsPattern("half"), null, false,
var page = userGameRepository.findOwnedGames(USER_ID, false, null, UserGameRepository.containsPattern("half"), null, ALL_SOURCES,
PageRequest.of(0, 1, Sort.by("appid")));
assertEquals(List.of(220L), page.map(UserGame::getAppid).getContent());
assertEquals(2, page.getTotalElements());
}
@Test
void findOwnedGames_CountQueryWithFilters_MatchesTheListedEntries() {
var page = userGameRepository.findOwnedGames(USER_ID, false, GameStatus.PLAYING, null, null, ALL_SOURCES,
PageRequest.of(0, 1, Sort.by("appid")));
assertEquals(1, page.getContent().size());
assertEquals(appids(GameStatus.PLAYING, null).size(), page.getTotalElements());
}
@Test
void findOwnedGames_HiddenGames_SortByTheirOwnPlaytime() {
hide(620L, 700L);
titlePlay("c1", 700L, "STEAM_OWNED", "PS5", 2000);
// hidden games are not in the entry view: they sort by their own playtime
var desc = userGameRepository.findOwnedGames(USER_ID, true, null, null, null, ALL_SOURCES,
PageRequest.of(0, 20, UserGameRepository.querySort(Sort.by(Sort.Direction.DESC, "playtimeMinutes"))));
assertEquals(List.of(620L, 700L), desc.map(UserGame::getAppid).getContent());
assertEquals(2, desc.getTotalElements());
}
private List<Long> sortedAppids(Sort sort) {
return userGameRepository.findOwnedGames(USER_ID, false, null, null, null, false, PageRequest.of(0, 20, UserGameRepository.querySort(sort)))
return userGameRepository.findOwnedGames(USER_ID, false, null, null, null, ALL_SOURCES, PageRequest.of(0, 20, UserGameRepository.querySort(sort)))
.map(UserGame::getAppid).getContent();
}
@@ -238,6 +270,49 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
assertEquals(List.of(380L, 700L, 220L, 620L), sortedAppids(Sort.by(Sort.Direction.DESC, "priority")));
}
private void titlePlay(String key, long appid, String state, String platform, int minutes) {
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes) "
+ "values (?, ?, ?, ?, ?, ?)", USER_ID, key, state, appid, platform, minutes);
}
@Test
void findOwnedGames_SortsByPlaytimeByTheEntryTotal() {
titlePlay("c1", 220L, "STEAM_OWNED", "PS5", 700);
assertEquals(List.of(220L, 620L, 380L, 700L), sortedAppids(Sort.by(Sort.Direction.DESC, "playtimeMinutes")));
assertEquals(List.of(380L, 700L, 620L, 220L), sortedAppids(Sort.by(Sort.Direction.ASC, "playtimeMinutes")));
}
@Test
void sumPlaytime_IsTheSumOfTheEntryTotals() {
titlePlay("c1", 220L, "STEAM_OWNED", "PS5", 700);
steamGameRepository.saveAllAndFlush(List.of(new SteamGame(800L, "Bloodborne", null, Instant.now()),
new SteamGame(-7L, "Manual", null, Instant.now())));
jdbc.update("insert into user_game (user_id, appid, status, source, platform, playtime_minutes) "
+ "values (?, 800, 'BACKLOG', 'PSN', 'PS5', 50)", USER_ID);
titlePlay("c2", 800L, "LINKED", "PS5", 400);
jdbc.update("insert into user_game (user_id, appid, status, source, platform, playtime_minutes) "
+ "values (?, -7, 'BACKLOG', 'MANUAL', 'PS5', 30)", USER_ID);
titlePlay("c3", -7L, "LINKED", "PS5", 90);
long sum = userGameRepository.sumPlaytime(USER_ID);
assertEquals(1000 + 900 + 400 + 90, sum);
assertEquals(sum, gameLinkRepository.entryPlaytimes(USER_ID, List.of(220L, 380L, 620L, 700L, 800L, -7L))
.values().stream().mapToLong(Integer::longValue).sum());
}
@Test
void findEntriesPlayedInTwoWeeks_ShowsTheEntryTotal() {
titlePlay("c1", 220L, "STEAM_OWNED", "PS5", 700);
userGameRepository.findById(new UserGameId(USER_ID, 220L)).orElseThrow().setPlaytime2Weeks(30);
userGameRepository.flush();
assertThat(userGameRepository.findEntriesPlayedInTwoWeeks(USER_ID, 20))
.extracting(RecentEntry::getAppid, RecentEntry::getPlaytimeMinutes)
.containsExactly(tuple(220L, 1000L));
}
@Test
void querySort_EqualPlaytime_BreaksTiesByAppid() {
assertEquals(List.of(620L, 220L, 380L, 700L), sortedAppids(Sort.by(Sort.Direction.DESC, "playtimeMinutes")));
@@ -267,7 +342,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
new GameTimeToBeat(620L, 300, 480, 900, Instant.now()),
new GameTimeToBeat(380L, null, null, null, Instant.now())));
List<Long> shortGames = userGameRepository.findOwnedGames(USER_ID, false, null, null, 600, false, PAGE).map(UserGame::getAppid).getContent();
List<Long> shortGames = userGameRepository.findOwnedGames(USER_ID, false, null, null, 600, ALL_SOURCES, PAGE).map(UserGame::getAppid).getContent();
assertEquals(List.of(620L), shortGames);
}
@@ -294,7 +369,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
hide(620L, 700L);
assertEquals(List.of(220L, 380L), appids(null, null));
assertEquals(List.of(620L, 700L), userGameRepository.findOwnedGames(USER_ID, true, null, null, null, false,
assertEquals(List.of(620L, 700L), userGameRepository.findOwnedGames(USER_ID, true, null, null, null, ALL_SOURCES,
PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent());
assertEquals(Set.of(620L, 700L), Set.copyOf(userGameRepository.findHiddenAppids(USER_ID)));
}
@@ -315,8 +390,8 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
assertEquals(Set.of(new StatusCount(GameStatus.PLAYING, 1), new StatusCount(GameStatus.BACKLOG, 1)),
Set.copyOf(userGameRepository.countByStatus(USER_ID)));
assertEquals(List.of(380L), userGameRepository.findBacklogAppids(USER_ID));
assertEquals(List.of(220L), userGameRepository.findListedPlayedInTwoWeeks(USER_ID, PAGE)
.stream().map(UserGame::getAppid).toList());
assertEquals(List.of(220L), userGameRepository.findEntriesPlayedInTwoWeeks(USER_ID, 20)
.stream().map(RecentEntry::getAppid).toList());
assertEquals(List.of(220L), userGameRepository.findListedByStatusIn(USER_ID, List.of(GameStatus.PLAYING), PAGE)
.stream().map(UserGame::getAppid).toList());
assertEquals(Set.of(220L, 380L), Set.copyOf(userGameRepository.findAchievementProgress(USER_ID)
@@ -359,6 +434,58 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
Set.copyOf(userGameRepository.countByStatus(USER_ID)));
}
private void setSource(GameSource source, long... appids) {
entityManager.flush();
for (long appid : appids) {
jdbc.update("update user_game set source = ? where user_id = ? and appid = ?", source.name(), USER_ID, appid);
}
entityManager.clear();
}
private List<Long> appidsOfSources(GameSource... sources) {
return userGameRepository.findOwnedGames(USER_ID, false, null, null, null, Set.of(sources),
PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent();
}
@Test
void findOwnedGames_BySource_OnlyRowsOfTheSource() {
setSource(GameSource.PSN, 380L, 700L);
assertEquals(List.of(380L, 700L), appidsOfSources(GameSource.PSN));
assertEquals(List.of(220L, 620L), appidsOfSources(GameSource.STEAM));
assertEquals(List.of(), appidsOfSources(GameSource.MANUAL));
}
@Test
void findOwnedGames_BySource_SeveralSourcesMatchEither() {
setSource(GameSource.PSN, 380L);
setSource(GameSource.MANUAL, 700L);
assertEquals(List.of(220L, 380L, 620L), appidsOfSources(GameSource.STEAM, GameSource.PSN));
assertEquals(List.of(380L, 700L), appidsOfSources(GameSource.PSN, GameSource.MANUAL));
}
@Test
void findOwnedGames_BySource_FollowsASteamTakeOver() {
setSource(GameSource.MANUAL, 700L);
assertEquals(List.of(700L), appidsOfSources(GameSource.MANUAL));
setSource(GameSource.STEAM, 700L);
assertEquals(List.of(), appidsOfSources(GameSource.MANUAL));
assertEquals(List.of(220L, 380L, 620L, 700L), appidsOfSources(GameSource.STEAM));
}
@Test
void findSources_DistinctNotRemoved() {
setSource(GameSource.PSN, 380L, 700L);
setSource(GameSource.MANUAL, 620L);
jdbc.update("update user_game set removed_at = now() where user_id = ? and appid = ?", USER_ID, 620L);
assertEquals(Set.of(GameSource.STEAM, GameSource.PSN), Set.copyOf(userGameRepository.findSources(USER_ID)));
assertEquals(2, userGameRepository.findSources(USER_ID).size());
}
private void link(long... appids) {
jdbc.update("delete from game_link");
for (long appid : appids) {
@@ -367,19 +494,99 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
}
@Test
void linkedBacklogEditions_CountOnceAndTheOnceFilterKeepsOne() {
void linkedBacklogEditions_CountOnceAndTheListKeepsOne() {
link(380L, 700L);
userGameRepository.flush();
assertEquals(Set.of(new StatusCount(GameStatus.PLAYING, 2), new StatusCount(GameStatus.BACKLOG, 1)),
Set.copyOf(userGameRepository.countByStatus(USER_ID)));
assertEquals(1, userGameRepository.countListedNeverPlayed(USER_ID));
assertEquals(List.of(220L, 380L, 620L), userGameRepository.findOwnedGames(USER_ID, false, null, null, null, true,
PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent());
assertEquals(List.of(220L, 380L, 620L, 700L), userGameRepository.findOwnedGames(USER_ID, false, null, null, null, false,
assertEquals(List.of(220L, 380L, 620L), userGameRepository.findOwnedGames(USER_ID, false, null, null, null, ALL_SOURCES,
PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent());
}
private List<Long> listed(boolean hidden, String query) {
return userGameRepository.findOwnedGames(USER_ID, hidden, null, UserGameRepository.containsPattern(query), null,
ALL_SOURCES, PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent();
}
@Test
void findOwnedGames_ASetIsOneRow_TheCountedEdition() {
link(220L, 380L);
userGameRepository.flush();
assertEquals(List.of(220L, 620L, 700L), listed(false, null));
}
@Test
void findOwnedGames_TheNameOfTheLesserEditionFindsTheEntry() {
link(220L, 380L);
userGameRepository.flush();
assertEquals(List.of(220L), listed(false, "episode"));
}
@Test
void findOwnedGames_BySource_FindsASteamRowWithAPlayStationTitle() {
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'STEAM_OWNED', 620)", USER_ID);
assertEquals(List.of(620L), appidsOfSources(GameSource.PSN));
}
@Test
void findOwnedGames_BySource_FindsAnEntryThroughItsLesserEdition() {
link(220L, 380L);
setSource(GameSource.PSN, 380L);
assertEquals(List.of(220L), appidsOfSources(GameSource.PSN));
}
@Test
void findOwnedGames_Hidden_ListsEveryHiddenRow() {
link(220L, 380L);
hide(220L, 380L);
assertEquals(List.of(220L, 380L), listed(true, null));
assertEquals(List.of(380L), listed(true, "episode"));
}
@Test
void findEntriesPlayedInTwoWeeks_SumsTheSetAndKeepsAnEntryPlayedOnlyOnTheLesserEdition() {
link(220L, 380L);
userGameRepository.findById(new UserGameId(USER_ID, 220L)).orElseThrow().setPlaytime2Weeks(30);
userGameRepository.findById(new UserGameId(USER_ID, 380L)).orElseThrow().setPlaytime2Weeks(40);
userGameRepository.findById(new UserGameId(USER_ID, 620L)).orElseThrow().setPlaytime2Weeks(50);
userGameRepository.flush();
var recent = userGameRepository.findEntriesPlayedInTwoWeeks(USER_ID, 20);
assertEquals(List.of(220L, 620L), recent.stream().map(RecentEntry::getAppid).toList());
assertEquals(70L, recent.getFirst().getPlaytime2Weeks());
assertEquals(300L, recent.getFirst().getPlaytimeMinutes());
userGameRepository.findById(new UserGameId(USER_ID, 220L)).orElseThrow().setPlaytime2Weeks(0);
userGameRepository.flush();
assertEquals(List.of(620L, 220L), userGameRepository.findEntriesPlayedInTwoWeeks(USER_ID, 20)
.stream().map(RecentEntry::getAppid).toList());
}
@Test
void countListed_CountsASetOnce() {
link(220L, 380L);
userGameRepository.flush();
assertEquals(3, userGameRepository.countListed(USER_ID));
}
@Test
void findListedByStatusIn_ListsASetOnce() {
link(220L, 380L);
userGameRepository.flush();
assertEquals(List.of(220L, 620L), userGameRepository.findListedByStatusIn(USER_ID, List.of(GameStatus.PLAYING), PAGE)
.stream().map(UserGame::getAppid).sorted().toList());
}
@Test
void backlogEditionOfAPlayingSet_IsNeverPicked() {
link(220L, 380L);
@@ -179,6 +179,35 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
.containsExactly(620L, 220L);
}
private void seedEntryTotal() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 100, GameStatus.PLAYING, Instant.now()),
game(620L, 300, GameStatus.PLAYING, Instant.now())));
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes) "
+ "values (?, 'c1', 'STEAM_OWNED', 220, 'PS5', 500)", USER_ID);
}
@Test
void mostPlayed_UsesTheEntryTotal() {
seedEntryTotal();
assertThat(repository.mostPlayed(USER_ID, 5)).extracting(UserStats.GameMinutes::appid, UserStats.GameMinutes::minutes)
.containsExactly(tuple(220L, 600L), tuple(620L, 300L));
}
@Test
void games_ListsShowTheEntryTotal() {
seedEntryTotal();
for (StatKey key : List.of(StatKey.MOST_PLAYED, StatKey.TOTAL)) {
assertThat(repository.games(USER_ID, key, "", BERLIN, 0, 10))
.extracting(UserStats.StatGame::appid, UserStats.StatGame::playtimeMinutes)
.contains(tuple(220L, 600L));
}
assertThat(repository.games(USER_ID, StatKey.MOST_PLAYED, "", BERLIN, 0, 10))
.extracting(UserStats.StatGame::appid).containsExactly(220L, 620L);
}
@Test
void mostLoyal_MostMonthsWins_MoreMinutesBreakATie() {
userGameRepository.saveAllAndFlush(List.of(
@@ -374,6 +403,81 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.games(USER_ID, StatKey.TOTAL, "", BERLIN, 0, 10)).hasSize(1);
}
private void seedSet() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 300, GameStatus.PLAYING, Instant.now()),
game(620L, 900, GameStatus.PLAYING, Instant.now()),
game(700L, 500, GameStatus.PLAYING, Instant.now())));
link(220L, 620L);
}
@Test
void mostPlayed_ASetIsOneRowWithTheSumOfItsEditions() {
seedSet();
assertThat(repository.mostPlayed(USER_ID, 5)).extracting(UserStats.GameMinutes::minutes)
.containsExactly(1200L, 500L);
assertThat(repository.games(USER_ID, StatKey.MOST_PLAYED, "", BERLIN, 0, 10))
.extracting(UserStats.StatGame::minutes).containsExactly(1200L, 500L);
}
@Test
void mostLoyal_CountsTheMonthsOfEveryEditionOfASet() {
seedSet();
addDay(220L, LocalDate.of(2026, 7, 1), 10);
addDay(620L, LocalDate.of(2026, 8, 1), 10);
addDay(620L, LocalDate.of(2026, 9, 1), 10);
addDay(700L, LocalDate.of(2026, 7, 3), 30);
addDay(700L, LocalDate.of(2026, 8, 3), 30);
UserStats.LoyalGame loyal = repository.mostLoyal(USER_ID).orElseThrow();
assertThat(loyal.months()).isEqualTo(3);
assertThat(loyal.minutes()).isEqualTo(30);
}
@Test
void genresAndMoods_CountASetOnceWithTheSummedMinutes() {
seedSet();
for (long appid : List.of(220L, 620L, 700L)) {
gameMoodsRepository.upsert(appid, new String[]{"ACTION"}, new String[]{"Shooter"}, false, Instant.now());
}
assertThat(repository.genres(USER_ID, 8)).containsExactly(new UserStats.TagCount("Shooter", 2, 1700));
assertThat(repository.moods(USER_ID)).containsExactly(new UserStats.TagCount("ACTION", 2, 1700));
assertThat(repository.countGames(USER_ID, StatKey.GENRE, "Shooter", BERLIN)).isEqualTo(2);
assertThat(repository.games(USER_ID, StatKey.GENRE, "Shooter", BERLIN, 0, 10))
.extracting(UserStats.StatGame::minutes).containsExactly(1200L, 500L);
}
@Test
void games_PerfectListsASetOnce() {
seedSet();
assertThat(repository.countGames(USER_ID, StatKey.PERFECT, List.of(220L, 620L), BERLIN)).isEqualTo(1);
assertThat(repository.games(USER_ID, StatKey.PERFECT, List.of(220L, 620L), BERLIN, 0, 10)).hasSize(1);
}
@Test
void games_LongestDayListsASetOnceWithTheSumOfItsEditions() {
seedSet();
LocalDate day = LocalDate.of(2026, 7, 1);
addDay(220L, day, 100);
addDay(620L, day, 100);
assertThat(repository.countGames(USER_ID, StatKey.LONGEST_DAY, day, BERLIN)).isEqualTo(1);
assertThat(repository.games(USER_ID, StatKey.LONGEST_DAY, day, BERLIN, 0, 10))
.extracting(UserStats.StatGame::minutes).containsExactly(200L);
}
@Test
void platformStat_ASetCountsOnEveryPlatformItHas() {
seedSet();
assertThat(repository.gamesPerPlatform(USER_ID)).containsExactly(new UserStats.TagCount("Steam", 3, 1700));
assertThat(repository.games(USER_ID, StatKey.PLATFORM, "Steam", BERLIN, 0, 10)).hasSize(3);
}
private void comingTomorrow(long appid) {
jdbc.update("insert into game_release (appid, release_date, fetched_at) values (?, ?, now())", appid,
Date.valueOf(LocalDate.now(BERLIN).plusDays(1)));
@@ -2,6 +2,7 @@ package core_service.modules.library.service;
import core_service.modules.library.api.dto.AchievementDto;
import core_service.modules.library.api.dto.GameDetailDto;
import core_service.modules.library.api.dto.PlatformPlayDto;
import core_service.modules.library.domain.AchievementKind;
import core_service.modules.library.domain.PsnEarned;
import core_service.modules.library.domain.PsnTrophy;
@@ -17,6 +18,7 @@ import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievementMeta;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameLinkRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.GameAchievementSchemaRepository;
import core_service.modules.library.repo.GameSpeedrunRepository;
@@ -34,6 +36,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.client.ResourceAccessException;
import java.sql.Timestamp;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
@@ -86,6 +89,9 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest {
@Autowired
private PsnTrophyRepository psnTrophyRepository;
@Autowired
private GameLinkRepository gameLinkRepository;
@Autowired
private JdbcTemplate jdbc;
@@ -99,6 +105,8 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest {
gameSpeedrunRepository.deleteAll();
gameTimeToBeatRepository.deleteAll();
gameMoodsRepository.deleteAll();
jdbc.update("delete from user_psn_title");
jdbc.update("delete from game_link");
jdbc.update("delete from user_psn_trophy");
jdbc.update("delete from user_psn_trophy_set");
jdbc.update("delete from psn_trophy");
@@ -292,4 +300,44 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest {
assertEquals(4, detail.achievements().size());
assertEquals(1, detail.achievementsTotal());
}
@Test
void getGameDetail_ASteamAndAPlayStationEdition_HasOneLinePerPlatformWithItsOwnPlayAndProgress() {
Instant steamPlayed = Instant.parse("2026-10-01T10:00:00Z");
Instant psnPlayed = Instant.parse("2026-10-05T18:00:00Z");
UserGame steam = userGameRepository.findById(new UserGameId(USER_ID, 620L)).orElseThrow();
steam.setPlaytimeMinutes(500);
steam.setLastPlayedAt(steamPlayed);
userGameRepository.save(steam);
schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 3, true, Instant.now()));
for (String name : List.of("A", "B", "C")) {
schemaRepository.save(new GameAchievementSchema(620L, name, name, null, null, null, false));
}
userAchievementMetaRepository.save(new UserGameAchievementMeta(USER_ID, 620L, Instant.now(), true, 2, 60));
steamGameRepository.save(new SteamGame(621L, "Portal 2 (PS5)", null, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, 621L, GameSource.PSN, "PS5", GameStatus.PLAYING, 300, Instant.now()));
gameLinkRepository.link(620L, 621L);
psnTrophyRepository.saveSet(USER_ID, 621L, new PsnTrophySet("NPWR9_00", "trophy2", "01.00", "Portal 2", null, "PS5",
2, 0, 0, 1, 2, false, Instant.now()));
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes, last_played_at) "
+ "values (?, 'c9', 'LINKED', 621, 'PS5', 300, ?)", USER_ID, Timestamp.from(psnPlayed));
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, null, 620L).orElseThrow();
assertEquals(List.of(
new PlatformPlayDto(GameSource.STEAM, null, 620L, 500, steamPlayed, 2, 3, false),
new PlatformPlayDto(GameSource.PSN, "PS5", 621L, 300, psnPlayed, 2, 3, false)), detail.platforms());
assertEquals(800, detail.totalPlaytimeMinutes());
assertEquals(500, detail.playtimeMinutes());
}
@Test
void getGameDetail_AGameOfOnePlatform_HasOneLineAndItsOwnMinutesAsTotal() {
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, null, 620L).orElseThrow();
assertEquals(1, detail.platforms().size());
assertEquals(GameSource.STEAM, detail.platforms().getFirst().source());
assertEquals(60, detail.platforms().getFirst().playtimeMinutes());
assertEquals(60, detail.totalPlaytimeMinutes());
}
}
@@ -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());
}
}
@@ -61,7 +61,7 @@ class LibrarySyncWorkerConcurrencyTest extends AbstractMockedAdaptersTest {
void runSyncNow_TheFollowUpStoresTheSpeedrunRow() {
when(steamApiService.getWishlist(STEAM_ID)).thenReturn(Optional.empty());
when(steamApiService.getOwnedGames(STEAM_ID))
.thenReturn(List.of(new SteamOwnedGamesResponse.Game((int) APPID, "Half-Life 2", 0, "icon", true, 0)));
.thenReturn(List.of(new SteamOwnedGamesResponse.Game((int) APPID, "Half-Life 2", 0, "icon", true, 0, null)));
when(speedrunApiService.isEnabled()).thenReturn(true);
when(speedrunApiService.findGames("Half-Life 2")).thenReturn(Optional.of(List.of(
new SpeedrunApiService.Game("hl2", "Half-Life 2", 2004, "https://www.speedrun.com/hl2"))));
@@ -153,6 +153,6 @@ class LibrarySyncWorkerConcurrencyTest extends AbstractMockedAdaptersTest {
}
private static SteamOwnedGamesResponse.Game game(String iconUrl) {
return new SteamOwnedGamesResponse.Game((int) APPID, "Half-Life 2", 60, iconUrl, true, 0);
return new SteamOwnedGamesResponse.Game((int) APPID, "Half-Life 2", 60, iconUrl, true, 0, null);
}
}
@@ -68,7 +68,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
private static final String OTHER_STEAM_ID = "76561198005679790";
private static SteamOwnedGamesResponse.Game game(int playtime, int playtime2Weeks) {
return new SteamOwnedGamesResponse.Game(220, "Half-Life 2", playtime, "icon", true, playtime2Weeks);
return new SteamOwnedGamesResponse.Game(220, "Half-Life 2", playtime, "icon", true, playtime2Weeks, null);
}
private static LocalDate today() {
@@ -297,6 +297,44 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 120);
}
private static SteamOwnedGamesResponse.Game gameLastPlayed(Long rtimeLastPlayed) {
return new SteamOwnedGamesResponse.Game(220, "Half-Life 2", 100, "icon", true, 0, rtimeLastPlayed);
}
@Test
void runSync_StoresSteamsLastPlayed() {
UserGame existing = owned(100);
steamReturns(gameLastPlayed(1_760_000_000L), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
assertEquals(Instant.ofEpochSecond(1_760_000_000L), existing.getLastPlayedAt());
}
@Test
void runSync_ZeroOrAbsentLastPlayed_KeepsTheOldValue() {
Instant old = Instant.parse("2026-10-05T18:00:00Z");
for (Long none : new Long[]{0L, null}) {
UserGame existing = owned(100);
existing.setLastPlayedAt(old);
steamReturns(gameLastPlayed(none), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
assertEquals(old, existing.getLastPlayedAt());
}
}
@Test
void runSync_NewSteamGame_GetsLastPlayedFromSteam() {
steamReturns(gameLastPlayed(1_760_000_000L), null);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(userGameRepository).save(argThat((UserGame created) ->
Instant.ofEpochSecond(1_760_000_000L).equals(created.getLastPlayedAt())));
}
@Test
void runSync_PlaytimeGrew_RecordsTheDifferenceForToday() {
owned(100);
@@ -618,7 +656,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
void runSync_GameWithoutName_GetsTheAppidAsName() {
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
new SteamOwnedGamesResponse.Game(2000001, null, 12, null, null, null),
new SteamOwnedGamesResponse.Game(2000001, null, 12, null, null, null, null),
game(90, 0)));
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
@@ -633,7 +671,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
when(steamGameRepository.findAllById(List.of(2000001L))).thenReturn(List.of(stored));
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
new SteamOwnedGamesResponse.Game(2000001, " ", 12, null, null, null)));
new SteamOwnedGamesResponse.Game(2000001, " ", 12, null, null, null, null)));
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
@@ -673,7 +711,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
}
private static SteamOwnedGamesResponse.Game gameOf(int appid, int playtime) {
return new SteamOwnedGamesResponse.Game(appid, "Game " + appid, playtime, "icon", true, playtime);
return new SteamOwnedGamesResponse.Game(appid, "Game " + appid, playtime, "icon", true, playtime, null);
}
@Test
@@ -7,6 +7,7 @@ import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.IgdbGame;
import core_service.modules.library.domain.PsnTitle;
import core_service.modules.library.domain.SyncChange;
import core_service.modules.library.domain.TitlePlay;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.GameImportRepository;
@@ -195,6 +196,38 @@ class PsnSyncStepTest extends AbstractUnitTest {
verifyNoInteractions(manualGameService);
}
@Test
void run_SavesThePlayOfALinkedAndASteamOwnedTitle_AndGivesNoPlaytimeToTheSteamRow() {
PsnApiService.Title other = new PsnApiService.Title("PPSA2_00", "Other", "ps5_native_game", Duration.ofMinutes(30), 8L, null);
fetches(ghost(), other);
when(psnTitleRepository.findAll(USER)).thenReturn(Map.of(
"c7", new PsnTitle("c7", PsnTitle.State.LINKED, -1L),
"c8", new PsnTitle("c8", PsnTitle.State.STEAM_OWNED, 100L)));
PsnSyncStep.Prepared prepared = step.run(USER);
ArgumentCaptor<List<TitlePlay>> plays = ArgumentCaptor.captor();
verify(psnTitleRepository).savePlays(eq(USER), plays.capture());
assertThat(plays.getValue()).containsExactlyInAnyOrder(new TitlePlay("c7", null, "PS5", 120, LAST_PLAYED),
new TitlePlay("c8", null, "PS5", 30, null));
assertThat(prepared.playtimes()).extracting(PsnSyncStep.Playtime::appid).containsExactly(-1L);
}
@Test
void run_ATitleLinkedInThisRunIsAlsoInTheSavedPlay() {
fetches(ghost());
IgdbGame game = igdb(1, "Ghost of Yotei", "PS5");
when(manualGameService.findGames("Ghost of Yotei")).thenReturn(Optional.of(List.of(game)));
when(manualGameService.addGame(eq(USER), eq(game), eq("PS5"), eq(GameStatus.PLAYING), eq(120),
eq(GameSource.PSN), isNull())).thenReturn(new ManualGameService.AddResult.Added(-1L));
step.run(USER);
ArgumentCaptor<List<TitlePlay>> plays = ArgumentCaptor.captor();
verify(psnTitleRepository).savePlays(eq(USER), plays.capture());
assertThat(plays.getValue()).containsExactly(new TitlePlay("c7", null, "PS5", 120, LAST_PLAYED));
}
@Test
void run_DismissedTitleAndTitleOfAnOpenImport_AreNotSearchedNorOffered() {
PsnApiService.Title other = new PsnApiService.Title("PPSA2_00", "Other", "ps5_native_game", Duration.ZERO, 8L, null);
@@ -0,0 +1,107 @@
package core_service.modules.rewards.api;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
/**
* The back pay cutoff: unproven finishes before app.rewards.early-since get no early points. The dev profile
* authenticates every request as user 1.
*/
@AutoConfigureMockMvc
@TestPropertySource(properties = "app.rewards.early-since=2026-09-10")
class RewardControllerEarlySinceTest extends AbstractIntegrationTest {
private static final long USER = 1L;
private static final long GAME = 4_100_101L;
private static final long OLD = 4_100_102L;
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void clean() {
jdbc.update("delete from reward_ledger where user_id = ?", USER);
jdbc.update("delete from user_game_playtime_day where user_id = ?", USER);
jdbc.update("delete from user_game where user_id = ?", USER);
}
private void finished(long appid, String source, String at) {
jdbc.update("insert into steam_game (appid, name) values (?, ?) on conflict do nothing", appid, "Game " + appid);
jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes, source) values (?, ?, 'BACKLOG', 120, ?)",
USER, appid, source);
jdbc.update("update user_game set status = 'FINISHED' where user_id = ? and appid = ?", USER, appid);
jdbc.update("update user_game_status_change set changed_at = ?::timestamptz where user_id = ? and appid = ?",
at, USER, appid);
}
private ResultActions gamePoints(long appid) throws Exception {
return mockMvc.perform(get("/api/rewards/games/" + appid));
}
private int rows(String kind) {
Integer count = jdbc.queryForObject("select count(*) from reward_ledger where user_id = ? and kind = ?",
Integer.class, USER, kind);
return count != null ? count : 0;
}
@Test
void gamePoints_FinishBeforeTheCutoff_NoEarlyRowAndSaysNoProof() throws Exception {
finished(GAME, "STEAM", "2026-09-09T20:00:00Z");
gamePoints(GAME)
.andExpect(jsonPath("$.paid").isEmpty())
.andExpect(jsonPath("$.missing.FINISHED").value("NO_PROOF"));
}
@Test
void gamePoints_FinishOnTheCutoffDay_PaysTenEarly() throws Exception {
finished(GAME, "STEAM", "2026-09-10T08:00:00Z");
gamePoints(GAME)
.andExpect(jsonPath("$.paid[0].kind").value("FINISHED_EARLY"))
.andExpect(jsonPath("$.paid[0].points").value(10));
}
@Test
void gamePoints_FinishBeforeTheCutoffThenProof_PaysFullFinishedNoTopUp() throws Exception {
finished(GAME, "STEAM", "2026-09-01T10:00:00Z");
gamePoints(GAME).andExpect(jsonPath("$.paid").isEmpty());
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, current_date, ?, 30)",
USER, GAME);
gamePoints(GAME)
.andExpect(jsonPath("$.paid.length()").value(1))
.andExpect(jsonPath("$.paid[0].kind").value("FINISHED"))
.andExpect(jsonPath("$.paid[0].points").value(30));
assertThat(rows("FINISHED_TOPUP")).isZero();
}
@Test
void gamePoints_OldUnprovenFinishDoesNotUseTheWeeklyCap() throws Exception {
// Monday and Tuesday of one ISO week, both before the cutoff.
finished(OLD, "STEAM", "2026-09-07T10:00:00Z");
for (long appid = -4_100_110L; appid > -4_100_113L; appid--) {
finished(appid, "MANUAL", "2026-09-08T10:00:00Z");
}
mockMvc.perform(get("/api/rewards/wallet"));
assertThat(rows("FINISHED_MANUAL")).isEqualTo(3);
assertThat(rows("FINISHED_EARLY")).isZero();
}
}
@@ -34,6 +34,8 @@ class RewardControllerTest extends AbstractIntegrationTest {
private static final long USER = 1L;
private static final long ADMIN = 2L;
private static final long GAME = 4_100_001L;
private static final long EDITION = 4_100_002L;
private static final long OTHER_USER = 99L;
@Autowired
private MockMvc mockMvc;
@@ -49,7 +51,11 @@ class RewardControllerTest extends AbstractIntegrationTest {
jdbc.update("delete from reward_ledger where user_id in (?, ?)", USER, ADMIN);
jdbc.update("delete from reward_equipped where user_id in (?, ?)", USER, ADMIN);
jdbc.update("delete from user_game_playtime_day where user_id = ?", USER);
jdbc.update("delete from user_game where user_id = ?", USER);
jdbc.update("delete from user_game where user_id in (?, ?)", USER, OTHER_USER);
jdbc.update("delete from user_psn_title where user_id = ?", USER);
jdbc.update("delete from game_link where appid in (?, ?)", GAME, EDITION);
jdbc.update("delete from game_achievement_schema_meta where appid = ?", GAME);
jdbc.update("delete from reward_ledger where user_id = ?", OTHER_USER);
}
private void game(long appid, String source, String status, int playtime) {
@@ -95,14 +101,15 @@ class RewardControllerTest extends AbstractIntegrationTest {
}
@Test
void wallet_HandSetFinishedWithoutPlaytime_PaysNothingEvenWithTheEndingProof() throws Exception {
void wallet_HandSetFinishedWithoutPlaytime_PaysTenEarlyEvenWithTheEndingProof() throws Exception {
game(GAME, "STEAM", "BACKLOG", 0);
jdbc.update("update user_game set finished_at_estimate = now() where user_id = ?", USER);
setStatus(GAME, "FINISHED");
mockMvc.perform(get("/api/rewards/wallet")).andExpect(jsonPath("$.balance").value(0));
mockMvc.perform(get("/api/rewards/wallet")).andExpect(jsonPath("$.balance").value(10));
assertThat(rows("FINISHED")).isZero();
assertThat(rows("FINISHED_EARLY")).isEqualTo(1);
}
@Test
@@ -117,6 +124,451 @@ class RewardControllerTest extends AbstractIntegrationTest {
assertThat(rows("FINISHED_MANUAL")).isEqualTo(3);
}
@Test
void gamePoints_ProvenSteamFinish_PaysOnTheCallAndSaysBookedNow() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, current_date, ?, 30)",
USER, GAME);
setStatus(GAME, "FINISHED");
mockMvc.perform(get("/api/rewards/games/" + GAME))
.andExpect(status().isOk())
.andExpect(jsonPath("$.balance").value(30))
.andExpect(jsonPath("$.paid.length()").value(1))
.andExpect(jsonPath("$.paid[0].kind").value("FINISHED"))
.andExpect(jsonPath("$.paid[0].points").value(30))
.andExpect(jsonPath("$.paid[0].bookedNow").value(true));
}
private void playedToday(long appid) {
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, current_date, ?, 30)",
USER, appid);
}
private ResultActions gamePoints(long appid) throws Exception {
return mockMvc.perform(get("/api/rewards/games/" + appid));
}
@Test
void gamePoints_SecondCall_SameRowNotBookedNow() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
playedToday(GAME);
setStatus(GAME, "FINISHED");
gamePoints(GAME).andExpect(jsonPath("$.paid[0].bookedNow").value(true));
gamePoints(GAME)
.andExpect(jsonPath("$.paid.length()").value(1))
.andExpect(jsonPath("$.paid[0].bookedNow").value(false))
.andExpect(jsonPath("$.balance").value(30));
}
@Test
void gamePoints_SteamFinishWithoutProof_PaysTenEarly() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
setStatus(GAME, "FINISHED");
gamePoints(GAME)
.andExpect(jsonPath("$.paid.length()").value(1))
.andExpect(jsonPath("$.paid[0].kind").value("FINISHED_EARLY"))
.andExpect(jsonPath("$.paid[0].points").value(10))
.andExpect(jsonPath("$.missing.FINISHED_TOPUP").value("NO_PROOF"));
}
@Test
void gamePoints_FourthManualFinishInAWeek_MissingFinishIsWeeklyLimit() throws Exception {
for (long appid = -4_100_010L; appid > -4_100_014L; appid--) {
game(appid, "MANUAL", "BACKLOG", 0);
setStatus(appid, "FINISHED");
}
gamePoints(-4_100_013L)
.andExpect(jsonPath("$.paid").isEmpty())
.andExpect(jsonPath("$.missing.FINISHED").value("WEEKLY_LIMIT"));
gamePoints(-4_100_010L).andExpect(jsonPath("$.paid[0].kind").value("FINISHED_MANUAL"));
}
@Test
void gamePoints_OtherEditionPaid_ListsItsRow() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
game(EDITION, "STEAM", "BACKLOG", 0);
jdbc.update("insert into game_link (appid, set_id) values (?, 4100), (?, 4100)", GAME, EDITION);
playedToday(GAME);
jdbc.update("update user_game set status = 'FINISHED' where user_id = ?", USER);
gamePoints(EDITION)
.andExpect(jsonPath("$.paid.length()").value(1))
.andExpect(jsonPath("$.paid[0].kind").value("FINISHED"))
.andExpect(jsonPath("$.paid[0].appid").value(GAME))
.andExpect(jsonPath("$.missing.FINISHED").doesNotExist());
}
private void allAchievements() {
jdbc.update("insert into game_achievement_schema_meta (appid, achievements_total, has_schema) values (?, 10, true)",
GAME);
jdbc.update("insert into user_game_achievement_meta (user_id, appid, achievements_unlocked) values (?, ?, 10)"
+ " on conflict (user_id, appid) do update set achievements_unlocked = 10", USER, GAME);
}
@Test
void gamePoints_StartedFinishedCompleted_ListsAllThreeRowsWithDates() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
playedToday(GAME);
allAchievements();
setStatus(GAME, "PLAYING");
setStatus(GAME, "FINISHED");
setStatus(GAME, "COMPLETED");
gamePoints(GAME)
.andExpect(jsonPath("$.paid.length()").value(3))
.andExpect(jsonPath("$.paid[?(@.kind == 'STARTED')].points").value(5))
.andExpect(jsonPath("$.paid[?(@.kind == 'FINISHED')].points").value(30))
.andExpect(jsonPath("$.paid[?(@.kind == 'COMPLETED')].points").value(40))
.andExpect(jsonPath("$.paid[0].paidAt").isNotEmpty())
.andExpect(jsonPath("$.missing").isEmpty())
.andExpect(jsonPath("$.balance").value(75));
}
@Test
void gamePoints_CompletedWithoutAllAchievements_MissingCompletedIsNotAllAchievements() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
playedToday(GAME);
setStatus(GAME, "FINISHED");
setStatus(GAME, "COMPLETED");
gamePoints(GAME)
.andExpect(jsonPath("$.paid.length()").value(1))
.andExpect(jsonPath("$.paid[0].kind").value("FINISHED"))
.andExpect(jsonPath("$.missing.COMPLETED").value("NOT_ALL_ACHIEVEMENTS"));
}
@Test
void gamePoints_HiddenGame_OneHiddenReasonOnly() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
playedToday(GAME);
setStatus(GAME, "FINISHED");
jdbc.update("update user_game set hidden_at = now() where user_id = ?", USER);
gamePoints(GAME)
.andExpect(jsonPath("$.paid").isEmpty())
.andExpect(jsonPath("$.missing.length()").value(1))
.andExpect(jsonPath("$.missing.FINISHED").value("HIDDEN"));
}
@Test
void gamePoints_HiddenAfterPaidStart_MissingFinishIsHidden() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
playedToday(GAME);
setStatus(GAME, "PLAYING");
gamePoints(GAME).andExpect(jsonPath("$.paid[0].kind").value("STARTED"));
jdbc.update("update user_game set hidden_at = now() where user_id = ?", USER);
setStatus(GAME, "FINISHED");
gamePoints(GAME).andExpect(jsonPath("$.missing.FINISHED").value("HIDDEN"));
}
@Test
void gamePoints_HiddenAfterPaidFinish_MissingCompletedIsHidden() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
playedToday(GAME);
setStatus(GAME, "FINISHED");
gamePoints(GAME).andExpect(jsonPath("$.paid[0].kind").value("FINISHED"));
jdbc.update("update user_game set hidden_at = now() where user_id = ?", USER);
allAchievements();
setStatus(GAME, "COMPLETED");
gamePoints(GAME).andExpect(jsonPath("$.missing.COMPLETED").value("HIDDEN"));
}
@Test
void gamePoints_HiddenStartedGame_MissingStartIsHidden() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
playedToday(GAME);
jdbc.update("update user_game set hidden_at = now() where user_id = ?", USER);
setStatus(GAME, "PLAYING");
gamePoints(GAME).andExpect(jsonPath("$.missing.STARTED").value("HIDDEN"));
}
@Test
void gamePoints_PsnRow_SaysPlaystation() throws Exception {
game(GAME, "PSN", "BACKLOG", 120);
setStatus(GAME, "FINISHED");
gamePoints(GAME).andExpect(jsonPath("$.missing.FINISHED").value("PLAYSTATION"));
}
@Test
void gamePoints_ManualGameWithLinkedPsnTitle_PaysFifteen() throws Exception {
game(-4_100_030L, "MANUAL", "BACKLOG", 0);
setStatus(-4_100_030L, "FINISHED");
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', ?)",
USER, -4_100_030L);
gamePoints(-4_100_030L)
.andExpect(jsonPath("$.paid[0].kind").value("FINISHED_MANUAL"))
.andExpect(jsonPath("$.paid[0].points").value(15))
.andExpect(jsonPath("$.missing.FINISHED").doesNotExist());
}
@Test
void gamePoints_SteamRowWithLinkedTitleAndEndingAchievement_PaysFull() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', ?)",
USER, GAME);
playedToday(GAME);
setStatus(GAME, "FINISHED");
jdbc.update("update user_game set finished_at_estimate = now() where user_id = ?", USER);
gamePoints(GAME)
.andExpect(jsonPath("$.paid.length()").value(1))
.andExpect(jsonPath("$.paid[0].kind").value("FINISHED"))
.andExpect(jsonPath("$.paid[0].points").value(30));
}
@Test
void gamePoints_LinkedTitleDaysNeverProve_PaysTenEarly() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', ?)",
USER, GAME);
playedToday(GAME);
setStatus(GAME, "FINISHED");
gamePoints(GAME)
.andExpect(jsonPath("$.paid[0].kind").value("FINISHED_EARLY"))
.andExpect(jsonPath("$.missing.FINISHED_TOPUP").value("PLAY_NOT_COUNTED"));
}
@Test
void gamePoints_FinishBeforeTheStatusLog_SaysBeforeHistory() throws Exception {
game(GAME, "STEAM", "FINISHED", 120);
jdbc.update("delete from user_game_status_change where user_id = ?", USER);
gamePoints(GAME).andExpect(jsonPath("$.missing.FINISHED").value("BEFORE_HISTORY"));
}
@Test
void gamePoints_FinishedPlayingFinished_PaysOnce() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
playedToday(GAME);
setStatus(GAME, "FINISHED");
gamePoints(GAME).andExpect(jsonPath("$.balance").value(30));
setStatus(GAME, "PLAYING");
setStatus(GAME, "FINISHED");
gamePoints(GAME).andExpect(jsonPath("$.balance").value(30));
assertThat(rows("FINISHED")).isEqualTo(1);
}
@Test
void gamePoints_LinkedTitlePlayedAfterEarly_SaysPlayNotCounted() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', ?)",
USER, GAME);
setStatus(GAME, "FINISHED");
gamePoints(GAME).andExpect(jsonPath("$.paid[0].kind").value("FINISHED_EARLY"));
playedToday(GAME);
gamePoints(GAME)
.andExpect(jsonPath("$.paid.length()").value(1))
.andExpect(jsonPath("$.missing.FINISHED_TOPUP").value("PLAY_NOT_COUNTED"));
}
@Test
void gamePoints_EarlyThenPlay_TopsUpToFullOnce() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
setStatus(GAME, "FINISHED");
gamePoints(GAME).andExpect(jsonPath("$.balance").value(10));
playedToday(GAME);
gamePoints(GAME)
.andExpect(jsonPath("$.paid[?(@.kind == 'FINISHED_TOPUP')].points").value(20))
.andExpect(jsonPath("$.missing.FINISHED_TOPUP").doesNotExist())
.andExpect(jsonPath("$.balance").value(30));
gamePoints(GAME).andExpect(jsonPath("$.balance").value(30));
assertThat(rows("FINISHED_EARLY")).isEqualTo(1);
assertThat(rows("FINISHED_TOPUP")).isEqualTo(1);
assertThat(rows("FINISHED")).isZero();
}
@Test
void gamePoints_ZeroPlaytimeFinished_PaysTenNoTopUpWithoutSteamPlay() throws Exception {
game(GAME, "STEAM", "BACKLOG", 0);
setStatus(GAME, "FINISHED");
gamePoints(GAME)
.andExpect(jsonPath("$.paid[0].kind").value("FINISHED_EARLY"))
.andExpect(jsonPath("$.missing.FINISHED_TOPUP").value("NO_PLAYTIME"));
gamePoints(GAME).andExpect(jsonPath("$.balance").value(10));
}
@Test
void gamePoints_FourthCappedFinishInAWeek_ManualAndEarlyShareTheCap() throws Exception {
for (long appid = -4_100_010L; appid > -4_100_012L; appid--) {
game(appid, "MANUAL", "BACKLOG", 0);
setStatus(appid, "FINISHED");
}
game(GAME, "STEAM", "BACKLOG", 120);
game(EDITION, "STEAM", "BACKLOG", 120);
setStatus(GAME, "FINISHED");
jdbc.update("update user_game_status_change set changed_at = changed_at + interval '1 second' where appid = ?", GAME);
setStatus(EDITION, "FINISHED");
jdbc.update("update user_game_status_change set changed_at = changed_at + interval '2 seconds' where appid = ?", EDITION);
gamePoints(EDITION)
.andExpect(jsonPath("$.paid").isEmpty())
.andExpect(jsonPath("$.missing.FINISHED").value("WEEKLY_LIMIT_NO_PROOF"));
assertThat(rows("FINISHED_MANUAL") + rows("FINISHED_EARLY")).isEqualTo(3);
}
@Test
void gamePoints_CappedEarlyThenProof_PaysTheFullFinish() throws Exception {
for (long appid = -4_100_010L; appid > -4_100_013L; appid--) {
game(appid, "MANUAL", "BACKLOG", 0);
setStatus(appid, "FINISHED");
}
game(GAME, "STEAM", "BACKLOG", 120);
setStatus(GAME, "FINISHED");
jdbc.update("update user_game_status_change set changed_at = changed_at + interval '1 second' where appid = ?", GAME);
gamePoints(GAME).andExpect(jsonPath("$.missing.FINISHED").value("WEEKLY_LIMIT_NO_PROOF"));
playedToday(GAME);
gamePoints(GAME)
.andExpect(jsonPath("$.paid[0].kind").value("FINISHED"))
.andExpect(jsonPath("$.paid[0].points").value(30));
}
@Test
void gamePoints_SetEarlyOnOneEditionProofOnTheOther_TopsUpOnce() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
game(EDITION, "STEAM", "BACKLOG", 60);
jdbc.update("insert into game_link (appid, set_id) values (?, 4100), (?, 4100)", GAME, EDITION);
setStatus(GAME, "FINISHED");
gamePoints(GAME).andExpect(jsonPath("$.paid[0].kind").value("FINISHED_EARLY"));
assertThat(rows("FINISHED_EARLY")).isEqualTo(1);
playedToday(EDITION);
setStatus(EDITION, "FINISHED");
gamePoints(EDITION);
gamePoints(GAME).andExpect(jsonPath("$.balance").value(30));
assertThat(rows("FINISHED_EARLY")).isEqualTo(1);
assertThat(rows("FINISHED_TOPUP")).isEqualTo(1);
assertThat(rows("FINISHED")).isZero();
}
@Test
void gamePoints_CompletedAfterEarly_NeedsProvenFinish_ThenPaysAfterTopUp() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
allAchievements();
setStatus(GAME, "FINISHED");
setStatus(GAME, "COMPLETED");
gamePoints(GAME)
.andExpect(jsonPath("$.paid.length()").value(1))
.andExpect(jsonPath("$.paid[0].kind").value("FINISHED_EARLY"))
.andExpect(jsonPath("$.missing.COMPLETED").value("NEEDS_PROVEN_FINISH"));
playedToday(GAME);
gamePoints(GAME)
.andExpect(jsonPath("$.paid[?(@.kind == 'FINISHED_TOPUP')]").isNotEmpty())
.andExpect(jsonPath("$.paid[?(@.kind == 'COMPLETED')].points").value(40))
.andExpect(jsonPath("$.balance").value(70));
}
@Test
void gamePoints_FinishedPlayingFinishedAfterEarly_PaysNoSecondEarly() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
setStatus(GAME, "FINISHED");
gamePoints(GAME).andExpect(jsonPath("$.balance").value(10));
setStatus(GAME, "PLAYING");
setStatus(GAME, "FINISHED");
gamePoints(GAME).andExpect(jsonPath("$.balance").value(10));
assertThat(rows("FINISHED_EARLY")).isEqualTo(1);
}
@Test
void gamePoints_HiddenAfterEarly_MissingTopUpIsHidden() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
setStatus(GAME, "FINISHED");
gamePoints(GAME).andExpect(jsonPath("$.paid[0].kind").value("FINISHED_EARLY"));
jdbc.update("update user_game set hidden_at = now() where user_id = ?", USER);
gamePoints(GAME).andExpect(jsonPath("$.missing.FINISHED_TOPUP").value("HIDDEN"));
}
@Test
void expected_ReturnsFullValuePerGame_ZeroWhenPaid() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
game(-4_100_040L, "MANUAL", "BACKLOG", 0);
jdbc.update("insert into game_time_to_beat (appid, normally_minutes, fetched_at) values (?, 1200, now()) "
+ "on conflict (appid) do update set normally_minutes = 1200", GAME);
try {
mockMvc.perform(get("/api/rewards/expected").param("appids", GAME + "," + (-4_100_040L)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.['" + GAME + "']").value(50))
.andExpect(jsonPath("$.['-4100040']").value(15));
setStatus(GAME, "FINISHED");
gamePoints(GAME);
mockMvc.perform(get("/api/rewards/expected").param("appids", String.valueOf(GAME)))
.andExpect(jsonPath("$.['" + GAME + "']").value(0));
} finally {
jdbc.update("delete from game_time_to_beat where appid = ?", GAME);
}
}
@Test
void expected_AnotherUsersAppid_IsLeftOut() throws Exception {
jdbc.update("insert into steam_game (appid, name) values (?, 'Game') on conflict do nothing", GAME);
jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes, source) values (?, ?, 'BACKLOG', 120, 'STEAM')",
OTHER_USER, GAME);
mockMvc.perform(get("/api/rewards/expected").param("appids", String.valueOf(GAME)))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isEmpty());
}
@Test
void expected_MoreThan200_Is400() throws Exception {
String ids = java.util.stream.LongStream.rangeClosed(1, 201).mapToObj(String::valueOf)
.collect(java.util.stream.Collectors.joining(","));
mockMvc.perform(get("/api/rewards/expected").param("appids", ids))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("TOO_MANY"));
}
@Test
void gamePoints_GameNotInLibrary_Is404() throws Exception {
gamePoints(GAME)
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("GAME_NOT_FOUND"));
}
@Test
void gamePoints_AnotherUsersGame_Is404AndBooksNothingForThem() throws Exception {
jdbc.update("insert into steam_game (appid, name) values (?, 'Game') on conflict do nothing", GAME);
jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes, source) values (?, ?, 'BACKLOG', 120, 'STEAM')",
OTHER_USER, GAME);
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, current_date, ?, 30)",
OTHER_USER, GAME);
jdbc.update("update user_game set status = 'FINISHED' where user_id = ?", OTHER_USER);
gamePoints(GAME).andExpect(status().isNotFound());
assertThat(jdbc.queryForObject("select count(*) from reward_ledger where user_id = ?", Integer.class, OTHER_USER))
.isZero();
}
@Test
void wallet_ManualGameInTheHistoryHasItsName() throws Exception {
game(-4_100_020L, "MANUAL", "BACKLOG", 0);
@@ -15,13 +15,128 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
class RewardRulesTest extends AbstractUnitTest {
private static final Instant AT = Instant.parse("2026-10-01T10:00:00Z");
private static final Instant EARLY_SINCE = Instant.parse("2026-09-10T00:00:00Z");
private static FinishFacts steam(int playtime, Integer normallyMinutes, boolean ending, boolean around) {
return new FinishFacts(220L, false, false, playtime, playtime, normallyMinutes, ending, around, false, AT);
return new FinishFacts(220L, false, false, playtime, playtime, normallyMinutes, ending, around, false, AT, false);
}
private static int points(FinishFacts facts) {
return RewardRules.finish(facts).map(RewardEntry::points).orElse(0);
return RewardRules.finish(facts, EARLY_SINCE).map(RewardEntry::points).orElse(0);
}
private static GameRewardFacts game(boolean manual, boolean finished, boolean completed, boolean finishLogged) {
return new GameRewardFacts(manual, finished, completed, false, false, true, finishLogged, false);
}
private static final GameRewardFacts STEAM_FINISHED = game(false, true, false, true);
@Test
void blocked_HiddenBeforePlaystation() {
GameRewardFacts all = new GameRewardFacts(false, true, false, true, true, true, true, false);
assertThat(RewardRules.blocked(all)).isEqualTo(NoPointsReason.HIDDEN);
assertThat(RewardRules.blocked(new GameRewardFacts(false, true, false, false, true, true, true, false)))
.isEqualTo(NoPointsReason.PLAYSTATION);
assertThat(RewardRules.blocked(STEAM_FINISHED)).isNull();
}
@Test
void noFinishReason_ManualOverTheCap_IsWeeklyLimit() {
FinishFacts manual = new FinishFacts(-1L, true, false, 0, 0, null, false, false, false, AT, false);
GameRewardFacts g = game(true, true, false, true);
assertThat(RewardRules.noFinishReason(g, manual, true, EARLY_SINCE)).isEqualTo(NoPointsReason.WEEKLY_LIMIT);
assertThat(RewardRules.noFinishReason(g, manual, false, EARLY_SINCE)).isNull();
}
@Test
void noFinishReason_SteamWithoutProof_IsNullBecauseEarlyPays() {
assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(60, 300, false, false), false, EARLY_SINCE)).isNull();
assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(0, 300, true, true), false, EARLY_SINCE)).isNull();
}
@Test
void noTopUpReason_SteamWithoutProof_IsNoProof() {
assertThat(RewardRules.noTopUpReason(STEAM_FINISHED, steam(60, 300, false, false))).isEqualTo(NoPointsReason.NO_PROOF);
assertThat(RewardRules.noTopUpReason(STEAM_FINISHED, steam(60, 300, true, false))).isNull();
assertThat(RewardRules.noTopUpReason(STEAM_FINISHED, null)).isNull();
}
@Test
void noTopUpReason_LinkedTitleWithoutProof_IsPlayNotCounted() {
GameRewardFacts linked = new GameRewardFacts(false, true, false, false, false, true, true, true);
assertThat(RewardRules.noTopUpReason(linked, steam(60, 300, false, false)))
.isEqualTo(NoPointsReason.PLAY_NOT_COUNTED);
assertThat(RewardRules.noTopUpReason(linked, steam(0, 300, false, false))).isEqualTo(NoPointsReason.NO_PLAYTIME);
assertThat(RewardRules.noTopUpReason(linked, steam(60, 300, true, false))).isNull();
assertThat(RewardRules.noStartReason(linked, new StartFacts(220L, false, AT, false)))
.isEqualTo(NoPointsReason.PLAY_NOT_COUNTED);
}
@Test
void noTopUpReason_SteamWithoutPlaytime_IsNoPlaytime() {
assertThat(RewardRules.noTopUpReason(STEAM_FINISHED, steam(0, 300, true, true))).isEqualTo(NoPointsReason.NO_PLAYTIME);
}
@Test
void noFinishReason_UnprovenBeforeTheCutoff_IsNoProof() {
Instant cutoff = AT.plusSeconds(1);
assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(60, 300, false, false), false, cutoff))
.isEqualTo(NoPointsReason.NO_PROOF);
assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(0, 300, false, false), false, cutoff))
.isEqualTo(NoPointsReason.NO_PLAYTIME);
assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(60, 300, true, false), false, cutoff)).isNull();
}
@Test
void noFinishReason_UnprovenSteamOverTheCap_IsWeeklyLimitNoProof() {
assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(60, 300, false, false), true, EARLY_SINCE))
.isEqualTo(NoPointsReason.WEEKLY_LIMIT_NO_PROOF);
assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(60, 300, true, false), true, EARLY_SINCE)).isNull();
}
@Test
void noFinishReason_NoStatusLog_IsBeforeHistory() {
assertThat(RewardRules.noFinishReason(game(false, true, false, false), null, false, EARLY_SINCE))
.isEqualTo(NoPointsReason.BEFORE_HISTORY);
assertThat(RewardRules.noFinishReason(game(false, false, false, false), null, false, EARLY_SINCE))
.isEqualTo(NoPointsReason.NOT_FINISHED);
}
@Test
void noStartReason_ManualGame_IsManualGame() {
assertThat(RewardRules.noStartReason(game(true, false, false, false), null)).isEqualTo(NoPointsReason.MANUAL_GAME);
}
@Test
void noStartReason_NoPlayAfterStart() {
StartFacts idle = new StartFacts(220L, false, AT, false);
assertThat(RewardRules.noStartReason(game(false, false, false, false), idle))
.isEqualTo(NoPointsReason.NO_PLAY_AFTER_START);
assertThat(RewardRules.noStartReason(new GameRewardFacts(false, false, false, false, false, false, false, false),
null)).isEqualTo(NoPointsReason.NOT_STARTED);
}
@Test
void noCompletedReason_WithoutProvenFinish_IsNeedsProvenFinish() {
GameRewardFacts g = game(false, true, true, true);
assertThat(RewardRules.noCompletedReason(g, null, false)).isEqualTo(NoPointsReason.NEEDS_PROVEN_FINISH);
assertThat(RewardRules.noCompletedReason(STEAM_FINISHED, null, true)).isEqualTo(NoPointsReason.NOT_COMPLETED);
assertThat(RewardRules.noCompletedReason(game(true, true, true, true), null, true))
.isEqualTo(NoPointsReason.MANUAL_GAME);
}
@Test
void noCompletedReason_MissingAchievements() {
FinishFacts open = new FinishFacts(220L, false, true, 60, 0, null, false, false, false, AT, false);
assertThat(RewardRules.noCompletedReason(game(false, true, true, true), open, true))
.isEqualTo(NoPointsReason.NOT_ALL_ACHIEVEMENTS);
}
@Test
@@ -34,46 +149,109 @@ class RewardRulesTest extends AbstractUnitTest {
}
@Test
void finish_WithoutPlaytime_PaysNothingEvenWithTheEndingProof() {
assertThat(RewardRules.finish(steam(0, 600, true, true))).isEmpty();
void finish_WithoutPlaytime_PaysTenEarlyEvenWithTheEndingProof() {
assertThat(RewardRules.finish(steam(0, 600, true, true), EARLY_SINCE))
.contains(new RewardEntry(RewardKind.FINISHED_EARLY, "220", 10, AT));
}
@Test
void finish_NeedsOneProof() {
assertThat(points(steam(60, 300, false, false))).isZero();
void finish_WithoutProof_PaysTenEarly() {
assertThat(points(steam(60, 300, false, false))).isEqualTo(10);
assertThat(points(steam(60, 300, true, false))).isEqualTo(30);
assertThat(points(steam(60, 300, false, true))).isEqualTo(30);
}
@Test
void finish_HalfOfTheNormalTimeIsAProof() {
assertThat(points(steam(149, 300, false, false))).isZero();
assertThat(points(steam(149, 300, false, false))).isEqualTo(10);
assertThat(points(steam(150, 300, false, false))).isEqualTo(30);
assertThat(points(steam(150, null, false, false))).isZero();
assertThat(points(steam(150, null, false, false))).isEqualTo(10);
}
@Test
void finish_ShareProofCountsOnlyPlaytimeSinceTheFinish() {
FinishFacts oldPlay = new FinishFacts(220L, false, false, 500, 100, 300, false, false, false, AT);
void finish_ShareProofCountsOnlyPlaytimeSinceTheFinish_PaysTenEarly() {
FinishFacts oldPlay = new FinishFacts(220L, false, false, 500, 100, 300, false, false, false, AT, false);
assertThat(RewardRules.finish(oldPlay)).isEmpty();
assertThat(RewardRules.finish(oldPlay, EARLY_SINCE)).contains(new RewardEntry(RewardKind.FINISHED_EARLY, "220", 10, AT));
}
@Test
void finish_ManualGame_PaysTheSmallPriceWithoutProof() {
FinishFacts manual = new FinishFacts(-5L, true, false, 0, 0, null, false, false, false, AT);
FinishFacts manual = new FinishFacts(-5L, true, false, 0, 0, null, false, false, false, AT, false);
assertThat(RewardRules.finish(manual)).contains(new RewardEntry(RewardKind.FINISHED_MANUAL, "-5", 15, AT));
assertThat(RewardRules.finish(manual, EARLY_SINCE)).contains(new RewardEntry(RewardKind.FINISHED_MANUAL, "-5", 15, AT));
}
private static FinishFacts earlyPaid(FinishFacts f) {
return new FinishFacts(f.appid(), f.manual(), f.completed(), f.playtimeMinutes(), f.playedSinceMinutes(),
f.normallyMinutes(), f.endingUnlocked(), f.playedAround(), f.allAchievements(), f.finishedAt(), true);
}
@Test
void finish_UnprovenBeforeTheCutoff_PaysNothing() {
assertThat(RewardRules.finish(steam(60, 300, false, false), AT.plusSeconds(1))).isEmpty();
}
@Test
void finish_UnprovenOnTheCutoffDay_PaysTenEarly() {
assertThat(RewardRules.finish(steam(60, 300, false, false), AT)).contains(
new RewardEntry(RewardKind.FINISHED_EARLY, "220", 10, AT));
}
@Test
void finish_ProvenBeforeTheCutoff_PaysFull() {
assertThat(RewardRules.finish(steam(60, 300, true, false), AT.plusSeconds(1))).contains(
new RewardEntry(RewardKind.FINISHED, "220", 30, AT));
}
@Test
void finish_ProvenAfterEarly_TopsUpToFull() {
FinishFacts f = earlyPaid(steam(60, 20 * 60, true, false));
assertThat(RewardRules.finish(f, EARLY_SINCE)).contains(new RewardEntry(RewardKind.FINISHED_TOPUP, "220", 40, AT));
}
@Test
void finish_UnprovenAfterEarly_PaysNothing() {
assertThat(RewardRules.finish(earlyPaid(steam(60, 300, false, false)), EARLY_SINCE)).isEmpty();
}
@Test
void finish_ZeroPlaytime_PaysTenEarly() {
assertThat(points(steam(0, 300, false, false))).isEqualTo(10);
assertThat(RewardRules.finish(earlyPaid(steam(0, 300, true, true)), EARLY_SINCE)).isEmpty();
}
@Test
void capped_UnprovenBeforeTheCutoff_IsNotCapped() {
assertThat(RewardRules.capped(steam(60, 300, false, false), AT.plusSeconds(1))).isFalse();
assertThat(RewardRules.capped(steam(60, 300, false, false), AT)).isTrue();
}
@Test
void capped_ManualAndUnprovenSteam_NotProvenSteam() {
assertThat(RewardRules.capped(new FinishFacts(-1L, true, false, 0, 0, null, false, false, false, AT, false),
EARLY_SINCE)).isTrue();
assertThat(RewardRules.capped(steam(60, 300, false, false), EARLY_SINCE)).isTrue();
assertThat(RewardRules.capped(steam(60, 300, true, false), EARLY_SINCE)).isFalse();
}
@Test
void expectedFinish_ManualIsFifteen_SteamByLength() {
assertThat(RewardRules.expectedFinish(true, 3000)).isEqualTo(15);
assertThat(RewardRules.expectedFinish(false, null)).isEqualTo(30);
assertThat(RewardRules.expectedFinish(false, 20 * 60)).isEqualTo(50);
assertThat(RewardRules.expectedFinish(false, 40 * 60)).isEqualTo(80);
}
@Test
void completed_NeedsAllAchievementsAndASteamGame() {
FinishFacts all = new FinishFacts(220L, false, true, 60, 60, null, true, true, true, AT);
FinishFacts all = new FinishFacts(220L, false, true, 60, 60, null, true, true, true, AT, false);
assertThat(RewardRules.completed(all)).contains(new RewardEntry(RewardKind.COMPLETED, "220", 40, AT));
assertThat(RewardRules.completed(new FinishFacts(220L, false, true, 60, 60, null, true, true, false, AT))).isEmpty();
assertThat(RewardRules.completed(new FinishFacts(220L, false, false, 60, 60, null, true, true, true, AT))).isEmpty();
assertThat(RewardRules.completed(new FinishFacts(220L, true, true, 60, 60, null, true, true, true, AT))).isEmpty();
assertThat(RewardRules.completed(new FinishFacts(220L, false, true, 60, 60, null, true, true, false, AT, false))).isEmpty();
assertThat(RewardRules.completed(new FinishFacts(220L, false, false, 60, 60, null, true, true, true, AT, false))).isEmpty();
assertThat(RewardRules.completed(new FinishFacts(220L, true, true, 60, 60, null, true, true, true, AT, false))).isEmpty();
}
@Test
@@ -1,6 +1,7 @@
package core_service.modules.rewards.repo;
import core_service.modules.rewards.domain.FinishFacts;
import core_service.modules.rewards.domain.GameRewardFacts;
import core_service.modules.rewards.domain.RewardEntry;
import core_service.modules.rewards.domain.RewardKind;
import core_service.modules.rewards.domain.StartFacts;
@@ -23,6 +24,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
@@ -63,6 +65,68 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
USER, day, GAME);
}
@Test
void game_FlagsHiddenManualAndStatusLog() {
game("BACKLOG", 120);
setStatus("PLAYING");
setStatus("FINISHED");
jdbc.update("update user_game set hidden_at = now() where user_id = ?", USER);
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', ?)", USER, GAME);
assertThat(repository.game(USER, GAME)).hasValue(
new GameRewardFacts(false, true, false, true, false, true, true, true));
}
@Test
void game_RemovedGame_IsEmpty() {
game("BACKLOG", 120);
jdbc.update("update user_game set removed_at = now() where user_id = ?", USER);
assertThat(repository.game(USER, GAME)).isEmpty();
assertThat(repository.game(USER, EDITION)).isEmpty();
}
@Test
void startFinishCompletedCandidates_WithAppid_OnlyThatGame() {
game("BACKLOG", 120);
jdbc.update("insert into steam_game (appid, name) values (?, 'Other') on conflict do nothing", EDITION);
jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes) values (?, ?, 'BACKLOG', 120)",
USER, EDITION);
jdbc.update("update user_game set status = 'PLAYING' where user_id = ?", USER);
jdbc.update("update user_game set status = 'COMPLETED' where user_id = ?", USER);
assertThat(repository.startCandidates(USER, BERLIN)).hasSize(2);
assertThat(repository.startCandidates(USER, BERLIN, EDITION)).extracting(StartFacts::appid)
.containsExactly(EDITION);
assertThat(repository.finishCandidates(USER, BERLIN)).hasSize(2);
assertThat(repository.finishCandidates(USER, BERLIN, GAME)).extracting(FinishFacts::appid)
.containsExactly(GAME);
ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(GAME), 30, Instant.now()));
ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(EDITION), 30, Instant.now()));
assertThat(repository.completedCandidates(USER)).hasSize(2);
assertThat(repository.completedCandidates(USER, GAME)).extracting(FinishFacts::appid).containsExactly(GAME);
}
@Test
void paidRows_ListsTheRowOfAnotherEditionOfTheSet() {
game("BACKLOG", 120);
jdbc.update("insert into steam_game (appid, name) values (?, 'Edition') on conflict do nothing", EDITION);
jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes) values (?, ?, 'BACKLOG', 0)",
USER, EDITION);
jdbc.update("insert into game_link (appid, set_id) values (?, 1), (?, 1)", GAME, EDITION);
ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(GAME), 30, Instant.now()));
ledger.add(USER, new RewardEntry(RewardKind.MONTH, "2026-09", 40, Instant.now()));
assertThat(repository.paidRows(USER, EDITION)).singleElement().satisfies(row -> {
assertThat(row.kind()).isEqualTo(RewardKind.FINISHED);
assertThat(row.appid()).isEqualTo(GAME);
assertThat(row.points()).isEqualTo(30);
});
assertThat(repository.paidRows(7777L, GAME)).isEmpty();
}
@Test
void finishCandidates_AppearsAfterTheChangeWithItsFacts() {
game("BACKLOG", 120);
@@ -222,6 +286,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 +443,144 @@ 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);
}
private void linkedTitle() {
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', ?)", USER, GAME);
}
@Test
void finishCandidates_EarlyPaidStaysACandidateWithTheFlag() {
game("BACKLOG", 120);
setStatus("FINISHED");
assertThat(repository.finishCandidates(USER, BERLIN)).singleElement()
.satisfies(f -> assertThat(f.earlyPaid()).isFalse());
paid(RewardKind.FINISHED_EARLY, GAME);
assertThat(repository.finishCandidates(USER, BERLIN)).singleElement()
.satisfies(f -> assertThat(f.earlyPaid()).isTrue());
}
@Test
void finishCandidates_TopUpPaidIsNoCandidate() {
game("BACKLOG", 120);
setStatus("FINISHED");
paid(RewardKind.FINISHED_EARLY, GAME);
paid(RewardKind.FINISHED_TOPUP, GAME);
assertThat(repository.finishCandidates(USER, BERLIN)).isEmpty();
}
@Test
void finishCandidates_ManualRowWithLinkedTitle_IsACandidate() {
long manual = -1_220L;
jdbc.update("insert into steam_game (appid, name) values (?, 'Half-Life 2 (Switch)') on conflict do nothing", manual);
jdbc.update("insert into user_game (user_id, appid, status, source) values (?, ?, 'BACKLOG', 'MANUAL')", USER, manual);
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', ?)", USER, manual);
jdbc.update("update user_game set status = 'FINISHED' where user_id = ?", USER);
assertThat(repository.finishCandidates(USER, BERLIN)).extracting(FinishFacts::appid).containsExactly(manual);
}
@Test
void finishCandidates_LinkedTitleDaysAreNoProof() {
game("BACKLOG", 120);
setStatus("FINISHED");
changedAt("FINISHED", "2026-09-15T10:00:00Z");
playedOn("2026-09-16");
linkedTitle();
assertThat(repository.finishCandidates(USER, BERLIN)).singleElement().satisfies(f -> {
assertThat(f.playedAround()).isFalse();
assertThat(f.playedSinceMinutes()).isZero();
});
}
@Test
void startCandidates_LinkedTitleDaysAreNoPlayAfterStart() {
game("BACKLOG", 120);
setStatus("PLAYING");
playedOn("2099-01-01");
linkedTitle();
assertThat(repository.startCandidates(USER, BERLIN)).singleElement()
.satisfies(f -> assertThat(f.playedAfter()).isFalse());
}
@Test
void completedCandidates_OpenAfterTopUp_NotAfterEarly() {
game("BACKLOG", 120);
setStatus("COMPLETED");
paid(RewardKind.FINISHED_EARLY, GAME);
assertThat(repository.completedCandidates(USER)).isEmpty();
paid(RewardKind.FINISHED_TOPUP, GAME);
assertThat(repository.completedCandidates(USER)).extracting(FinishFacts::appid).containsExactly(GAME);
}
@Test
void expectedFinish_PaidSetIsZero_HiddenAndPsnLeftOut() {
long manual = -1_220L;
long hidden = 222L;
long psn = 223L;
game("BACKLOG", 120);
for (long appid : new long[]{manual, hidden, psn}) {
jdbc.update("insert into steam_game (appid, name) values (?, 'G') on conflict do nothing", appid);
}
jdbc.update("insert into user_game (user_id, appid, status, source) values (?, ?, 'BACKLOG', 'MANUAL')", USER, manual);
jdbc.update("insert into user_game (user_id, appid, status, hidden_at) values (?, ?, 'BACKLOG', now())", USER, hidden);
jdbc.update("insert into user_game (user_id, appid, status, source, platform) values (?, ?, 'BACKLOG', 'PSN', 'PS5')",
USER, psn);
assertThat(repository.expectedFinish(USER, List.of(GAME, manual, hidden, psn, 999L)))
.containsExactlyInAnyOrderEntriesOf(java.util.Map.of(GAME, 50, manual, 15));
paid(RewardKind.FINISHED_EARLY, GAME);
assertThat(repository.expectedFinish(USER, List.of(GAME))).containsExactly(java.util.Map.entry(GAME, 0));
assertThat(repository.expectedFinish(USER, List.of())).isEmpty();
}
}
@@ -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);
}
}
@@ -0,0 +1,109 @@
package core_service.modules.rewards.service;
import core_service.shared.event.LibraryActivityEvent;
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.context.ApplicationEventPublisher;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.support.TransactionTemplate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
class RewardServiceSyncSettleTest extends AbstractIntegrationTest {
private static final long USER = 15L;
private static final long GAME = 240L;
@Autowired
private ApplicationEventPublisher publisher;
@Autowired
private TransactionTemplate transactionTemplate;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
@AfterEach
void clean() {
jdbc.update("delete from reward_ledger where user_id = ?", USER);
jdbc.update("delete from user_game where user_id = ?", USER);
jdbc.update("delete from blocked_user where user_id = ?", USER);
}
private void finishedGameWithoutProof() {
jdbc.update("insert into steam_game (appid, name) values (?, 'Synced') on conflict do nothing", GAME);
jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes) values (?, ?, 'BACKLOG', 90)",
USER, GAME);
jdbc.update("update user_game set status = 'FINISHED' where user_id = ?", USER);
}
private int earlyRows() {
return jdbc.queryForObject("select count(*) from reward_ledger where user_id = ? and kind = 'FINISHED_EARLY'",
Integer.class, USER);
}
@Test
void libraryActivity_OutsideATransaction_BooksTheRow() {
finishedGameWithoutProof();
publisher.publishEvent(new LibraryActivityEvent(USER));
assertThat(earlyRows()).isEqualTo(1);
}
@Test
void libraryActivity_InsideATransaction_BooksTheRowAfterCommit() {
finishedGameWithoutProof();
transactionTemplate.executeWithoutResult(status -> {
publisher.publishEvent(new LibraryActivityEvent(USER));
assertThat(earlyRows()).isZero();
});
assertThat(earlyRows()).isEqualTo(1);
}
@Test
void libraryActivity_ARollbackBooksNothing() {
finishedGameWithoutProof();
transactionTemplate.executeWithoutResult(status -> {
publisher.publishEvent(new LibraryActivityEvent(USER));
status.setRollbackOnly();
});
assertThat(earlyRows()).isZero();
}
@Test
void onLibraryActivity_SettleThatThrows_DoesNotReachThePublisher() {
finishedGameWithoutProof();
jdbc.execute("create or replace function review_fail() returns trigger language plpgsql as "
+ "$$ begin raise exception 'review: settle fails'; end $$");
jdbc.execute("create trigger review_fail before insert on reward_ledger for each row when (new.user_id = "
+ USER + ") execute function review_fail()");
try {
assertThatCode(() -> publisher.publishEvent(new LibraryActivityEvent(USER))).doesNotThrowAnyException();
assertThat(earlyRows()).isZero();
} finally {
jdbc.execute("drop trigger review_fail on reward_ledger");
jdbc.execute("drop function review_fail()");
}
}
@Test
void libraryActivity_BlockedUser_BooksNothingAndThrowsNothing() {
finishedGameWithoutProof();
jdbc.update("insert into blocked_user (user_id) values (?)", USER);
assertThatCode(() -> publisher.publishEvent(new LibraryActivityEvent(USER))).doesNotThrowAnyException();
assertThat(earlyRows()).isZero();
}
}
@@ -109,7 +109,7 @@ class SteamApiServiceCacheTest extends AbstractIntegrationTest {
@Test
void getOwnedGames_TransientSteamError_IsTriedAgain() {
SteamOwnedGamesResponse games = new SteamOwnedGamesResponse(new SteamOwnedGamesResponse.OwnedGamesResponse(
List.of(new SteamOwnedGamesResponse.Game(220, "Half-Life 2", 60, null, true, 0))));
List.of(new SteamOwnedGamesResponse.Game(220, "Half-Life 2", 60, null, true, 0, null))));
when(steamRestClient.get().uri(any(Function.class)).retrieve().body(SteamOwnedGamesResponse.class))
.thenAnswer(invocation -> {
if (steamCalls.incrementAndGet() == 1) {
@@ -88,7 +88,8 @@ class SteamApiServiceTest {
"name": "Counter-Strike",
"playtime_forever": 32,
"img_icon_url": "icon_url",
"has_community_visible_stats": true
"has_community_visible_stats": true,
"rtime_last_played": 1760000000
}
]
}
@@ -104,6 +105,7 @@ class SteamApiServiceTest {
assertThat(result).hasSize(1);
assertThat(result.getFirst().name()).isEqualTo("Counter-Strike");
assertThat(result.getFirst().rtimeLastPlayed()).isEqualTo(1_760_000_000L);
}
@Test