diff --git a/README.md b/README.md index 53a815d..54ed9c6 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,9 @@ To Be Played is split into three repositories: - **Coming games**: a game that is not released yet (and nobody played) shows a "Coming " badge in the library, on the details page, in the stat game lists and on the public profile. The pile counts only the games Core sends in `pileGames`, so coming games are not on it; Core never picks them. -- **What to play next** (`/play-next`): four rows of up to 3 backlog games each: up next (your own backlog order), - because you played (a similar game for each of your 3 most played), longest on your list, and mood picks. A time +- **What to play next** (`/play-next`): six rows of up to 3 backlog games each: up next (your own backlog order), + next in series (the next part of a series you played), because you played (a similar game for each of your 3 most + played), same studio (a developer you played), longest on your list, and mood picks. A time budget (up to 5 / 15 / 40 h or any) filters all rows; a mood (story, co-op, chill, action, scary from IGDB; speedrun from speedrun.com) only the mood row. Each row has its own reroll without repeats, and "Start playing" in one click. On a phone each row scrolls sideways. diff --git a/public/i18n/de.json b/public/i18n/de.json index fd64de4..3bcf9d5 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -431,14 +431,18 @@ }, "PLAY_NEXT": { "TITLE": "Was spiele ich als Nächstes?", - "SUBTITLE": "Spiele aus deinem Backlog in vier Reihen: als Nächstes in deiner Reihenfolge, wie Spiele, die du viel gespielt hast, am längsten wartend und nach Stimmung. Wähle eine Dauer für alle Reihen und eine Stimmung für die letzte.", + "SUBTITLE": "Spiele aus deinem Backlog in sechs Reihen: als Nächstes in deiner Reihenfolge, der nächste Teil einer Reihe, die du gespielt hast, wie Spiele, die du viel gespielt hast, von einem Studio, das du gespielt hast, am längsten wartend und nach Stimmung. Wähle eine Dauer für alle Reihen und eine Stimmung für die letzte.", "REASON": { "SIMILAR": "Weil du {{name}} gespielt hast ({{playtime}})", + "SERIES": "Reihe: {{name}}", + "STUDIO": "Von {{name}}", "LONGEST": "Auf deiner Liste seit {{date}}" }, "CATEGORY": { "NEXT_UP": "Als Nächstes", + "SERIES": "Nächster Teil der Reihe", "SIMILAR": "Weil du gespielt hast", + "STUDIO": "Vom selben Studio", "LONGEST": "Am längsten auf deiner Liste", "RANDOM": "Zufallstreffer", "MOOD": "Stimmung: {{mood}}" diff --git a/public/i18n/en.json b/public/i18n/en.json index c8b85e0..cbe8b40 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -431,14 +431,18 @@ }, "PLAY_NEXT": { "TITLE": "What to play next", - "SUBTITLE": "Games from your backlog in four rows: next in your order, like games you played a lot, waiting longest, and by mood. Pick a length for all rows and a mood for the last one.", + "SUBTITLE": "Games from your backlog in six rows: next in your order, the next part of a series you played, like games you played a lot, from a studio you played, waiting longest, and by mood. Pick a length for all rows and a mood for the last one.", "REASON": { "SIMILAR": "Because you played {{name}} ({{playtime}})", + "SERIES": "Series: {{name}}", + "STUDIO": "From {{name}}", "LONGEST": "On your list since {{date}}" }, "CATEGORY": { "NEXT_UP": "Up next", + "SERIES": "Next in series", "SIMILAR": "Because you played", + "STUDIO": "Same studio", "LONGEST": "Longest on your list", "RANDOM": "Random picks", "MOOD": "Mood: {{mood}}" diff --git a/src/pages/PlayNextPage.jsx b/src/pages/PlayNextPage.jsx index 10b5154..3c1ae1d 100644 --- a/src/pages/PlayNextPage.jsx +++ b/src/pages/PlayNextPage.jsx @@ -27,7 +27,7 @@ const BUDGETS = [ // games without that data. const MOODS = ['ANY', 'STORY', 'COOP', 'CHILL', 'ACTION', 'SCARY', 'SPEEDRUN']; // The rows in the order Core sends them; RANDOM is the mood row. -const ROW_ORDER = ['NEXT_UP', 'SIMILAR', 'LONGEST', 'RANDOM']; +const ROW_ORDER = ['NEXT_UP', 'SERIES', 'SIMILAR', 'STUDIO', 'LONGEST', 'RANDOM']; // The backend reads only this many excludes. With a bigger backlog the oldest seen games can come back. const MAX_EXCLUDES = 100; const ROW_CLASS = 'flex gap-4 overflow-x-auto snap-x snap-mandatory pb-2 sm:grid sm:grid-cols-3 sm:overflow-visible'; @@ -253,6 +253,12 @@ const reasonText = (reason, t, language) => { playtime: formatPlaytime(reason.anchorPlaytimeMinutes), }); } + if (reason?.source === 'SERIES') { + return t('PLAY_NEXT.REASON.SERIES', {name: reason.seriesName}); + } + if (reason?.source === 'STUDIO') { + return t('PLAY_NEXT.REASON.STUDIO', {name: reason.studioName}); + } if (reason?.source === 'LONGEST') { return t('PLAY_NEXT.REASON.LONGEST', {date: formatDate(reason.firstSeenAt, language, {dateStyle: 'medium'})}); } diff --git a/src/pages/PlayNextPage.test.jsx b/src/pages/PlayNextPage.test.jsx index d5bda9c..2fa3f0a 100644 --- a/src/pages/PlayNextPage.test.jsx +++ b/src/pages/PlayNextPage.test.jsx @@ -18,7 +18,9 @@ const game = (appid, extra = {}) => ({ const row = (source, ...games) => ({source, games}); const ALL = () => [ row('NEXT_UP', game(1), game(2)), + row('SERIES', game(6, {reason: {source: 'SERIES', seriesName: 'Mass Effect'}})), row('SIMILAR', game(3, {reason: {source: 'SIMILAR', anchorAppid: 9, anchorName: 'Celeste', anchorPlaytimeMinutes: 600}})), + row('STUDIO', game(8, {reason: {source: 'STUDIO', studioName: 'BioWare'}})), row('LONGEST', game(4, {reason: {source: 'LONGEST', firstSeenAt: '2024-03-12T10:00:00Z'}})), row('RANDOM', game(5, {reason: {source: 'RANDOM'}})), ]; @@ -75,8 +77,8 @@ describe('PlayNextPage', () => { await screen.findByText('Game 1'); expect(screen.getAllByRole('heading', {level: 2}).map((h) => h.textContent)).toEqual([ - 'PLAY_NEXT.CATEGORY.NEXT_UP', 'PLAY_NEXT.CATEGORY.SIMILAR', 'PLAY_NEXT.CATEGORY.LONGEST', - 'PLAY_NEXT.CATEGORY.RANDOM']); + 'PLAY_NEXT.CATEGORY.NEXT_UP', 'PLAY_NEXT.CATEGORY.SERIES', 'PLAY_NEXT.CATEGORY.SIMILAR', + 'PLAY_NEXT.CATEGORY.STUDIO', 'PLAY_NEXT.CATEGORY.LONGEST', 'PLAY_NEXT.CATEGORY.RANDOM']); const params = pickerUrls()[0]; expect(params.has('count')).toBe(false); expect(params.has('category')).toBe(false); @@ -175,6 +177,20 @@ describe('PlayNextPage', () => { expect(pickerUrls()[2].has('mood')).toBe(false); }); + it('rerolls the series row with category=SERIES and without a mood', async () => { + mockRows(ALL(), {SERIES: [row('SERIES', game(10))]}); + renderPage(); + await screen.findByText('Game 1'); + fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.STORY'})); + await screen.findByText('Game 5'); + + fireEvent.click(rerollOf('PLAY_NEXT.CATEGORY.SERIES')); + + await screen.findByText('Game 10'); + expect(pickerUrls()[2].get('category')).toBe('SERIES'); + expect(pickerUrls()[2].has('mood')).toBe(false); + }); + it('starts a row over when it has no unseen game left', async () => { mockBackend((params) => { if (!params.get('category')) return ALL(); @@ -228,12 +244,14 @@ describe('PlayNextPage', () => { expect(await screen.findByText('PLAY_NEXT.EMPTY_MOOD')).toBeTruthy(); }); - it('shows a reason line for the similar and longest rows only', async () => { + it('shows a reason line for the series, similar, studio and longest rows only', async () => { mockRows(); renderPage(); expect(await screen.findByText('PLAY_NEXT.REASON.SIMILAR')).toBeTruthy(); + expect(screen.getByText('PLAY_NEXT.REASON.SERIES')).toBeTruthy(); + expect(screen.getByText('PLAY_NEXT.REASON.STUDIO')).toBeTruthy(); expect(screen.getByText('PLAY_NEXT.REASON.LONGEST')).toBeTruthy(); expect(screen.queryByText(/PLAY_NEXT\.REASON\.(RANDOM|NEXT_UP)/)).toBeNull(); });