From e87f40bba08dc5c8c96ae32df95c9a0a0280dba1 Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 7 Oct 2026 09:42:10 +0200 Subject: [PATCH] feat(picker): add FRIENDS row from followed users Backlog games a followed user finished or played >= 5 h. One game per friend; strongest signal first (finished, then playtime). Requires an active follow of a public profile that shows SUMMARY or STATUS. Skips DROPPED games, blocked and deleted users, hidden and removed games. --- readme.md | 12 +- .../library/api/dto/PickReasonDto.java | 24 +- .../modules/library/domain/FriendPlay.java | 9 + .../modules/library/domain/PickSource.java | 5 +- .../library/repo/FriendPlayRepository.java | 74 ++++ .../library/repo/UserGameRepository.java | 2 +- .../library/service/LibraryQueryService.java | 29 +- .../LibraryControllerPickerFriendsTest.java | 344 ++++++++++++++++++ 8 files changed, 481 insertions(+), 18 deletions(-) create mode 100644 src/main/java/core_service/modules/library/domain/FriendPlay.java create mode 100644 src/main/java/core_service/modules/library/repo/FriendPlayRepository.java create mode 100644 src/test/java/core_service/modules/library/api/LibraryControllerPickerFriendsTest.java diff --git a/readme.md b/readme.md index 2ce9ffa..a9c8687 100644 --- a/readme.md +++ b/readme.md @@ -21,19 +21,23 @@ To Be Played is split into three repositories: can be taken over; its own finish then changes nothing. - **Backlog**: status (`BACKLOG`, `PLAYING`, `FINISHED`, `COMPLETED` = 100 %, `DROPPED`), rating 1–5, notes, priority, filters and sorting. "Pile of Shame" summary with a random suggestion of what to play. -- **Picker**: six rows of up to 3 backlog games each, for a time budget (IGDB normal time to beat), in this order: +- **Picker**: seven rows of up to 3 backlog games each, for a time budget (IGDB normal time to beat), in this order: `NEXT_UP` (the first games of the user's own backlog order), `SERIES` (next in series: for each of the 6 most played IGDB series the user played, one backlog part, the parts released after the latest played part first), `SIMILAR` ("because you played": for each of the 3 most played games the first backlog game sharing its moods, no game twice), `STUDIO` (for each of the 6 developers of the user's most played games, one backlog game with a credit of that company, developer credits first; publishers are never anchors, so a big publisher adds at most one card), + `FRIENDS` (for each followed user with a public profile, one backlog game of the user that the friend finished or + played 5 h or more, strongest signal first; a finish and playtime need the `SUMMARY` or `STATUS` profile section (`FINISHED` alone lists only 12 + games); `DROPPED`, hidden and removed games never count; the friend's current + display name, never an id), `LONGEST` (oldest `firstSeenAt`) and `RANDOM` (the mood row: random, HIGH priority first, then MEDIUM, then the rest). The mood filters only the `RANDOM` row; budget and exclude filters apply to every row. A row with no game is left out. A game can show in more than one row. IGDB series (collections) and companies are stored per game (`game_credit`, `game_credit_check`) and refreshed with the other IGDB data; a scheduled backfill (`app.library.credit-backfill-*`) fetches them for existing games, 250 games per 30 s, so the two rows fill up over time after a deploy. Every game carries a `reason` (`source`, plus the anchor game for `SIMILAR`, `seriesName` for - `SERIES`, `studioName` for `STUDIO` or `firstSeenAt` for `LONGEST`). + `SERIES`, `studioName` for `STUDIO`, `friendName`, `friendFinished` and `friendPlaytimeMinutes` for `FRIENDS` or `firstSeenAt` for `LONGEST`). Games already shown can be excluded for a reroll. Moods `STORY`, `COOP`, `CHILL`, `ACTION`, `SCARY` come from IGDB genres, themes and game modes; `SPEEDRUN` from speedrun.com. - **Follow users (#27)**: a logged-in user follows people with a public profile by display name (`/api/follows`, at most @@ -274,7 +278,7 @@ flowchart LR Igdb --> IgdbApi["IGDB API"] Igdb -->|"OAuth token"| Twitch["Twitch"] Speedrun --> SpeedrunApi["speedrun.com API"] - LibRepo --> Db[("PostgreSQL tbp_core")] + LibRepo -->|"own tables; picker also follow, public_user, account_name, blocked_user, deleted_user"| Db[("PostgreSQL tbp_core")] Stats -->|"listed_user_game, counted_user_game, game_mood, coming_game, user_game_status_change, deleted_user, blocked_user, listed_user, account_name, game_alias"| Db Follow -->|"follow, follow_digest, inbox_note, public_user, sync_change, user_game_status_change, account_name"| Db Inbox --> Db @@ -301,7 +305,7 @@ flowchart LR SQL, which ArchUnit cannot see: `stats` reads `listed_user`, `account_name`, `game_alias`, `listed_user_game`, `counted_user_game`, `steam_game`, `game_mood`, `coming_game`, `user_game_status_change`, `deleted_user` and `blocked_user`; `account` 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`. 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`. 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 diff --git a/src/main/java/core_service/modules/library/api/dto/PickReasonDto.java b/src/main/java/core_service/modules/library/api/dto/PickReasonDto.java index e892f5a..41c938f 100644 --- a/src/main/java/core_service/modules/library/api/dto/PickReasonDto.java +++ b/src/main/java/core_service/modules/library/api/dto/PickReasonDto.java @@ -7,35 +7,43 @@ import java.time.Instant; /** * Why the picker chose a game. The anchor fields are set only for SIMILAR, {@code firstSeenAt} only for LONGEST, - * {@code seriesName} only for SERIES, {@code studioName} only for STUDIO; the others are null. + * {@code seriesName} only for SERIES, {@code studioName} only for STUDIO, {@code friendName}, {@code friendFinished} and + * {@code friendPlaytimeMinutes} only for FRIENDS; the others are null. * * @param anchorName null when the anchor game has no name */ public record PickReasonDto(PickSource source, Long anchorAppid, String anchorName, Integer anchorPlaytimeMinutes, - Instant firstSeenAt, String seriesName, String studioName) { + Instant firstSeenAt, String seriesName, String studioName, + String friendName, Boolean friendFinished, Integer friendPlaytimeMinutes) { public static PickReasonDto similar(UserGame anchor, String anchorName) { return new PickReasonDto(PickSource.SIMILAR, anchor.getAppid(), anchorName, anchor.getPlaytimeMinutes(), null, - null, null); + null, null, null, null, null); } public static PickReasonDto longest(Instant firstSeenAt) { - return new PickReasonDto(PickSource.LONGEST, null, null, null, firstSeenAt, null, null); + return new PickReasonDto(PickSource.LONGEST, null, null, null, firstSeenAt, null, null, null, null, null); } public static PickReasonDto random() { - return new PickReasonDto(PickSource.RANDOM, null, null, null, null, null, null); + return new PickReasonDto(PickSource.RANDOM, null, null, null, null, null, null, null, null, null); } public static PickReasonDto nextUp() { - return new PickReasonDto(PickSource.NEXT_UP, null, null, null, null, null, null); + return new PickReasonDto(PickSource.NEXT_UP, null, null, null, null, null, null, null, null, null); } public static PickReasonDto series(String seriesName) { - return new PickReasonDto(PickSource.SERIES, null, null, null, null, seriesName, null); + return new PickReasonDto(PickSource.SERIES, null, null, null, null, seriesName, null, null, null, null); } public static PickReasonDto studio(String studioName) { - return new PickReasonDto(PickSource.STUDIO, null, null, null, null, null, studioName); + return new PickReasonDto(PickSource.STUDIO, null, null, null, null, null, studioName, null, null, null); + } + + /** @param playtimeMinutes null when the friend's profile hides it */ + public static PickReasonDto friends(String friendName, boolean finished, Integer playtimeMinutes) { + return new PickReasonDto(PickSource.FRIENDS, null, null, null, null, null, null, friendName, finished, + playtimeMinutes); } } diff --git a/src/main/java/core_service/modules/library/domain/FriendPlay.java b/src/main/java/core_service/modules/library/domain/FriendPlay.java new file mode 100644 index 0000000..e844fdb --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/FriendPlay.java @@ -0,0 +1,9 @@ +package core_service.modules.library.domain; + +/** + * A backlog game of the caller that a followed user finished or played a lot, with that friend's visible data. + * + * @param friendUserId never sent to the client; one slot per friend + * @param playtimeMinutes null when the friend's profile hides SUMMARY + */ +public record FriendPlay(long appid, long friendUserId, String friendName, boolean finished, Integer playtimeMinutes) {} diff --git a/src/main/java/core_service/modules/library/domain/PickSource.java b/src/main/java/core_service/modules/library/domain/PickSource.java index da96936..bfd2fc7 100644 --- a/src/main/java/core_service/modules/library/domain/PickSource.java +++ b/src/main/java/core_service/modules/library/domain/PickSource.java @@ -2,14 +2,15 @@ package core_service.modules.library.domain; /** * Where a picker game came from, and the picker row it is shown in. The order of the constants is the order of the - * rows. SERIES is the next part of a played series, STUDIO a game by a developer the user played. RANDOM is the mood - * row. + * rows. SERIES is the next part of a played series, STUDIO a game by a developer the user played, FRIENDS a + * backlog game a followed user finished or played a lot. RANDOM is the mood row. */ public enum PickSource { NEXT_UP, SERIES, SIMILAR, STUDIO, + FRIENDS, LONGEST, RANDOM } diff --git a/src/main/java/core_service/modules/library/repo/FriendPlayRepository.java b/src/main/java/core_service/modules/library/repo/FriendPlayRepository.java new file mode 100644 index 0000000..4562d6e --- /dev/null +++ b/src/main/java/core_service/modules/library/repo/FriendPlayRepository.java @@ -0,0 +1,74 @@ +package core_service.modules.library.repo; + +import core_service.modules.library.domain.FriendPlay; +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.Types; +import java.util.Collection; +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. + */ +@Repository +@RequiredArgsConstructor +public class FriendPlayRepository { + + /** Playtime from which a friend "played" a game: 5 h. */ + private static final int PLAYED_MINUTES = 300; + + // A finish and playtime are shown with SUMMARY or STATUS (the profile's full lists); FINISHED alone lists only + // 12 games. No sections = all shown. Hidden values are nulled before the order, so they do not leak through it. + private static final String SQL = """ + with played as ( + select ug.appid, f.followed_user_id, a.display_name, + (fg.status in ('FINISHED', 'COMPLETED') + and (p.sections is null or p.sections && array['SUMMARY', 'STATUS']::varchar[])) as finished, + case when p.sections is null or p.sections && array['SUMMARY', 'STATUS']::varchar[] + then fg.playtime_minutes end as playtime_minutes + from follow f + 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 + where f.user_id = :userId + 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) + and %s + ), + best as ( + select distinct on (appid) * from played + where finished or playtime_minutes >= :minPlayed + order by appid, finished desc, playtime_minutes desc nulls last, followed_user_id + ) + select appid, followed_user_id, display_name, finished, playtime_minutes from best + order by finished desc, playtime_minutes desc nulls last, random() + """.formatted(UserGameRepository.PICKER_FILTERS); + + private final NamedParameterJdbcTemplate jdbc; + + /** + * Per backlog game of the caller that passes the picker filters, the followed user with the strongest signal + * (a finish first, then the most playtime), strongest game first. Only active follows of public users who are + * not blocked or deleted; a finish and playtime count only when the profile shows SUMMARY or STATUS. Hidden, removed and dropped games of the friend are not counted. + * + * @param maxMinutes only games whose normal time to beat is known and at most this; null for every game + * @param excludeAppids never picked; may be empty + */ + public List findFriendPlays(long userId, Integer maxMinutes, Collection excludeAppids) { + MapSqlParameterSource params = new MapSqlParameterSource("userId", userId) + .addValue("excludeAppids", UserGameRepository.excludedOrNone(excludeAppids)) + .addValue("maxMinutes", maxMinutes, Types.INTEGER) + .addValue("mood", null, Types.VARCHAR) + .addValue("speedrun", false) + .addValue("minPlayed", PLAYED_MINUTES); + return jdbc.query(SQL, params, (rs, row) -> new FriendPlay(rs.getLong("appid"), + rs.getLong("followed_user_id"), rs.getString("display_name"), rs.getBoolean("finished"), + rs.getObject("playtime_minutes", Integer.class))); + } +} diff --git a/src/main/java/core_service/modules/library/repo/UserGameRepository.java b/src/main/java/core_service/modules/library/repo/UserGameRepository.java index 4ab37f9..5874bec 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java @@ -303,7 +303,7 @@ public interface UserGameRepository extends JpaRepository "order by ug.playtime_minutes desc, ug.appid limit :limit", nativeQuery = true) List findMostPlayedGames(@Param("userId") Long userId, @Param("limit") int limit); - private static List excludedOrNone(Collection excludeAppids) { + static List excludedOrNone(Collection excludeAppids) { List excluded = new ArrayList<>(excludeAppids); // "not in ()" is invalid SQL excluded.add(NO_GAME); diff --git a/src/main/java/core_service/modules/library/service/LibraryQueryService.java b/src/main/java/core_service/modules/library/service/LibraryQueryService.java index c8913b2..1c9193d 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -15,6 +15,7 @@ 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.FriendPlay; import core_service.modules.library.domain.GameAchievementSchemaMeta; import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.GameStatus; @@ -26,8 +27,10 @@ import core_service.modules.library.domain.StatGroup; import core_service.modules.library.domain.SteamGame; 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.domain.UserSteamSync; import core_service.modules.library.repo.ComingGameRepository; +import core_service.modules.library.repo.FriendPlayRepository; import core_service.modules.library.repo.GameAchievementSchemaMetaRepository; import core_service.modules.library.repo.GameCreditRepository; import core_service.modules.library.repo.GameLinkRepository; @@ -96,6 +99,7 @@ public class LibraryQueryService { private final SpeedrunService speedrunService; private final GameLinkRepository gameLinkRepository; private final GameCreditRepository gameCreditRepository; + private final FriendPlayRepository friendPlayRepository; @Value("${app.library.zone:Europe/Berlin}") private ZoneId zone; @@ -115,9 +119,9 @@ public class LibraryQueryService { } /** - * One row per {@link PickSource} (next up, next in series, because you played, same studio, longest waiting, - * mood), in the order of its constants, each with up to three backlog games. Budget and exclude apply to every - * row; the mood only to the RANDOM row. Rows without a game are left out. + * One row per {@link PickSource} (next up, next in series, because you played, same studio, friends played, + * longest waiting, mood), in the order of its constants, each with up to three backlog games. Budget and exclude + * apply to every row; the mood only to the RANDOM row. Rows without a game are left out. * With a mood, also refreshes the backlog's moods or speedrun links in the background for the next pick. * * @param only the one row to build (a reroll); null builds all rows @@ -139,6 +143,7 @@ public class LibraryQueryService { case SERIES -> nextInSeries(userId, maxMinutes, excludeAppids); case SIMILAR -> becauseYouPlayed(userId, maxMinutes, excludeAppids); case STUDIO -> sameStudio(userId, maxMinutes, excludeAppids); + case FRIENDS -> friendsPlayed(userId, maxMinutes, excludeAppids); case LONGEST -> withReason(userGameRepository.findLongestWaitingGames(userId, maxMinutes, null, excludeAppids, ROW_SIZE), g -> PickReasonDto.longest(g.getFirstSeenAt())); case RANDOM -> withReason(userGameRepository.findPickerGames(userId, maxMinutes, mood, @@ -179,6 +184,24 @@ public class LibraryQueryService { anchor -> PickReasonDto.similar(anchor, names.get(anchor.getAppid()))); } + /** + * The strongest friend signals first, one game per friend. A game a stronger friend took is not shown for another + * friend. + */ + private List> friendsPlayed(Long userId, Integer maxMinutes, + List excludeAppids) { + Set friends = new HashSet<>(); + List picks = friendPlayRepository.findFriendPlays(userId, maxMinutes, excludeAppids).stream() + .filter(p -> friends.add(p.friendUserId())).limit(ROW_SIZE).toList(); + Map games = userGameRepository.findAllById( + picks.stream().map(p -> new UserGameId(userId, p.appid())).toList()).stream() + .collect(Collectors.toMap(UserGame::getAppid, g -> g)); + return picks.stream() + .map(p -> Map.entry(games.get(p.appid()), + PickReasonDto.friends(p.friendName(), p.finished(), p.playtimeMinutes()))) + .toList(); + } + /** * For each series the user played (most played first), the first backlog part no earlier series took: the parts * released after the latest played part come first. diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerPickerFriendsTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerPickerFriendsTest.java new file mode 100644 index 0000000..49149bb --- /dev/null +++ b/src/test/java/core_service/modules/library/api/LibraryControllerPickerFriendsTest.java @@ -0,0 +1,344 @@ +package core_service.modules.library.api; + +import core_service.modules.library.domain.GameStatus; +import core_service.modules.library.domain.GameTimeToBeat; +import core_service.modules.library.domain.SteamGame; +import core_service.modules.library.domain.UserGame; +import core_service.modules.library.repo.GameReleaseRepository; +import core_service.modules.library.repo.GameTimeToBeatRepository; +import core_service.modules.library.repo.SteamGameRepository; +import core_service.modules.library.repo.UserGameAchievementMetaRepository; +import core_service.modules.library.repo.UserGameRepository; +import core_service.shared.test.AbstractMockedLibraryServicesTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +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.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import java.time.Instant; +import java.time.LocalDate; + +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.not; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * The FRIENDS picker row. The dev profile authenticates every request as user 1. + */ +@AutoConfigureMockMvc +class LibraryControllerPickerFriendsTest extends AbstractMockedLibraryServicesTest { + + private static final long USER_ID = 1L; + private static final long MAX = 950_001L; + private static final long ANNA = 950_002L; + private static final long TOM = 950_003L; + private static final long[] FRIENDS = {MAX, ANNA, TOM, 950_004L, 950_005L, 950_006L, 950_007L}; + private static final String ROW = "$.categories[?(@.source == 'FRIENDS')]"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JdbcTemplate jdbc; + + @Autowired + private SteamGameRepository steamGameRepository; + + @Autowired + private UserGameRepository userGameRepository; + + @Autowired + private GameTimeToBeatRepository gameTimeToBeatRepository; + + @Autowired + private GameReleaseRepository gameReleaseRepository; + + @Autowired + private UserGameAchievementMetaRepository achievementMetaRepository; + + @BeforeEach + void clean() { + for (long id : FRIENDS) { + jdbc.update("delete from follow where user_id = ? or followed_user_id = ?", id, id); + jdbc.update("delete from public_user where user_id = ?", id); + jdbc.update("delete from blocked_user where user_id = ?", id); + jdbc.update("delete from deleted_user where user_id = ?", id); + jdbc.update("delete from account_name where user_id = ?", id); + } + jdbc.update("delete from follow where user_id = ?", USER_ID); + gameReleaseRepository.deleteAll(); + achievementMetaRepository.deleteAll(); + userGameRepository.deleteAll(); + gameTimeToBeatRepository.deleteAll(); + } + + private void mine(long appid, GameStatus status, Integer normallyMinutes) { + steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now())); + save(USER_ID, appid, status, 0); + if (normallyMinutes != null) { + gameTimeToBeatRepository.save(new GameTimeToBeat(appid, null, normallyMinutes, null, Instant.now())); + } + } + + private void friendGame(long userId, long appid, GameStatus status, int playtime) { + steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now())); + save(userId, appid, status, playtime); + } + + private void save(long userId, long appid, GameStatus status, int playtime) { + UserGame game = new UserGame(userId, appid, playtime, Instant.now(), Instant.now()); + game.setStatus(status); + userGameRepository.save(game); + } + + /** A public friend the caller follows; null sections show everything. */ + private void friend(long userId, String name, String[] sections) { + jdbc.update("insert into account_name (user_id, display_name) values (?, ?)", userId, name); + jdbc.update("insert into public_user (user_id, sections) values (?, ?)", userId, sections); + jdbc.update("insert into follow (user_id, followed_user_id) values (?, ?)", USER_ID, userId); + } + + private ResultActions pick(String... params) throws Exception { + var request = get("/api/library/picker").param("category", "FRIENDS"); + for (int i = 0; i < params.length; i += 2) { + request.param(params[i], params[i + 1]); + } + return mockMvc.perform(request).andExpect(status().isOk()); + } + + private void expectNoRow() throws Exception { + pick().andExpect(jsonPath("$.categories", hasSize(0))); + } + + private static String reason(long appid, String field) { + return ROW + ".games[?(@.appid == " + appid + ")].reason." + field; + } + + @Test + void friends_FinishedAndPlayedGames_WithReason() throws Exception { + mine(10L, GameStatus.BACKLOG, null); + mine(20L, GameStatus.BACKLOG, null); + mine(30L, GameStatus.BACKLOG, null); + friend(MAX, "Max", null); + friend(ANNA, "Anna", null); + friend(TOM, "Tom", null); + friendGame(MAX, 10L, GameStatus.FINISHED, 100); + friendGame(ANNA, 20L, GameStatus.PLAYING, 2400); + friendGame(TOM, 30L, GameStatus.PLAYING, 60); + + pick().andExpect(jsonPath(ROW + ".games[*].appid", contains(10, 20))) + .andExpect(jsonPath(reason(10, "source"), contains("FRIENDS"))) + .andExpect(jsonPath(reason(10, "friendName"), contains("Max"))) + .andExpect(jsonPath(reason(10, "friendFinished"), contains(true))) + .andExpect(jsonPath(reason(20, "friendName"), contains("Anna"))) + .andExpect(jsonPath(reason(20, "friendFinished"), contains(false))) + .andExpect(jsonPath(reason(20, "friendPlaytimeMinutes"), contains(2400))); + } + + @Test + void friends_UsesTheCurrentDisplayName() throws Exception { + mine(10L, GameStatus.BACKLOG, null); + friend(MAX, "Max", null); + friendGame(MAX, 10L, GameStatus.FINISHED, 100); + jdbc.update("update account_name set display_name = 'Maximilian' where user_id = ?", MAX); + + pick().andExpect(jsonPath(reason(10, "friendName"), contains("Maximilian"))); + } + + @Test + void friends_NeverSendsAUserId() throws Exception { + mine(10L, GameStatus.BACKLOG, null); + friend(MAX, "Max", null); + friendGame(MAX, 10L, GameStatus.FINISHED, 100); + + String body = pick().andReturn().getResponse().getContentAsString(); + org.assertj.core.api.Assertions.assertThat(body).doesNotContain(String.valueOf(MAX)).doesNotContain("friendUserId"); + } + + @Test + void friends_OneGamePerFriend_StrongestFriendPerGame() throws Exception { + mine(10L, GameStatus.BACKLOG, null); + mine(20L, GameStatus.BACKLOG, null); + mine(30L, GameStatus.BACKLOG, null); + mine(40L, GameStatus.BACKLOG, null); + friend(MAX, "Max", null); + friend(ANNA, "Anna", null); + friendGame(MAX, 10L, GameStatus.FINISHED, 900); + friendGame(MAX, 20L, GameStatus.FINISHED, 100); + friendGame(MAX, 30L, GameStatus.FINISHED, 50); + friendGame(ANNA, 10L, GameStatus.PLAYING, 600); + friendGame(ANNA, 40L, GameStatus.PLAYING, 400); + + pick().andExpect(jsonPath(ROW + ".games[*].appid", contains(10, 40))) + .andExpect(jsonPath(ROW + ".games[*].reason.friendName", contains("Max", "Anna"))); + } + + @Test + void friends_ExactlyFiveHours_Counts() throws Exception { + mine(10L, GameStatus.BACKLOG, null); + mine(20L, GameStatus.BACKLOG, null); + friend(MAX, "Max", null); + friend(ANNA, "Anna", null); + friendGame(MAX, 10L, GameStatus.PLAYING, 300); + friendGame(ANNA, 20L, GameStatus.PLAYING, 299); + + pick().andExpect(jsonPath(ROW + ".games[*].appid", contains(10))); + } + + @Test + void friends_Reroll_ExcludesShownAndEndsWhenNoneLeft() throws Exception { + mine(10L, GameStatus.BACKLOG, null); + mine(20L, GameStatus.BACKLOG, null); + friend(MAX, "Max", null); + friendGame(MAX, 10L, GameStatus.FINISHED, 900); + friendGame(MAX, 20L, GameStatus.FINISHED, 100); + + pick().andExpect(jsonPath(ROW + ".games[*].appid", contains(10))); + pick("exclude", "10").andExpect(jsonPath(ROW + ".games[*].appid", contains(20))); + pick("exclude", "10,20").andExpect(jsonPath("$.categories", hasSize(0))); + } + + @Test + void friends_RowSitsBetweenStudioAndLongest() throws Exception { + mine(10L, GameStatus.BACKLOG, null); + friend(MAX, "Max", null); + friendGame(MAX, 10L, GameStatus.FINISHED, 100); + + mockMvc.perform(get("/api/library/picker")).andExpect(status().isOk()) + .andExpect(jsonPath("$.categories[*].source", contains("NEXT_UP", "FRIENDS", "LONGEST", "RANDOM"))); + } + + @Test + void friends_NoFollows_LeavesOutTheRow() throws Exception { + mine(10L, GameStatus.BACKLOG, null); + + mockMvc.perform(get("/api/library/picker")).andExpect(status().isOk()) + .andExpect(jsonPath("$.categories[*].source", not(hasItem("FRIENDS")))); + } + + private enum Hidden { NOT_FOLLOWED, NOT_PUBLIC, BLOCKED, DELETED, ONLY_FOLLOWS_ME } + + @ParameterizedTest + @EnumSource(Hidden.class) + void friends_NeverShows(Hidden case_) throws Exception { + mine(10L, GameStatus.BACKLOG, null); + jdbc.update("insert into account_name (user_id, display_name) values (?, 'Max')", MAX); + if (case_ != Hidden.NOT_PUBLIC) { + jdbc.update("insert into public_user (user_id) values (?)", MAX); + } + switch (case_) { + case NOT_FOLLOWED, NOT_PUBLIC -> { } + case BLOCKED -> jdbc.update("insert into blocked_user (user_id) values (?)", MAX); + case DELETED -> jdbc.update("insert into deleted_user (user_id) values (?)", MAX); + case ONLY_FOLLOWS_ME -> jdbc.update("insert into follow (user_id, followed_user_id) values (?, ?)", MAX, USER_ID); + } + if (case_ != Hidden.NOT_FOLLOWED && case_ != Hidden.ONLY_FOLLOWS_ME) { + jdbc.update("insert into follow (user_id, followed_user_id) values (?, ?)", USER_ID, MAX); + } + friendGame(MAX, 10L, GameStatus.FINISHED, 2400); + + expectNoRow(); + jdbc.update("delete from follow where user_id = ?", MAX); + } + + @Test + void friends_HiddenSections_ShownOnlyWithSummaryOrStatus() throws Exception { + for (long appid = 10; appid <= 60; appid += 10) { + mine(appid, GameStatus.BACKLOG, null); + } + friend(MAX, "Stats", new String[]{"STATS"}); + friend(ANNA, "Summary", new String[]{"SUMMARY"}); + friend(TOM, "FinishedOnly", new String[]{"FINISHED"}); + friend(950_004L, "All", null); + friend(950_005L, "Status", new String[]{"STATUS"}); + friend(950_006L, "PlayingFinished", new String[]{"PLAYING", "FINISHED"}); + friendGame(MAX, 10L, GameStatus.FINISHED, 2400); + friendGame(ANNA, 20L, GameStatus.FINISHED, 2400); + friendGame(TOM, 30L, GameStatus.FINISHED, 2400); + friendGame(950_004L, 40L, GameStatus.FINISHED, 2400); + friendGame(950_005L, 50L, GameStatus.FINISHED, 2400); + friendGame(950_006L, 60L, GameStatus.FINISHED, 2400); + + pick().andExpect(jsonPath(ROW + ".games[*].appid", containsInAnyOrder(20, 40, 50))); + pick("exclude", "20,40,50").andExpect(jsonPath("$.categories", hasSize(0))); + for (long appid : new long[]{20, 40, 50}) { + pick().andExpect(jsonPath(reason(appid, "friendFinished"), contains(true))) + .andExpect(jsonPath(reason(appid, "friendPlaytimeMinutes"), contains(2400))); + } + } + + @Test + void friends_FinishedOnlyProfile_NeverShowsAFinishTheProfileDoesNotList() throws Exception { + friend(MAX, "Max", new String[]{"FINISHED"}); + for (long appid = 101; appid <= 113; appid++) { + friendGame(MAX, appid, GameStatus.FINISHED, 2400); + } + mine(113L, GameStatus.BACKLOG, null); + + expectNoRow(); + } + + @Test + void friends_DroppedGame_NeverCounts() throws Exception { + mine(10L, GameStatus.BACKLOG, null); + friend(MAX, "Max", null); + friendGame(MAX, 10L, GameStatus.DROPPED, 6000); + + expectNoRow(); + } + + @Test + void friends_HiddenOrRemovedGameOfFriend_NeverCounts() throws Exception { + mine(10L, GameStatus.BACKLOG, null); + mine(20L, GameStatus.BACKLOG, null); + friend(MAX, "Max", null); + friendGame(MAX, 10L, GameStatus.FINISHED, 100); + friendGame(MAX, 20L, GameStatus.FINISHED, 100); + jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = 10", MAX); + jdbc.update("update user_game set removed_at = now() where user_id = ? and appid = 20", MAX); + + expectNoRow(); + } + + @Test + void friends_OnlyMyBacklog() throws Exception { + mine(10L, GameStatus.PLAYING, null); + mine(20L, GameStatus.BACKLOG, null); + mine(30L, GameStatus.BACKLOG, null); + friend(MAX, "Max", null); + friendGame(MAX, 10L, GameStatus.FINISHED, 100); + friendGame(MAX, 20L, GameStatus.FINISHED, 100); + friendGame(MAX, 40L, GameStatus.FINISHED, 100); + jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = 20", USER_ID); + + expectNoRow(); + } + + @Test + void friends_BudgetAndReleasedOnly() throws Exception { + mine(10L, GameStatus.BACKLOG, 3000); + mine(20L, GameStatus.BACKLOG, 60); + mine(30L, GameStatus.BACKLOG, 60); + gameReleaseRepository.upsert(20L, LocalDate.now().plusDays(1), false, Instant.now()); + friend(MAX, "Max", null); + friend(ANNA, "Anna", null); + friend(TOM, "Tom", null); + friendGame(MAX, 10L, GameStatus.FINISHED, 100); + // No playtime: a game anybody played is never "coming" (coming_game view). + friendGame(ANNA, 20L, GameStatus.FINISHED, 0); + friendGame(TOM, 30L, GameStatus.FINISHED, 100); + + pick("maxMinutes", "300").andExpect(jsonPath(ROW + ".games[*].appid", contains(30))) + .andExpect(jsonPath(ROW + ".games", hasSize(1))); + } +}