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,
|
- **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
|
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 %.
|
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
|
- **What to play next** (`/play-next`): three backlog picks for a time budget (up to 5 / 15 / 40 h or any) and a mood
|
||||||
priority first, reroll without repeats, and "Start playing" in one click.
|
(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
|
- **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.
|
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,
|
- **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.
|
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 %),
|
- **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.
|
every achievement is unlocked.
|
||||||
- **Wishlist** with store price and discount.
|
- **Wishlist** with store price and discount.
|
||||||
- **Public profile** at `/u/<display name>`, opt-in in the settings, with perfect games and average completion.
|
- **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 --> Steam["Steam Web API"]
|
||||||
Core --> Igdb["IGDB API"]
|
Core --> Igdb["IGDB API"]
|
||||||
Core -->|"OAuth token"| Twitch["Twitch"]
|
Core -->|"OAuth token"| Twitch["Twitch"]
|
||||||
|
Core --> Speedrun["speedrun.com API"]
|
||||||
Browser -.->|"game art"| Images["Steam and IGDB image hosts"]
|
Browser -.->|"game art"| Images["Steam and IGDB image hosts"]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -117,7 +120,7 @@ npm run build
|
|||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
- Mood filter for "What to play next".
|
See the roadmap in the ToBePlayed-Core readme.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+6
-2
@@ -185,6 +185,7 @@
|
|||||||
"NOT_FOUND": "Spiel nicht in deiner Bibliothek.",
|
"NOT_FOUND": "Spiel nicht in deiner Bibliothek.",
|
||||||
"PLAYTIME": "Spielzeit: {{time}}",
|
"PLAYTIME": "Spielzeit: {{time}}",
|
||||||
"OPEN_IN_STEAM": "Auf Steam ansehen",
|
"OPEN_IN_STEAM": "Auf Steam ansehen",
|
||||||
|
"OPEN_IN_SPEEDRUN": "Speedruns auf speedrun.com",
|
||||||
"ACHIEVEMENTS": "Erfolge",
|
"ACHIEVEMENTS": "Erfolge",
|
||||||
"UNLOCKED": "Erreicht",
|
"UNLOCKED": "Erreicht",
|
||||||
"REMAINING": "Offen",
|
"REMAINING": "Offen",
|
||||||
@@ -312,10 +313,13 @@
|
|||||||
"COOP": "Koop",
|
"COOP": "Koop",
|
||||||
"CHILL": "Entspannt",
|
"CHILL": "Entspannt",
|
||||||
"ACTION": "Action",
|
"ACTION": "Action",
|
||||||
"SCARY": "Gruselig"
|
"SCARY": "Gruselig",
|
||||||
|
"SPEEDRUN": "Speedrun"
|
||||||
},
|
},
|
||||||
"MOOD_HINT": "Nur Spiele, deren Genre IGDB kennt.",
|
"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": {
|
"MANUAL_GAME": {
|
||||||
"ADD_BUTTON": "Spiel hinzufügen",
|
"ADD_BUTTON": "Spiel hinzufügen",
|
||||||
|
|||||||
+6
-2
@@ -185,6 +185,7 @@
|
|||||||
"NOT_FOUND": "Game not in your library.",
|
"NOT_FOUND": "Game not in your library.",
|
||||||
"PLAYTIME": "Playtime: {{time}}",
|
"PLAYTIME": "Playtime: {{time}}",
|
||||||
"OPEN_IN_STEAM": "View on Steam",
|
"OPEN_IN_STEAM": "View on Steam",
|
||||||
|
"OPEN_IN_SPEEDRUN": "Speedruns on speedrun.com",
|
||||||
"ACHIEVEMENTS": "Achievements",
|
"ACHIEVEMENTS": "Achievements",
|
||||||
"UNLOCKED": "Unlocked",
|
"UNLOCKED": "Unlocked",
|
||||||
"REMAINING": "Remaining",
|
"REMAINING": "Remaining",
|
||||||
@@ -312,10 +313,13 @@
|
|||||||
"COOP": "Co-op",
|
"COOP": "Co-op",
|
||||||
"CHILL": "Chill",
|
"CHILL": "Chill",
|
||||||
"ACTION": "Action",
|
"ACTION": "Action",
|
||||||
"SCARY": "Scary"
|
"SCARY": "Scary",
|
||||||
|
"SPEEDRUN": "Speedrun"
|
||||||
},
|
},
|
||||||
"MOOD_HINT": "Only games whose genre IGDB knows.",
|
"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": {
|
"MANUAL_GAME": {
|
||||||
"ADD_BUTTON": "Add game",
|
"ADD_BUTTON": "Add game",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {useEffect, useMemo, useState} from 'react';
|
import {useEffect, useMemo, useState} from 'react';
|
||||||
import {Link, useNavigate, useParams} from 'react-router-dom';
|
import {Link, useNavigate, useParams} from 'react-router-dom';
|
||||||
import {useTranslation} from 'react-i18next';
|
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 {Badge} from '@/components/ui/badge';
|
||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
@@ -24,6 +24,8 @@ import {apiFetch} from '../utils/api.js';
|
|||||||
import {hasSession} from '../utils/auth.js';
|
import {hasSession} from '../utils/auth.js';
|
||||||
import {isSteamKey} from '../utils/manualGames.js';
|
import {isSteamKey} from '../utils/manualGames.js';
|
||||||
|
|
||||||
|
const SPEEDRUN_PREFIX = 'https://www.speedrun.com/';
|
||||||
|
|
||||||
const formatUnlockedAt = (instant, locale) => {
|
const formatUnlockedAt = (instant, locale) => {
|
||||||
if (!instant) return '';
|
if (!instant) return '';
|
||||||
try {
|
try {
|
||||||
@@ -103,6 +105,9 @@ export default function GameDetail() {
|
|||||||
|
|
||||||
const manual = data?.source === 'MANUAL';
|
const manual = data?.source === 'MANUAL';
|
||||||
const onSteam = !!data && isSteamKey(data.appid);
|
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 isBackground = !!data && appearance.backgroundAppid === data.appid;
|
||||||
const isBanner = !!data && appearance.showBanner && appearance.bannerAppid === data.appid;
|
const isBanner = !!data && appearance.showBanner && appearance.bannerAppid === data.appid;
|
||||||
|
|
||||||
@@ -154,6 +159,13 @@ export default function GameDetail() {
|
|||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<TimeToBeat time={data.timeToBeat}/>
|
<TimeToBeat time={data.timeToBeat}/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{/* Store page, background and banner need Steam art, so a game off Steam has none of them. */}
|
{/* 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">
|
{onSteam && <div className="flex flex-wrap gap-2">
|
||||||
|
|||||||
@@ -35,6 +35,24 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('GameDetail', () => {
|
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 () => {
|
it('keeps unsaved notes in the backlog card when the hint sets the game to playing', async () => {
|
||||||
renderDetail();
|
renderDetail();
|
||||||
const notes = await screen.findByLabelText('BACKLOG.NOTES_LABEL');
|
const notes = await screen.findByLabelText('BACKLOG.NOTES_LABEL');
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ const BUDGETS = [
|
|||||||
{key: 'LONG', maxMinutes: 2400},
|
{key: 'LONG', maxMinutes: 2400},
|
||||||
{key: 'ANY', maxMinutes: null},
|
{key: 'ANY', maxMinutes: null},
|
||||||
];
|
];
|
||||||
// The moods Core derives from IGDB's genres, themes and game modes; a mood leaves out games without IGDB data.
|
// Core derives the moods from IGDB's genres, themes and game modes, and SPEEDRUN from speedrun.com; a mood leaves out
|
||||||
const MOODS = ['ANY', 'STORY', 'COOP', 'CHILL', 'ACTION', 'SCARY'];
|
// games without that data.
|
||||||
|
const MOODS = ['ANY', 'STORY', 'COOP', 'CHILL', 'ACTION', 'SCARY', 'SPEEDRUN'];
|
||||||
const PICK_COUNT = 3;
|
const PICK_COUNT = 3;
|
||||||
// The backend reads only this many excludes. With a bigger backlog the oldest seen games can come back.
|
// The backend reads only this many excludes. With a bigger backlog the oldest seen games can come back.
|
||||||
const MAX_EXCLUDES = 100;
|
const MAX_EXCLUDES = 100;
|
||||||
@@ -144,7 +145,9 @@ export default function PlayNextPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{mood !== 'ANY' && (
|
{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>
|
</div>
|
||||||
|
|
||||||
@@ -188,6 +191,8 @@ export default function PlayNextPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const emptyKey = (budget, mood) => {
|
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';
|
if (mood !== 'ANY') return 'PLAY_NEXT.EMPTY_MOOD';
|
||||||
return budget.maxMinutes ? 'PLAY_NEXT.EMPTY_BUDGET' : 'PLAY_NEXT.EMPTY';
|
return budget.maxMinutes ? 'PLAY_NEXT.EMPTY_BUDGET' : 'PLAY_NEXT.EMPTY';
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -110,6 +110,30 @@ describe('PlayNextPage', () => {
|
|||||||
expect(screen.getByText('PLAY_NEXT.MOOD_HINT')).toBeTruthy();
|
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 () => {
|
it('drops the old picks when a new budget fails to load', async () => {
|
||||||
mockBackend([[game(1)]]);
|
mockBackend([[game(1)]]);
|
||||||
const pickerFetch = globalThis.fetch;
|
const pickerFetch = globalThis.fetch;
|
||||||
|
|||||||
Reference in new issue
Block a user