feat(discovery): mature filter, release ranges, seeded shuffle and discovery endpoints
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
-- ===================================
|
||||
-- Migration 009: Site tagline
|
||||
-- ===================================
|
||||
-- A line shown under the site name in the header, part of an instance's branding.
|
||||
|
||||
INSERT OR IGNORE INTO system_settings (key, value, data_type, category, description, is_public, default_value, validation_schema)
|
||||
VALUES
|
||||
('app.tagline', '', 'string', 'app', 'Tagline shown under the site name', 1, '', '{"type":"string","maxLength":120}');
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
|
||||
vi.mock('../utils/logger', () => ({
|
||||
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
const passThrough = vi.hoisted(() => (_req: unknown, _res: unknown, next: () => void) => next());
|
||||
vi.mock('../middleware/rateLimiter', () => ({ rateLimitStandard: passThrough }));
|
||||
vi.mock('../middleware/auth', () => ({ sharedAccessAuth: passThrough }));
|
||||
|
||||
const playlists = vi.hoisted(() => [
|
||||
{ id: 'hof', title: ' Hall of Fame ', library: 'arcade', gameIds: ['g1', 'g2', 'g3', 'g4'] },
|
||||
{ id: 'food', title: 'Flash Food', library: 'arcade', gameIds: ['g3', 'g5'] },
|
||||
{ id: 'anim', title: 'Animations', library: 'theatre', gameIds: ['g6'] },
|
||||
{ id: 'empty', title: 'Favorites', library: 'arcade', gameIds: [] },
|
||||
]);
|
||||
vi.mock('../services/PlaylistService', () => ({
|
||||
PlaylistService: vi.fn().mockImplementation(() => ({ getAllPlaylists: async () => playlists })),
|
||||
}));
|
||||
vi.mock('../services/GameService', () => ({
|
||||
GameService: vi.fn().mockImplementation(() => ({
|
||||
getGameById: async (id: string) => ({ id, title: `Game ${id}` }),
|
||||
getGamesByIds: async (ids: string[]) => ids.map((id) => ({ id, title: `Game ${id}` })),
|
||||
})),
|
||||
}));
|
||||
vi.mock('../services/MatureContentService', () => ({
|
||||
MatureContentService: { isMature: (id: string) => id === 'g2' },
|
||||
}));
|
||||
|
||||
import discoveryRouter from './discovery';
|
||||
import { errorHandler } from '../middleware/errorHandler';
|
||||
|
||||
const app = express();
|
||||
app.use('/api/discovery', discoveryRouter);
|
||||
app.use(errorHandler);
|
||||
|
||||
describe('discovery', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('picks the same curated game for everyone on a day, never a mature one by default', async () => {
|
||||
const days = await Promise.all(
|
||||
Array.from({ length: 30 }, (_, i) =>
|
||||
request(app).get(`/api/discovery/daily?date=2026-10-${String(i + 1).padStart(2, '0')}`)
|
||||
)
|
||||
);
|
||||
const picks = days.map((res) => res.body.game.id);
|
||||
expect(new Set(picks).size).toBeGreaterThan(1);
|
||||
expect(picks).not.toContain('g2');
|
||||
expect(picks).not.toContain('g6');
|
||||
|
||||
const again = await request(app).get('/api/discovery/daily?date=2026-10-01');
|
||||
expect(again.body.game.id).toBe(picks[0]);
|
||||
expect(again.body.playlist.title).toMatch(/Hall of Fame|Flash Food/);
|
||||
});
|
||||
|
||||
it('returns shelves of curated arcade playlists, reproducible by seed', async () => {
|
||||
const first = await request(app).get('/api/discovery/playlists?count=5&perShelf=3&seed=42');
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body.map((shelf: { id: string }) => shelf.id).sort()).toEqual(['food', 'hof']);
|
||||
const hof = first.body.find((shelf: { id: string }) => shelf.id === 'hof');
|
||||
expect(hof).toMatchObject({ title: 'Hall of Fame', total: 3 });
|
||||
expect(hof.games).toHaveLength(3);
|
||||
expect(hof.games.map((g: { id: string }) => g.id)).not.toContain('g2');
|
||||
|
||||
const again = await request(app).get('/api/discovery/playlists?count=5&perShelf=3&seed=42');
|
||||
expect(again.body).toEqual(first.body);
|
||||
});
|
||||
|
||||
it('includes mature games when asked', async () => {
|
||||
const res = await request(app).get(
|
||||
'/api/discovery/playlists?count=5&perShelf=10&seed=1&showExtreme=true'
|
||||
);
|
||||
const hof = res.body.find((shelf: { id: string }) => shelf.id === 'hof');
|
||||
expect(hof.total).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { asyncHandler } from '../middleware/asyncHandler';
|
||||
import { sharedAccessAuth } from '../middleware/auth';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
import { rateLimitStandard } from '../middleware/rateLimiter';
|
||||
import { GameService } from '../services/GameService';
|
||||
import { MatureContentService } from '../services/MatureContentService';
|
||||
import { PlaylistService, type Playlist } from '../services/PlaylistService';
|
||||
|
||||
const router = Router();
|
||||
const gameService = new GameService();
|
||||
const playlistService = new PlaylistService();
|
||||
|
||||
router.use(rateLimitStandard);
|
||||
router.use(sharedAccessAuth);
|
||||
|
||||
const showExtremeSchema = z
|
||||
.enum(['true', 'false'])
|
||||
.optional()
|
||||
.transform((value) => value === 'true');
|
||||
|
||||
/** mulberry32: a small seeded generator, so a seed always gives the same picks */
|
||||
function seededRandom(seed: number): () => number {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) >>> 0;
|
||||
let t = state;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function shuffled<T>(items: readonly T[], seed: number): T[] {
|
||||
const random = seededRandom(seed);
|
||||
const result = [...items];
|
||||
for (let i = result.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(random() * (i + 1));
|
||||
[result[i], result[j]] = [result[j], result[i]];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function hashString(text: string): number {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
/** Flashpoint's curated arcade playlists, with their games narrowed to what may be shown. */
|
||||
async function curatedPlaylists(
|
||||
showExtreme: boolean
|
||||
): Promise<Required<Pick<Playlist, 'id' | 'title' | 'gameIds'>>[]> {
|
||||
const playlists = await playlistService.getAllPlaylists();
|
||||
return playlists
|
||||
.filter((playlist) => (playlist.library ?? 'arcade') === 'arcade')
|
||||
.map((playlist) => ({
|
||||
id: playlist.id,
|
||||
title: playlist.title.trim(),
|
||||
gameIds: (playlist.gameIds ?? []).filter(
|
||||
(id) => showExtreme || !MatureContentService.isMature(id)
|
||||
),
|
||||
}))
|
||||
.filter((playlist) => playlist.gameIds.length > 0);
|
||||
}
|
||||
|
||||
// The same pick for everyone on a given day, from Flashpoint's curated playlists
|
||||
router.get(
|
||||
'/daily',
|
||||
asyncHandler(async (req, res) => {
|
||||
const date =
|
||||
z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.optional()
|
||||
.parse(req.query.date) ?? new Date().toISOString().slice(0, 10);
|
||||
const showExtreme = showExtremeSchema.parse(req.query.showExtreme);
|
||||
|
||||
const playlists = await curatedPlaylists(showExtreme);
|
||||
const pool = [...new Set(playlists.flatMap((playlist) => playlist.gameIds))].sort();
|
||||
if (pool.length === 0) {
|
||||
throw new AppError(404, 'No curated games found');
|
||||
}
|
||||
|
||||
// Skip games missing from this install, trying the following ones in a fixed order
|
||||
const start = hashString(date) % pool.length;
|
||||
for (let i = 0; i < Math.min(pool.length, 20); i++) {
|
||||
const gameId = pool[(start + i) % pool.length];
|
||||
const game = await gameService.getGameById(gameId);
|
||||
if (game) {
|
||||
const playlist = playlists.find((p) => p.gameIds.includes(gameId));
|
||||
res.json({ date, game, playlist: playlist && { id: playlist.id, title: playlist.title } });
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new AppError(404, 'No curated games found');
|
||||
})
|
||||
);
|
||||
|
||||
// A few curated playlists, each with a sample of its games, shuffled by seed
|
||||
router.get(
|
||||
'/playlists',
|
||||
asyncHandler(async (req, res) => {
|
||||
const query = z
|
||||
.object({
|
||||
count: z.coerce.number().int().min(1).max(10).default(3),
|
||||
perShelf: z.coerce.number().int().min(1).max(30).default(7),
|
||||
seed: z.coerce.number().int().min(0).max(2147483647).default(0),
|
||||
})
|
||||
.parse(req.query);
|
||||
const showExtreme = showExtremeSchema.parse(req.query.showExtreme);
|
||||
|
||||
const playlists = shuffled(await curatedPlaylists(showExtreme), query.seed).slice(
|
||||
0,
|
||||
query.count
|
||||
);
|
||||
const shelves = await Promise.all(
|
||||
playlists.map(async (playlist) => {
|
||||
const sample = shuffled(playlist.gameIds, query.seed + hashString(playlist.id)).slice(
|
||||
0,
|
||||
query.perShelf
|
||||
);
|
||||
const games = await gameService.getGamesByIds(sample);
|
||||
const byId = new Map(games.map((game) => [game.id, game]));
|
||||
return {
|
||||
id: playlist.id,
|
||||
title: playlist.title,
|
||||
total: playlist.gameIds.length,
|
||||
games: sample.flatMap((id) => byId.get(id) ?? []),
|
||||
};
|
||||
})
|
||||
);
|
||||
res.json(shelves);
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -26,6 +26,8 @@ const booleanSchema = z.preprocess((val) => {
|
||||
|
||||
const gameIdSchema = z.string().uuid('Invalid game ID format');
|
||||
|
||||
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected YYYY-MM-DD');
|
||||
|
||||
// Schema for POST search requests (arrays in JSON body) - supports complex filter combinations
|
||||
const searchBodySchema = z.object({
|
||||
search: z.string().optional(),
|
||||
@@ -42,9 +44,12 @@ const searchBodySchema = z.object({
|
||||
yearTo: z.number().int().min(1970).max(2100).optional(),
|
||||
dateAddedSince: z.string().datetime().optional(),
|
||||
dateModifiedSince: z.string().datetime().optional(),
|
||||
releasedFrom: isoDate.optional(),
|
||||
releasedTo: isoDate.optional(),
|
||||
sortBy: z
|
||||
.enum(['title', 'releaseDate', 'dateAdded', 'dateModified', 'developer'])
|
||||
.enum(['title', 'releaseDate', 'dateAdded', 'dateModified', 'developer', 'random'])
|
||||
.default('title'),
|
||||
seed: z.number().int().min(0).max(2147483647).optional(),
|
||||
sortOrder: z.enum(['asc', 'desc']).default('asc'),
|
||||
page: z.number().int().min(1).default(1),
|
||||
limit: z.number().int().min(1).max(100).default(50),
|
||||
@@ -100,7 +105,10 @@ router.post(
|
||||
yearTo: body.yearTo,
|
||||
dateAddedSince: body.dateAddedSince,
|
||||
dateModifiedSince: body.dateModifiedSince,
|
||||
releasedFrom: body.releasedFrom,
|
||||
releasedTo: body.releasedTo,
|
||||
sortBy: body.sortBy,
|
||||
seed: body.seed,
|
||||
sortOrder: body.sortOrder,
|
||||
page: body.page,
|
||||
limit: body.limit,
|
||||
@@ -207,7 +215,8 @@ router.get(
|
||||
}
|
||||
}
|
||||
|
||||
const game = await gameService.getRandomGame(library, platforms);
|
||||
const showExtreme = booleanSchema.optional().parse(req.query.showExtreme) ?? false;
|
||||
const game = await gameService.getRandomGame(library, platforms, showExtreme);
|
||||
|
||||
if (!game) {
|
||||
throw new AppError(404, 'No games found');
|
||||
@@ -249,7 +258,8 @@ router.get(
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = gameIdSchema.parse(req.params.id);
|
||||
const limit = Math.max(1, Math.min(parseInt(req.query.limit as string, 10) || 10, 50));
|
||||
const relatedGames = await gameService.getRelatedGames(id, limit);
|
||||
const showExtreme = booleanSchema.optional().parse(req.query.showExtreme) ?? false;
|
||||
const relatedGames = await gameService.getRelatedGames(id, limit, showExtreme);
|
||||
|
||||
res.json(relatedGames);
|
||||
})
|
||||
|
||||
@@ -23,6 +23,7 @@ import sharedPlaylistsRouter from './shared-playlists';
|
||||
import favoritesRouter from './favorites';
|
||||
import ratingsRouter from './ratings';
|
||||
import gameSavesRouter from './game-saves';
|
||||
import discoveryRouter from './discovery';
|
||||
import ruffleRouter from './ruffle';
|
||||
import githubRouter from './github';
|
||||
import domainsRouter from './domains';
|
||||
@@ -51,6 +52,7 @@ export function setupRoutes(app: Express): void {
|
||||
app.use('/api/favorites', favoritesRouter);
|
||||
app.use('/api/ratings', ratingsRouter);
|
||||
app.use('/api/game-saves', gameSavesRouter);
|
||||
app.use('/api/discovery', discoveryRouter);
|
||||
app.use('/api/games', gamesRouter);
|
||||
app.use('/api/games', downloadsRouter);
|
||||
app.use('/api/platforms', platformsRouter);
|
||||
|
||||
@@ -353,6 +353,10 @@ export class DatabaseService {
|
||||
if (GameSearchCache) {
|
||||
GameSearchCache.clearCache();
|
||||
}
|
||||
// Lazy require: MatureContentService queries through this service
|
||||
(
|
||||
require('./MatureContentService') as typeof import('./MatureContentService')
|
||||
).MatureContentService.invalidate();
|
||||
if (GameServiceStatic) {
|
||||
GameServiceStatic.clearFlashSwfCache();
|
||||
GameServiceStatic.clearFilterOptionsCache();
|
||||
@@ -436,6 +440,10 @@ export class DatabaseService {
|
||||
if (GameSearchCache) {
|
||||
GameSearchCache.clearCache();
|
||||
}
|
||||
// Lazy require: MatureContentService queries through this service
|
||||
(
|
||||
require('./MatureContentService') as typeof import('./MatureContentService')
|
||||
).MatureContentService.invalidate();
|
||||
if (GameServiceStatic) {
|
||||
GameServiceStatic.clearFlashSwfCache();
|
||||
GameServiceStatic.clearFilterOptionsCache();
|
||||
|
||||
@@ -63,6 +63,9 @@ export class GameSearchCache {
|
||||
yearTo: query.yearTo ?? '',
|
||||
dateAddedSince: query.dateAddedSince || '',
|
||||
dateModifiedSince: query.dateModifiedSince || '',
|
||||
releasedFrom: query.releasedFrom || '',
|
||||
releasedTo: query.releasedTo || '',
|
||||
seed: query.sortBy === 'random' ? (query.seed ?? 0) : '',
|
||||
sortBy: query.sortBy,
|
||||
sortOrder: query.sortOrder,
|
||||
page: query.page,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import BetterSqlite3 from 'better-sqlite3';
|
||||
|
||||
vi.mock('./DatabaseService', () => ({
|
||||
DatabaseService: { all: vi.fn(), allAsync: vi.fn(), get: vi.fn(), getDatabase: vi.fn() },
|
||||
}));
|
||||
vi.mock('../utils/logger', () => ({
|
||||
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
// No preferences file here, so the launcher's default extreme tags apply
|
||||
vi.mock('../config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../config')>();
|
||||
return { config: { ...actual.config, flashpointPath: '/nonexistent-flashpoint' } };
|
||||
});
|
||||
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
import { GameService } from './GameService';
|
||||
import { MatureContentService } from './MatureContentService';
|
||||
|
||||
let db: BetterSqlite3.Database;
|
||||
|
||||
const games = [
|
||||
['a', 'Ant Attack', '2008-06-03'],
|
||||
['b', 'Bloodbath', '2008-06-04'],
|
||||
['c', 'Cat Chase', '2008'],
|
||||
['d', 'Dog Dash', '2008-06'],
|
||||
['e', 'Eel Escape', '2008-06-09'],
|
||||
['f', 'Frog Frenzy', '2008-06-08'],
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
db = new BetterSqlite3(':memory:');
|
||||
db.exec(`
|
||||
CREATE TABLE game (
|
||||
id TEXT PRIMARY KEY, title TEXT, alternateTitles TEXT, developer TEXT, publisher TEXT,
|
||||
platformName TEXT, platformsStr TEXT, library TEXT, orderTitle TEXT, releaseDate TEXT, tagsStr TEXT,
|
||||
broken INTEGER DEFAULT 0, extreme INTEGER DEFAULT 0, launchCommand TEXT
|
||||
);
|
||||
CREATE TABLE game_data (id INTEGER PRIMARY KEY, gameId TEXT, launchCommand TEXT, presentOnDisk INTEGER);
|
||||
CREATE TABLE tag_alias (id INTEGER PRIMARY KEY, tagId INTEGER, name TEXT);
|
||||
CREATE TABLE game_tags_tag (gameId TEXT, tagId INTEGER, PRIMARY KEY (gameId, tagId));
|
||||
INSERT INTO tag_alias VALUES (1, 10, 'Gore'), (2, 11, 'Puzzle');
|
||||
INSERT INTO game_tags_tag VALUES ('b', 10), ('a', 11);
|
||||
`);
|
||||
const insert = db.prepare(
|
||||
"INSERT INTO game (id, title, orderTitle, releaseDate, platformName, library) VALUES (?, ?, lower(?), ?, 'HTML5', 'arcade')"
|
||||
);
|
||||
for (const [id, title, date] of games) insert.run(id, title, title, date);
|
||||
vi.mocked(DatabaseService.all).mockImplementation(
|
||||
(sql: string, params: unknown[] = []) => db.prepare(sql).all(...params) as unknown[]
|
||||
);
|
||||
vi.mocked(DatabaseService.allAsync).mockImplementation(
|
||||
async (sql: string, params: unknown[] = []) => db.prepare(sql).all(...params) as unknown[]
|
||||
);
|
||||
vi.mocked(DatabaseService.get).mockImplementation(
|
||||
(sql: string, params: unknown[] = []) => db.prepare(sql).get(...params) as unknown
|
||||
);
|
||||
MatureContentService.invalidate();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const query = {
|
||||
sortBy: 'title',
|
||||
sortOrder: 'asc' as const,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
showBroken: false,
|
||||
showExtreme: false,
|
||||
fields: 'list' as const,
|
||||
};
|
||||
const titles = (result: { data: { title: string }[] }) => result.data.map((g) => g.title);
|
||||
|
||||
describe('game search for discovery', () => {
|
||||
it('hides games with extreme tags unless asked to show them', async () => {
|
||||
expect(titles(await new GameService().searchGames(query))).not.toContain('Bloodbath');
|
||||
expect(titles(await new GameService().searchGames({ ...query, showExtreme: true }))).toContain(
|
||||
'Bloodbath'
|
||||
);
|
||||
expect(await new GameService().getRandomGame('arcade', undefined, false)).not.toMatchObject({
|
||||
id: 'b',
|
||||
});
|
||||
});
|
||||
|
||||
it('finds games released in a date range, only where the day is known', async () => {
|
||||
const result = await new GameService().searchGames({
|
||||
...query,
|
||||
showExtreme: true,
|
||||
releasedFrom: '2008-06-02',
|
||||
releasedTo: '2008-06-08',
|
||||
sortBy: 'releaseDate',
|
||||
sortOrder: 'desc',
|
||||
});
|
||||
expect(titles(result)).toEqual(['Frog Frenzy', 'Bloodbath', 'Ant Attack']);
|
||||
});
|
||||
|
||||
it('shuffles by seed, consistently across pages', async () => {
|
||||
const service = new GameService();
|
||||
const shuffle = (seed: number, page: number, limit: number) =>
|
||||
service.searchGames({ ...query, showExtreme: true, sortBy: 'random', seed, page, limit });
|
||||
|
||||
const all = titles(await shuffle(7, 1, 6));
|
||||
expect(titles(await shuffle(7, 1, 6))).toEqual(all);
|
||||
expect([...titles(await shuffle(7, 1, 3)), ...titles(await shuffle(7, 2, 3))]).toEqual(all);
|
||||
const others = await Promise.all(
|
||||
[1, 2, 3, 4, 5].map(async (seed) => titles(await shuffle(seed, 1, 6)))
|
||||
);
|
||||
expect(others.some((order) => order.join() !== all.join())).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
import { logger } from '../utils/logger';
|
||||
import { config } from '../config';
|
||||
import { MatureContentService } from './MatureContentService';
|
||||
import { StreamingService } from './StreamingService';
|
||||
|
||||
export interface GameSearchQuery {
|
||||
@@ -19,7 +20,12 @@ export interface GameSearchQuery {
|
||||
yearTo?: number;
|
||||
dateAddedSince?: string; // ISO 8601 timestamp - filter games added after this date
|
||||
dateModifiedSince?: string; // ISO 8601 timestamp - filter games modified after this date
|
||||
/** Release date range, YYYY-MM-DD inclusive; only games dated to the day match */
|
||||
releasedFrom?: string;
|
||||
releasedTo?: string;
|
||||
sortBy: string;
|
||||
/** Shuffle seed for sortBy 'random' */
|
||||
seed?: number;
|
||||
sortOrder: 'asc' | 'desc';
|
||||
page: number;
|
||||
limit: number;
|
||||
@@ -357,7 +363,7 @@ export class GameService {
|
||||
// library and orderTitle needed for filtering/sorting
|
||||
return `
|
||||
g.id, g.title, g.developer, g.platformName,
|
||||
g.tagsStr, g.library, g.orderTitle
|
||||
g.tagsStr, g.library, g.orderTitle, g.releaseDate
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -509,7 +515,18 @@ export class GameService {
|
||||
}
|
||||
|
||||
if (!query.showExtreme) {
|
||||
sql += ` AND (g.extreme = 0 OR g.extreme IS NULL)`;
|
||||
sql += ` AND ${MatureContentService.excludeCondition('g', params)}`;
|
||||
}
|
||||
|
||||
// Release date range, for "what came out that week"; only dates known to the day
|
||||
if (query.releasedFrom) {
|
||||
sql += ` AND length(g.releaseDate) >= 10 AND g.releaseDate >= ?`;
|
||||
params.push(query.releasedFrom);
|
||||
}
|
||||
|
||||
if (query.releasedTo) {
|
||||
sql += ` AND length(g.releaseDate) >= 10 AND g.releaseDate <= ?`;
|
||||
params.push(`${query.releasedTo}~`);
|
||||
}
|
||||
|
||||
// Exclude Flash games without SWF launch commands (not web-playable)
|
||||
@@ -536,9 +553,17 @@ export class GameService {
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
if (query.sortBy === 'random') {
|
||||
// A seeded shuffle, so paging through it neither repeats nor skips games. Each
|
||||
// multiplier permutes rowids differently modulo the prime; an added seed wouldn't.
|
||||
const multiplier = 1 + ((((query.seed ?? 0) + 1) * 2654435761) % 4294967290);
|
||||
sql += ` ORDER BY (g.rowid * ?) % 4294967291, g.rowid`;
|
||||
params.push(multiplier);
|
||||
} else {
|
||||
const sortColumn = this.getSortColumn(query.sortBy);
|
||||
const safeOrder = query.sortOrder.toLowerCase() === 'asc' ? 'ASC' : 'DESC';
|
||||
sql += ` ORDER BY ${sortColumn} ${safeOrder}`;
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
sql += ` LIMIT ? OFFSET ?`;
|
||||
@@ -748,7 +773,7 @@ export class GameService {
|
||||
}
|
||||
}
|
||||
|
||||
async getRelatedGames(gameId: string, limit: number = 10): Promise<Game[]> {
|
||||
async getRelatedGames(gameId: string, limit: number = 10, showExtreme = false): Promise<Game[]> {
|
||||
try {
|
||||
// First get the source game
|
||||
const sourceGame = await this.getGameById(gameId);
|
||||
@@ -757,6 +782,7 @@ export class GameService {
|
||||
}
|
||||
|
||||
// Find games with same developer or platform
|
||||
const matureParams: unknown[] = [];
|
||||
const sql = `
|
||||
SELECT
|
||||
g.id, g.title, g.alternateTitles, g.developer, g.publisher,
|
||||
@@ -768,6 +794,7 @@ export class GameService {
|
||||
g.developer = ? OR
|
||||
g.platformName = ?
|
||||
)
|
||||
${showExtreme ? '' : 'AND ' + MatureContentService.excludeCondition('g', matureParams)}
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN g.developer = ? THEN 1
|
||||
@@ -782,6 +809,7 @@ export class GameService {
|
||||
gameId,
|
||||
sourceGame.developer,
|
||||
sourceGame.platformName,
|
||||
...matureParams,
|
||||
sourceGame.developer,
|
||||
sourceGame.platformName,
|
||||
limit,
|
||||
@@ -794,7 +822,11 @@ export class GameService {
|
||||
}
|
||||
}
|
||||
|
||||
async getRandomGame(library?: string, platforms?: string[]): Promise<Game | null> {
|
||||
async getRandomGame(
|
||||
library?: string,
|
||||
platforms?: string[],
|
||||
showExtreme = false
|
||||
): Promise<Game | null> {
|
||||
try {
|
||||
// Skip Flash SWF filter when explicitly requesting Flash platform
|
||||
const isFlashOnlyFilter = platforms?.length === 1 && platforms[0] === 'Flash';
|
||||
@@ -823,6 +855,10 @@ export class GameService {
|
||||
params.push(...platforms);
|
||||
}
|
||||
|
||||
if (!showExtreme) {
|
||||
sql += ` AND ${MatureContentService.excludeCondition('g', params)}`;
|
||||
}
|
||||
|
||||
sql += ` ORDER BY RANDOM() LIMIT 1`;
|
||||
|
||||
const game = DatabaseService.get(sql, params) as Game | null;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
const config = vi.hoisted(() => ({ flashpointPath: '' }));
|
||||
vi.mock('../config', () => ({ config }));
|
||||
vi.mock('./DatabaseService', () => ({ DatabaseService: { all: vi.fn() } }));
|
||||
vi.mock('../utils/logger', () => ({
|
||||
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
import { MatureContentService } from './MatureContentService';
|
||||
|
||||
describe('MatureContentService.extremeTagNames', () => {
|
||||
beforeEach(() => {
|
||||
config.flashpointPath = fs.mkdtempSync(path.join(os.tmpdir(), 'fp-prefs-'));
|
||||
});
|
||||
|
||||
it("uses the tags of the install's extreme filter groups", () => {
|
||||
fs.writeFileSync(
|
||||
path.join(config.flashpointPath, 'preferences.json'),
|
||||
JSON.stringify({
|
||||
tagFilters: [
|
||||
{ name: 'Violence', extreme: true, tags: ['Gore', 'Strong Language'] },
|
||||
{ name: 'Mature Topics', extreme: false, tags: ['Drugs'] },
|
||||
{ name: 'Custom', extreme: true, tags: ['Gore', 'Crude Humor'] },
|
||||
],
|
||||
})
|
||||
);
|
||||
expect(MatureContentService.extremeTagNames()).toEqual([
|
||||
'Gore',
|
||||
'Strong Language',
|
||||
'Crude Humor',
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to the launcher's defaults without preferences", () => {
|
||||
const names = MatureContentService.extremeTagNames();
|
||||
expect(names).toContain('Adult');
|
||||
expect(names).toContain('Strong Language');
|
||||
expect(names).not.toContain('Drugs');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { config } from '../config';
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* Tags in the Flashpoint launcher's default extreme filter groups (Pornography,
|
||||
* Fetishes, Violence, Bigotry, Pornography Extreme), for installs whose
|
||||
* preferences don't list them.
|
||||
*/
|
||||
const DEFAULT_EXTREME_TAGS: readonly string[] = [
|
||||
'Anal',
|
||||
'Anal Insertion',
|
||||
'Cartoon Porn',
|
||||
'Adult',
|
||||
'Anilingus',
|
||||
'Fingering',
|
||||
'Oral',
|
||||
'Sexual Content',
|
||||
'Cunnilingus',
|
||||
'Fellatio',
|
||||
'Footjob',
|
||||
'Handjob',
|
||||
'Interspecies',
|
||||
'Masturbation',
|
||||
'Paizuri',
|
||||
'Sex Toys',
|
||||
'Tentacles',
|
||||
'Touching',
|
||||
'Tribadism',
|
||||
'Vaginal',
|
||||
'Vaginal Insertion',
|
||||
'Futanari',
|
||||
'Male Futanari',
|
||||
'Gynomorph',
|
||||
'Andromorph',
|
||||
'Intersex',
|
||||
'Porn',
|
||||
'Fisting',
|
||||
'Hentai',
|
||||
'Group',
|
||||
'Solo',
|
||||
'Frottage',
|
||||
'Multiple Penises',
|
||||
'Ambiguous Penetration',
|
||||
'Self Oral',
|
||||
'Sex Simulator',
|
||||
'Undressing',
|
||||
'Age Regression',
|
||||
'Asphyxiophilia',
|
||||
'BDSM',
|
||||
'Bestiality',
|
||||
'Bimbofication',
|
||||
'Breast Milking',
|
||||
'Cannibalism',
|
||||
'Enema',
|
||||
'Expansion',
|
||||
'Flatulence',
|
||||
'Gloryhole',
|
||||
'Hypnosis',
|
||||
'Incest',
|
||||
'Infantilism',
|
||||
'Inflation',
|
||||
'Kabeshiri',
|
||||
'Macrophilia',
|
||||
'Muscle Growth',
|
||||
'Necrophilia',
|
||||
'Obesity',
|
||||
'Oviposition',
|
||||
'Podophilia',
|
||||
'Popping',
|
||||
'Pregnancy',
|
||||
'Quicksand',
|
||||
'Scat',
|
||||
'Selfcest',
|
||||
'Spanking',
|
||||
'Tickling',
|
||||
'Transformation',
|
||||
'Urination',
|
||||
'Vomit',
|
||||
'Vore',
|
||||
'Weight Gain',
|
||||
'Gachimuchi',
|
||||
'Gore',
|
||||
'Strong Violence',
|
||||
'Strong Language',
|
||||
'Homophobia',
|
||||
'Stereotyping',
|
||||
'Racism',
|
||||
'Transphobia',
|
||||
'Sexual Violence',
|
||||
];
|
||||
|
||||
interface TagFilterGroup {
|
||||
extreme?: boolean;
|
||||
tags?: unknown;
|
||||
}
|
||||
|
||||
let matureGameIds: string | null = null;
|
||||
let matureGameIdSet: Set<string> | null = null;
|
||||
|
||||
/**
|
||||
* Decides which games are mature the way the Flashpoint launcher does: a game
|
||||
* carrying any tag from a tag filter group marked extreme. The game table's own
|
||||
* `extreme` flag is unset in current Flashpoint data, so it isn't enough alone.
|
||||
*/
|
||||
export class MatureContentService {
|
||||
/** Extreme tags from the install's preferences, else the launcher's defaults. */
|
||||
static extremeTagNames(): string[] {
|
||||
for (const file of ['preferences.json', '.preferences.defaults.json']) {
|
||||
try {
|
||||
const prefs = JSON.parse(fs.readFileSync(path.join(config.flashpointPath, file), 'utf8'));
|
||||
const groups: TagFilterGroup[] = Array.isArray(prefs.tagFilters) ? prefs.tagFilters : [];
|
||||
const tags = groups
|
||||
.filter((group) => group.extreme)
|
||||
.flatMap((group) => (Array.isArray(group.tags) ? group.tags : []))
|
||||
.filter((tag): tag is string => typeof tag === 'string');
|
||||
if (tags.length > 0) {
|
||||
return [...new Set(tags)];
|
||||
}
|
||||
} catch {
|
||||
// Missing or unreadable; try the next file
|
||||
}
|
||||
}
|
||||
return [...DEFAULT_EXTREME_TAGS];
|
||||
}
|
||||
|
||||
/** JSON array of mature game IDs, computed once per database load. */
|
||||
static matureGameIdsJson(): string {
|
||||
if (matureGameIds === null) {
|
||||
const started = Date.now();
|
||||
const names = this.extremeTagNames();
|
||||
let rows: { gameId: string }[];
|
||||
try {
|
||||
rows = DatabaseService.all<{ gameId: string }>(
|
||||
`SELECT DISTINCT gt.gameId FROM game_tags_tag gt
|
||||
WHERE gt.tagId IN (
|
||||
SELECT ta.tagId FROM tag_alias ta
|
||||
WHERE ta.name COLLATE NOCASE IN (${names.map(() => '?').join(',')})
|
||||
)`,
|
||||
names
|
||||
);
|
||||
} catch (error) {
|
||||
// Without tag tables only the extreme flag can filter; retry on the next query
|
||||
logger.warn(
|
||||
'[MatureContent] Could not read game tags; filtering by the extreme flag only',
|
||||
error
|
||||
);
|
||||
return '[]';
|
||||
}
|
||||
matureGameIds = JSON.stringify(rows.map((row) => row.gameId));
|
||||
logger.info(
|
||||
`[MatureContent] ${rows.length} mature games from ${names.length} tags in ${Date.now() - started}ms`
|
||||
);
|
||||
}
|
||||
return matureGameIds;
|
||||
}
|
||||
|
||||
static isMature(gameId: string): boolean {
|
||||
matureGameIdSet ??= new Set(JSON.parse(this.matureGameIdsJson()) as string[]);
|
||||
return matureGameIdSet.has(gameId);
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL condition excluding mature games; pushes its one parameter onto params.
|
||||
* Passing the IDs as JSON keeps it a plain parameter for the worker pool.
|
||||
*/
|
||||
static excludeCondition(alias: string, params: unknown[]): string {
|
||||
params.push(this.matureGameIdsJson());
|
||||
return `(${alias}.extreme = 0 OR ${alias}.extreme IS NULL) AND ${alias}.id NOT IN (SELECT value FROM json_each(?))`;
|
||||
}
|
||||
|
||||
/** Forget the computed set, after the database changes. */
|
||||
static invalidate(): void {
|
||||
matureGameIds = null;
|
||||
matureGameIdSet = null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user