Public Access
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b24fd91e88 | ||
|
|
7c4b475321 | ||
|
|
4ed8a341af | ||
|
|
1ff1e061c7 | ||
|
|
413381d404 | ||
|
|
82d3b59165 | ||
|
|
8e4c382563 | ||
|
|
6cd92f3e83 |
No files matched your search
@@ -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();
|
||||
}
|
||||
|
||||
+3
-1
@@ -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
|
||||
) {}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
+15
-1
@@ -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)));
|
||||
|
||||
+12
@@ -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());
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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();
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in new issue
Block a user