feat(discovery): mature filter, release ranges, seeded shuffle and discovery endpoints

This commit is contained in:
2026-10-12 17:44:53 -07:00
parent 6781e2ae19
commit d65f25aa2a
11 changed files with 632 additions and 10 deletions
@@ -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}');
+79
View File
@@ -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);
});
});
+140
View File
@@ -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;
+13 -3
View File
@@ -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);
})
+2
View File
@@ -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);
+8
View File
@@ -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();
+3
View File
@@ -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);
});
});
+43 -7
View File
@@ -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
const sortColumn = this.getSortColumn(query.sortBy);
const safeOrder = query.sortOrder.toLowerCase() === 'asc' ? 'ASC' : 'DESC';
sql += ` ORDER BY ${sortColumn} ${safeOrder}`;
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;
}
}