Public Access
feat(picker): add the speedrun mood and the speedrun.com link
"What to play next" gets a Speedrun mood. The game detail links the game's speedrun.com page when Core found one with verified runs; only https://www.speedrun.com/ links are shown.
This commit is contained in:
1 parent
b0c78d8dca
commit
23ab5a91e7
7 files changed
+82
-12
No files matched your search
@@ -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/<display name>`, 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
|
||||
|
||||
|
||||
+6
-2
@@ -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",
|
||||
|
||||
+6
-2
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
<div className="mt-3">
|
||||
<TimeToBeat time={data.timeToBeat}/>
|
||||
</div>
|
||||
{speedrunUrl && (
|
||||
<Button asChild variant="outline" size="sm" className="mt-3">
|
||||
<a href={speedrunUrl} target="_blank" rel="noopener noreferrer">
|
||||
<Timer/>{t('GAME_DETAIL.OPEN_IN_SPEEDRUN')}<ExternalLink/>
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{/* Store page, background and banner need Steam art, so a game off Steam has none of them. */}
|
||||
{onSteam && <div className="flex flex-wrap gap-2">
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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() {
|
||||
))}
|
||||
</div>
|
||||
{mood !== 'ANY' && (
|
||||
<p className="text-xs text-muted-foreground">{t('PLAY_NEXT.MOOD_HINT')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(mood === 'SPEEDRUN' ? 'PLAY_NEXT.MOOD_HINT_SPEEDRUN' : 'PLAY_NEXT.MOOD_HINT')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user