From bd47e161389c5afc894b0ce133b437f21879e8ca Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Mon, 12 Oct 2026 18:59:32 -0700 Subject: [PATCH] feat(games): rank related games by shared tags, series and developer --- .../src/services/GameService.related.test.ts | 107 ++++++++++++++++++ backend/src/services/GameService.ts | 74 +++++++----- 2 files changed, 153 insertions(+), 28 deletions(-) create mode 100644 backend/src/services/GameService.related.test.ts diff --git a/backend/src/services/GameService.related.test.ts b/backend/src/services/GameService.related.test.ts new file mode 100644 index 0000000..108ea67 --- /dev/null +++ b/backend/src/services/GameService.related.test.ts @@ -0,0 +1,107 @@ +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() }, +})); +vi.mock('../config', async (importOriginal) => { + const actual = await importOriginal(); + return { config: { ...actual.config, flashpointPath: '/nonexistent-flashpoint' } }; +}); + +import { DatabaseService } from './DatabaseService'; +import { GameService } from './GameService'; +import { MatureContentService } from './MatureContentService'; +import type { Game } from './GameService'; + +let db: BetterSqlite3.Database; + +beforeEach(() => { + db = new BetterSqlite3(':memory:'); + db.exec(` + CREATE TABLE game ( + id TEXT PRIMARY KEY, title TEXT, alternateTitles TEXT, developer TEXT, publisher TEXT, + series TEXT, platformName TEXT, platformsStr TEXT, library TEXT, orderTitle TEXT, + tagsStr TEXT, extreme INTEGER DEFAULT 0 + ); + CREATE TABLE tag_category (id INTEGER PRIMARY KEY, name TEXT); + CREATE TABLE tag (id INTEGER PRIMARY KEY, categoryId 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_category VALUES (1, 'genre'), (2, 'meta'), (3, 'default'); + INSERT INTO tag VALUES (10, 1), (11, 3), (12, 2), (13, 1); + INSERT INTO tag_alias VALUES (1, 10, 'Puzzle'), (2, 11, 'Cubes'), (3, 12, 'Auto-zipped'), + (4, 13, 'Gore'); + `); + const games: [string, string, string, string, string][] = [ + ['src', 'Cube Logic', 'Ann', 'Cube Logic', 'arcade'], + ['sequel', 'Cube Logic 2', 'Ann', 'Cube Logic', 'arcade'], + ['cubes', 'Cube Stack', 'Bo', '', 'arcade'], + ['puzzle', 'Any Puzzle', 'Cy', '', 'arcade'], + ['zipped', 'Also Zipped', 'Di', '', 'arcade'], + ['gory', 'Gory Cubes', 'Ed', '', 'arcade'], + ['cartoon', 'Cube Cartoon', 'Fi', '', 'theatre'], + ]; + const insert = db.prepare( + "INSERT INTO game (id, title, developer, series, library, platformName) VALUES (?, ?, ?, ?, ?, 'Flash')" + ); + for (const game of games) insert.run(...game); + const tag = db.prepare('INSERT INTO game_tags_tag VALUES (?, ?)'); + for (const [gameId, tagId] of [ + ['src', 10], + ['src', 11], + ['src', 12], + ['sequel', 10], + ['cubes', 10], + ['cubes', 11], + ['puzzle', 10], + ['zipped', 12], + ['gory', 11], + ['gory', 13], + ['cartoon', 11], + ] as const) { + tag.run(gameId, tagId); + } + // Puzzle is the common tag; give it more games so Cubes is the rarer, stronger match + for (let i = 0; i < 20; i++) tag.run(`filler-${i}`, 10); + + 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[] + ); + MatureContentService.invalidate(); +}); + +afterEach(() => { + db.close(); + vi.clearAllMocks(); +}); + +function serviceFor(id: string): GameService { + const service = new GameService(); + const source = db.prepare('SELECT * FROM game WHERE id = ?').get(id) as Game; + vi.spyOn(service, 'getGameById').mockResolvedValue(source); + return service; +} + +describe('related games', () => { + it('ranks the same series first, then games sharing rarer tags', async () => { + const related = await serviceFor('src').getRelatedGames('src', 10); + expect(related.map((game) => game.id)).toEqual(['sequel', 'cubes', 'puzzle']); + }); + + it('ignores meta tags, other libraries and mature games unless asked', async () => { + const ids = (await serviceFor('src').getRelatedGames('src', 10)).map((game) => game.id); + expect(ids).not.toContain('zipped'); + expect(ids).not.toContain('cartoon'); + expect(ids).not.toContain('gory'); + + const withMature = await serviceFor('src').getRelatedGames('src', 10, true); + expect(withMature.map((game) => game.id)).toContain('gory'); + }); +}); diff --git a/backend/src/services/GameService.ts b/backend/src/services/GameService.ts index 1d97db5..40d51a3 100644 --- a/backend/src/services/GameService.ts +++ b/backend/src/services/GameService.ts @@ -773,49 +773,67 @@ export class GameService { } } + /** + * Games most like this one: ranked by shared descriptive tags (rarer tags count for more), + * then the same series, then the same developer. + */ async getRelatedGames(gameId: string, limit: number = 10, showExtreme = false): Promise { try { - // First get the source game const sourceGame = await this.getGameById(gameId); if (!sourceGame) { return []; } - // Find games with same developer or platform - const matureParams: unknown[] = []; + // Meta, presence, warning and language tags say little about what a game is like + const tags = DatabaseService.all( + `SELECT gt.tagId, (SELECT COUNT(*) FROM game_tags_tag c WHERE c.tagId = gt.tagId) AS games + FROM game_tags_tag gt + JOIN tag t ON t.id = gt.tagId + JOIN tag_category tc ON tc.id = t.categoryId + WHERE gt.gameId = ? AND tc.name NOT IN ('meta', 'presence', 'warning', 'language')`, + [gameId] + ) as { tagId: number; games: number }[]; + + const candidates: string[] = []; + const params: unknown[] = []; + if (tags.length > 0) { + candidates.push( + `SELECT gameId AS id, CASE tagId ${tags.map(() => 'WHEN ? THEN ?').join(' ')} END AS score + FROM game_tags_tag WHERE tagId IN (${tags.map(() => '?').join(', ')})` + ); + for (const tag of tags) params.push(tag.tagId, 1 / Math.log(2 + tag.games)); + for (const tag of tags) params.push(tag.tagId); + } + if (sourceGame.series) { + candidates.push('SELECT id, 1.0 FROM game WHERE library = ? AND series = ?'); + params.push(sourceGame.library, sourceGame.series); + } + if (sourceGame.developer) { + candidates.push('SELECT id, 0.5 FROM game WHERE library = ? AND developer = ?'); + params.push(sourceGame.library, sourceGame.developer); + } + if (candidates.length === 0) { + return []; + } + + params.push(gameId, sourceGame.library); const sql = ` SELECT g.id, g.title, g.alternateTitles, g.developer, g.publisher, g.platformName, g.platformsStr, g.library, g.tagsStr, g.orderTitle - FROM game g - WHERE g.id != ? - AND ( - g.developer = ? OR - g.platformName = ? - ) - ${showExtreme ? '' : 'AND ' + MatureContentService.excludeCondition('g', matureParams)} - ORDER BY - CASE - WHEN g.developer = ? THEN 1 - WHEN g.platformName = ? THEN 2 - ELSE 3 - END, - RANDOM() + FROM ( + SELECT id, SUM(score) AS score FROM (${candidates.join(' UNION ALL ')}) GROUP BY id + ) r + JOIN game g ON g.id = r.id + WHERE g.id != ? AND g.library = ? + ${showExtreme ? '' : 'AND ' + MatureContentService.excludeCondition('g', params)} + ORDER BY r.score DESC, RANDOM() LIMIT ? `; + params.push(limit); - const games = DatabaseService.all(sql, [ - gameId, - sourceGame.developer, - sourceGame.platformName, - ...matureParams, - sourceGame.developer, - sourceGame.platformName, - limit, - ]) as Game[]; - - return games; + return (await DatabaseService.allAsync(sql, params)) as Game[]; } catch (error) { logger.error('Error getting related games:', error); throw error;