diff --git a/README.md b/README.md index 8ba200c..e870517 100644 --- a/README.md +++ b/README.md @@ -22,14 +22,16 @@ To Be Played is split into three repositories: - **Dashboard**: most played games, games in progress, "Pile of Shame" with backlog hours and a random suggestion, Steam avatar and a banner from the most played game of the last two weeks, and an achievement card: unlocked total, perfect games, average completion and the 5 games closest to 100 %. -- **What to play next** (`/play-next`): three backlog picks for a time budget (up to 5 / 15 / 40 h or any), high - priority first, reroll without repeats, and "Start playing" in one click. +- **What to play next** (`/play-next`): three backlog picks for a time budget (up to 5 / 15 / 40 h or any) and a mood + (story, co-op, chill, action, scary from IGDB; speedrun from speedrun.com), high priority first, reroll without + repeats, and "Start playing" in one click. - **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest in library, rating or priority, paging. Each row shows the unlocked achievements. - **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit, a platform and optional hours. Works without a Steam ID. - **Game detail**: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %), - a hint when Steam shows recent playtime for a game still in the backlog, and one to set the status to 100 % when + a link to the game's speedrun.com page when it has verified runs, a hint when Steam shows recent playtime for a + game still in the backlog, and one to set the status to 100 % when every achievement is unlocked. - **Wishlist** with store price and discount. - **Public profile** at `/u/`, opt-in in the settings, with perfect games and average completion. @@ -64,6 +66,7 @@ flowchart LR Core --> Steam["Steam Web API"] Core --> Igdb["IGDB API"] Core -->|"OAuth token"| Twitch["Twitch"] + Core --> Speedrun["speedrun.com API"] Browser -.->|"game art"| Images["Steam and IGDB image hosts"] ``` @@ -117,7 +120,7 @@ npm run build ## Roadmap -- Mood filter for "What to play next". +See the roadmap in the ToBePlayed-Core readme. ## License diff --git a/public/i18n/de.json b/public/i18n/de.json index 54e1ee4..a9456ed 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -185,6 +185,7 @@ "NOT_FOUND": "Spiel nicht in deiner Bibliothek.", "PLAYTIME": "Spielzeit: {{time}}", "OPEN_IN_STEAM": "Auf Steam ansehen", + "OPEN_IN_SPEEDRUN": "Speedruns auf speedrun.com", "ACHIEVEMENTS": "Erfolge", "UNLOCKED": "Erreicht", "REMAINING": "Offen", @@ -312,10 +313,13 @@ "COOP": "Koop", "CHILL": "Entspannt", "ACTION": "Action", - "SCARY": "Gruselig" + "SCARY": "Gruselig", + "SPEEDRUN": "Speedrun" }, "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." + "MOOD_HINT_SPEEDRUN": "Nur Spiele mit bestätigten Runs auf speedrun.com. Der Backlog wird im Hintergrund geprüft, etwa ein Spiel alle paar Sekunden.", + "EMPTY_MOOD": "Kein Backlog-Spiel passt. Probier eine andere Stimmung oder Dauer. Neue Spiele bekommen ihre Stimmung kurz nach der ersten Auswahl.", + "EMPTY_SPEEDRUN": "Kein Backlog-Spiel mit Speedruns gefunden. Neue Spiele werden eventuell noch im Hintergrund geprüft; versuch es in ein paar Minuten noch einmal." }, "MANUAL_GAME": { "ADD_BUTTON": "Spiel hinzufügen", diff --git a/public/i18n/en.json b/public/i18n/en.json index 751212a..3f8e879 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -185,6 +185,7 @@ "NOT_FOUND": "Game not in your library.", "PLAYTIME": "Playtime: {{time}}", "OPEN_IN_STEAM": "View on Steam", + "OPEN_IN_SPEEDRUN": "Speedruns on speedrun.com", "ACHIEVEMENTS": "Achievements", "UNLOCKED": "Unlocked", "REMAINING": "Remaining", @@ -312,10 +313,13 @@ "COOP": "Co-op", "CHILL": "Chill", "ACTION": "Action", - "SCARY": "Scary" + "SCARY": "Scary", + "SPEEDRUN": "Speedrun" }, "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." + "MOOD_HINT_SPEEDRUN": "Only games with verified runs on speedrun.com. The backlog is checked in the background, about one game every few seconds.", + "EMPTY_MOOD": "No backlog game fits. Try another mood or length. New games get their mood a moment after the first pick.", + "EMPTY_SPEEDRUN": "No backlog game with speedruns found. New games may still be checked in the background; try again in a few minutes." }, "MANUAL_GAME": { "ADD_BUTTON": "Add game", diff --git a/src/pages/GameDetail.jsx b/src/pages/GameDetail.jsx index 944ec6f..5773ba8 100644 --- a/src/pages/GameDetail.jsx +++ b/src/pages/GameDetail.jsx @@ -1,7 +1,7 @@ import {useEffect, useMemo, useState} from 'react'; import {Link, useNavigate, useParams} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; -import {ArrowLeft, Clock, ExternalLink, Gamepad2, Image as ImageIcon, ImageOff, PanelTop, RotateCcw, ShieldOff, Trophy} from 'lucide-react'; +import {ArrowLeft, Clock, ExternalLink, Gamepad2, Image as ImageIcon, ImageOff, PanelTop, RotateCcw, ShieldOff, Timer, Trophy} from 'lucide-react'; import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; @@ -24,6 +24,8 @@ import {apiFetch} from '../utils/api.js'; import {hasSession} from '../utils/auth.js'; import {isSteamKey} from '../utils/manualGames.js'; +const SPEEDRUN_PREFIX = 'https://www.speedrun.com/'; + const formatUnlockedAt = (instant, locale) => { if (!instant) return ''; try { @@ -103,6 +105,9 @@ export default function GameDetail() { const manual = data?.source === 'MANUAL'; const onSteam = !!data && isSteamKey(data.appid); + // Core checks the link too; a link from the API must never run script. + const speedrunUrl = typeof data?.speedrunUrl === 'string' && data.speedrunUrl.startsWith(SPEEDRUN_PREFIX) + ? data.speedrunUrl : null; const isBackground = !!data && appearance.backgroundAppid === data.appid; const isBanner = !!data && appearance.showBanner && appearance.bannerAppid === data.appid; @@ -154,6 +159,13 @@ export default function GameDetail() {
+ {speedrunUrl && ( + + )} {/* Store page, background and banner need Steam art, so a game off Steam has none of them. */} {onSteam &&
diff --git a/src/pages/GameDetail.test.jsx b/src/pages/GameDetail.test.jsx index 998f4cb..7a4603d 100644 --- a/src/pages/GameDetail.test.jsx +++ b/src/pages/GameDetail.test.jsx @@ -35,6 +35,24 @@ afterEach(() => { }); describe('GameDetail', () => { + it('links the speedrun.com page when Core found one', async () => { + globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, + json: async () => ({...game, speedrunUrl: 'https://www.speedrun.com/hl2'})})); + renderDetail(); + + const link = await screen.findByRole('link', {name: /GAME_DETAIL.OPEN_IN_SPEEDRUN/}); + expect(link.getAttribute('href')).toBe('https://www.speedrun.com/hl2'); + }); + + it.each([null, 'https://www.speedrun.com.evil.example/hl2', 'https://evil.example/hl2', 42])( + 'shows no speedrun link for %s', async (speedrunUrl) => { + globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...game, speedrunUrl})})); + renderDetail(); + + await screen.findByText('GAME_DETAIL.OPEN_IN_STEAM'); + expect(screen.queryByText('GAME_DETAIL.OPEN_IN_SPEEDRUN')).toBeNull(); + }); + it('keeps unsaved notes in the backlog card when the hint sets the game to playing', async () => { renderDetail(); const notes = await screen.findByLabelText('BACKLOG.NOTES_LABEL'); diff --git a/src/pages/PlayNextPage.jsx b/src/pages/PlayNextPage.jsx index 7445877..eab3d6f 100644 --- a/src/pages/PlayNextPage.jsx +++ b/src/pages/PlayNextPage.jsx @@ -23,8 +23,9 @@ 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']; +// Core derives the moods from IGDB's genres, themes and game modes, and SPEEDRUN from speedrun.com; a mood leaves out +// games without that data. +const MOODS = ['ANY', 'STORY', 'COOP', 'CHILL', 'ACTION', 'SCARY', 'SPEEDRUN']; 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; @@ -144,7 +145,9 @@ export default function PlayNextPage() { ))}
{mood !== 'ANY' && ( -

{t('PLAY_NEXT.MOOD_HINT')}

+

+ {t(mood === 'SPEEDRUN' ? 'PLAY_NEXT.MOOD_HINT_SPEEDRUN' : 'PLAY_NEXT.MOOD_HINT')} +

)} @@ -188,6 +191,8 @@ export default function PlayNextPage() { } const emptyKey = (budget, mood) => { + // With a length limit, waiting for the background check may never help. + if (mood === 'SPEEDRUN' && !budget.maxMinutes) return 'PLAY_NEXT.EMPTY_SPEEDRUN'; if (mood !== 'ANY') return 'PLAY_NEXT.EMPTY_MOOD'; return budget.maxMinutes ? 'PLAY_NEXT.EMPTY_BUDGET' : 'PLAY_NEXT.EMPTY'; }; diff --git a/src/pages/PlayNextPage.test.jsx b/src/pages/PlayNextPage.test.jsx index a1f2765..e64111e 100644 --- a/src/pages/PlayNextPage.test.jsx +++ b/src/pages/PlayNextPage.test.jsx @@ -110,6 +110,30 @@ describe('PlayNextPage', () => { expect(screen.getByText('PLAY_NEXT.MOOD_HINT')).toBeTruthy(); }); + it('asks for speedrun games and explains the background check', async () => { + mockBackend([[game(1)], []]); + renderPage(); + await screen.findByText('Game 1'); + + fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.SPEEDRUN'})); + + expect(await screen.findByText('PLAY_NEXT.EMPTY_SPEEDRUN')).toBeTruthy(); + expect(pickerUrls()[1].get('mood')).toBe('SPEEDRUN'); + expect(screen.getByText('PLAY_NEXT.MOOD_HINT_SPEEDRUN')).toBeTruthy(); + }); + + it('with a length limit, an empty speedrun pick says to change the length', async () => { + mockBackend([[game(1)], [game(2)], []]); + 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.SPEEDRUN'})); + + expect(await screen.findByText('PLAY_NEXT.EMPTY_MOOD')).toBeTruthy(); + }); + it('drops the old picks when a new budget fails to load', async () => { mockBackend([[game(1)]]); const pickerFetch = globalThis.fetch;