feat(play-next): pick games by mood

This commit is contained in:
lobo committed 2026-09-26 07:37:16 +02:00
1 parent 7d9cf907fc
commit fa1bd59509
4 files changed
+111 -13

No files matched your search

+44 -9
View File
@@ -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() {
)}
</div>
<div className="space-y-2">
<div className="flex flex-wrap gap-2" role="group" aria-label={t('PLAY_NEXT.MOOD_LABEL')}>
{MOODS.map((key) => (
<Button
key={key}
size="sm"
variant={mood === key ? 'default' : 'outline'}
aria-pressed={mood === key}
onClick={() => chooseMood(key)}
>
{t(`PLAY_NEXT.MOOD.${key}`)}
</Button>
))}
</div>
{mood !== 'ANY' && (
<p className="text-xs text-muted-foreground">{t('PLAY_NEXT.MOOD_HINT')}</p>
)}
</div>
{error && <p className="text-sm text-destructive">{t(error)}</p>}
{games === null && !error && (
@@ -133,7 +163,7 @@ export default function PlayNextPage() {
)}
{(games?.length > 0 || error) && (
<Button variant="outline" onClick={() => load(budget.maxMinutes, seen)} disabled={loading}>
<Button variant="outline" onClick={() => load(budget.maxMinutes, mood, seen)} disabled={loading}>
{loading ? <Loader2 className="animate-spin"/> : <Shuffle/>}
{t('PLAY_NEXT.REROLL')}
</Button>
@@ -142,7 +172,7 @@ export default function PlayNextPage() {
{allShown && (
<div className="space-y-3">
<p className="text-muted-foreground">{t('PLAY_NEXT.ALL_SHOWN')}</p>
<Button variant="outline" onClick={() => load(budget.maxMinutes, [])} disabled={loading}>
<Button variant="outline" onClick={() => load(budget.maxMinutes, mood, [])} disabled={loading}>
<Shuffle/>{t('PLAY_NEXT.START_OVER')}
</Button>
</div>
@@ -150,13 +180,18 @@ export default function PlayNextPage() {
{games?.length === 0 && seen.length === 0 && (
<p className="text-muted-foreground">
{t(budget.maxMinutes ? 'PLAY_NEXT.EMPTY_BUDGET' : 'PLAY_NEXT.EMPTY')}
{t(emptyKey(budget, mood))}
</p>
)}
</PageContainer>
);
}
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);
+41
View File
@@ -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;