diff --git a/public/i18n/de.json b/public/i18n/de.json index e042edd..54e1ee4 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -288,7 +288,7 @@ }, "PLAY_NEXT": { "TITLE": "Was spiele ich als Nächstes?", - "SUBTITLE": "Drei Spiele aus deinem Backlog, hohe Priorität zuerst.", + "SUBTITLE": "Drei Spiele aus deinem Backlog, hohe Priorität zuerst. Wähle eine Länge und eine Stimmung.", "BUDGET_LABEL": "Spieldauer", "BUDGET": { "SHORT": "Bis 5 h", @@ -304,7 +304,18 @@ "START_OVER": "Von vorn", "EMPTY": "Dein Backlog ist leer. Stark!", "EMPTY_BUDGET": "Kein Backlog-Spiel passt zu dieser Dauer. Versuch eine längere.", - "ERROR_LOAD": "Die Vorschläge konnten nicht geladen werden." + "ERROR_LOAD": "Die Vorschläge konnten nicht geladen werden.", + "MOOD_LABEL": "Stimmung", + "MOOD": { + "ANY": "Jede Stimmung", + "STORY": "Story", + "COOP": "Koop", + "CHILL": "Entspannt", + "ACTION": "Action", + "SCARY": "Gruselig" + }, + "MOOD_HINT": "Nur Spiele, deren Genre IGDB kennt.", + "EMPTY_MOOD": "Kein Backlog-Spiel passt. Probier eine andere Stimmung oder Dauer. Neue Spiele bekommen ihre Stimmung kurz nach der ersten Auswahl." }, "MANUAL_GAME": { "ADD_BUTTON": "Spiel hinzufügen", diff --git a/public/i18n/en.json b/public/i18n/en.json index 2403cc5..751212a 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -288,7 +288,7 @@ }, "PLAY_NEXT": { "TITLE": "What to play next", - "SUBTITLE": "Three games from your backlog, high priority first.", + "SUBTITLE": "Three games from your backlog, high priority first. Pick a length and a mood.", "BUDGET_LABEL": "Time to beat", "BUDGET": { "SHORT": "Up to 5 h", @@ -304,7 +304,18 @@ "START_OVER": "Start over", "EMPTY": "Your backlog is empty. Nice!", "EMPTY_BUDGET": "No backlog game fits this length. Try a longer one.", - "ERROR_LOAD": "Could not load the picks." + "ERROR_LOAD": "Could not load the picks.", + "MOOD_LABEL": "Mood", + "MOOD": { + "ANY": "Any mood", + "STORY": "Story", + "COOP": "Co-op", + "CHILL": "Chill", + "ACTION": "Action", + "SCARY": "Scary" + }, + "MOOD_HINT": "Only games whose genre IGDB knows.", + "EMPTY_MOOD": "No backlog game fits. Try another mood or length. New games get their mood a moment after the first pick." }, "MANUAL_GAME": { "ADD_BUTTON": "Add game", diff --git a/src/pages/PlayNextPage.jsx b/src/pages/PlayNextPage.jsx index 3ea3908..7445877 100644 --- a/src/pages/PlayNextPage.jsx +++ b/src/pages/PlayNextPage.jsx @@ -23,13 +23,16 @@ const BUDGETS = [ {key: 'LONG', maxMinutes: 2400}, {key: 'ANY', maxMinutes: null}, ]; +// The moods Core derives from IGDB's genres, themes and game modes; a mood leaves out games without IGDB data. +const MOODS = ['ANY', 'STORY', 'COOP', 'CHILL', 'ACTION', 'SCARY']; const PICK_COUNT = 3; // The backend reads only this many excludes. With a bigger backlog the oldest seen games can come back. const MAX_EXCLUDES = 100; -const pickerQuery = (maxMinutes, exclude) => { +const pickerQuery = (maxMinutes, mood, exclude) => { const params = new URLSearchParams({count: String(PICK_COUNT)}); if (maxMinutes) params.set('maxMinutes', String(maxMinutes)); + if (mood !== 'ANY') params.set('mood', mood); exclude.forEach((appid) => params.append('exclude', String(appid))); return params.toString(); }; @@ -39,14 +42,15 @@ export default function PlayNextPage() { const navigate = useNavigate(); const expireSession = useSessionExpired(); const [budget, setBudget] = useState(BUDGETS[3]); + const [mood, setMood] = useState('ANY'); const [games, setGames] = useState(null); const [seen, setSeen] = useState([]); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); - // A slow answer for an older budget must not replace the newer one. + // A slow answer for an older budget or mood must not replace the newer one. const latestRequest = useRef(0); - const load = useCallback(async (maxMinutes, exclude) => { + const load = useCallback(async (maxMinutes, moodKey, exclude) => { if (!hasSession()) { navigate('/login'); return; @@ -56,7 +60,7 @@ export default function PlayNextPage() { setError(null); try { const response = await apiFetch( - `${import.meta.env.VITE_CORE_API_URL}/api/library/picker?${pickerQuery(maxMinutes, exclude)}`); + `${import.meta.env.VITE_CORE_API_URL}/api/library/picker?${pickerQuery(maxMinutes, moodKey, exclude)}`); if (request !== latestRequest.current) return; if (response.status === 401) { expireSession(); @@ -80,8 +84,8 @@ export default function PlayNextPage() { }, [navigate, expireSession]); useEffect(() => { - load(budget.maxMinutes, []); - }, [budget, load]); + load(budget.maxMinutes, mood, []); + }, [budget, mood, load]); const chooseBudget = (next) => { if (next.key === budget.key) return; @@ -90,6 +94,13 @@ export default function PlayNextPage() { setBudget(next); }; + const chooseMood = (next) => { + if (next === mood) return; + setGames(null); + setSeen([]); + setMood(next); + }; + const allShown = games?.length === 0 && seen.length > 0; return ( @@ -118,6 +129,25 @@ export default function PlayNextPage() { )} +
+
+ {MOODS.map((key) => ( + + ))} +
+ {mood !== 'ANY' && ( +

{t('PLAY_NEXT.MOOD_HINT')}

+ )} +
+ {error &&

{t(error)}

} {games === null && !error && ( @@ -133,7 +163,7 @@ export default function PlayNextPage() { )} {(games?.length > 0 || error) && ( - @@ -142,7 +172,7 @@ export default function PlayNextPage() { {allShown && (

{t('PLAY_NEXT.ALL_SHOWN')}

-
@@ -150,13 +180,18 @@ export default function PlayNextPage() { {games?.length === 0 && seen.length === 0 && (

- {t(budget.maxMinutes ? 'PLAY_NEXT.EMPTY_BUDGET' : 'PLAY_NEXT.EMPTY')} + {t(emptyKey(budget, mood))}

)} ); } +const emptyKey = (budget, mood) => { + if (mood !== 'ANY') return 'PLAY_NEXT.EMPTY_MOOD'; + return budget.maxMinutes ? 'PLAY_NEXT.EMPTY_BUDGET' : 'PLAY_NEXT.EMPTY'; +}; + function PickCard({game, expireSession}) { const {t} = useTranslation(); const [busy, setBusy] = useState(false); diff --git a/src/pages/PlayNextPage.test.jsx b/src/pages/PlayNextPage.test.jsx index 3372f98..a1f2765 100644 --- a/src/pages/PlayNextPage.test.jsx +++ b/src/pages/PlayNextPage.test.jsx @@ -69,6 +69,47 @@ describe('PlayNextPage', () => { expect(pickerUrls()[1].has('exclude')).toBe(false); }); + it('asks for games of the chosen mood and keeps the length', async () => { + mockBackend([[game(1)], [game(2)], [game(3)]]); + renderPage(); + await screen.findByText('Game 1'); + fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.BUDGET.SHORT'})); + await screen.findByText('Game 2'); + + fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.COOP'})); + + expect(await screen.findByText('Game 3')).toBeTruthy(); + expect(pickerUrls()[2].get('mood')).toBe('COOP'); + expect(pickerUrls()[2].get('maxMinutes')).toBe('300'); + expect(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.COOP'}).getAttribute('aria-pressed')).toBe('true'); + }); + + it('sends no mood for any mood and rerolls within the mood', async () => { + mockBackend([[game(1)], [game(2)], [game(3)]]); + renderPage(); + await screen.findByText('Game 1'); + expect(pickerUrls()[0].has('mood')).toBe(false); + + fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.STORY'})); + await screen.findByText('Game 2'); + fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.REROLL'})); + + await screen.findByText('Game 3'); + expect(pickerUrls()[2].get('mood')).toBe('STORY'); + expect(pickerUrls()[2].getAll('exclude')).toEqual(['2']); + }); + + it('suggests another mood when none of the backlog fits', async () => { + mockBackend([[game(1)], []]); + renderPage(); + await screen.findByText('Game 1'); + + fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.SCARY'})); + + expect(await screen.findByText('PLAY_NEXT.EMPTY_MOOD')).toBeTruthy(); + expect(screen.getByText('PLAY_NEXT.MOOD_HINT')).toBeTruthy(); + }); + it('drops the old picks when a new budget fails to load', async () => { mockBackend([[game(1)]]); const pickerFetch = globalThis.fetch;