diff --git a/app/api/[[...slugs]]/route.ts b/app/api/[[...slugs]]/route.ts index f96e239..06187cd 100644 --- a/app/api/[[...slugs]]/route.ts +++ b/app/api/[[...slugs]]/route.ts @@ -24,6 +24,7 @@ import { searchUnifiedRoutes } from "@/lib/api/search-unified" import { gameStubRoutes } from "@/lib/api/game-stub" import { gameStatsRoutes } from "@/lib/api/game-stats" import { gamesManualRoutes } from "@/lib/api/games-manual" +import { compareRoutes } from "@/lib/api/compare" import { gamesListingRoutes } from "@/lib/api/games-listing" import { steamgridProxyRoutes } from "@/lib/api/steamgrid-proxy" @@ -96,6 +97,8 @@ export const app = new Elysia({ prefix: "/api" }) .use(savedGamesRoutes) // Contact form .use(contactRoutes) + // Compare + .use(compareRoutes) // Root .get("/", () => ({ name: "DeckyVault API", diff --git a/lib/api/compare.ts b/lib/api/compare.ts new file mode 100644 index 0000000..1f4ba4d --- /dev/null +++ b/lib/api/compare.ts @@ -0,0 +1,188 @@ +import { Elysia, t } from "elysia" +import { db } from "@/lib/db/index" +import { games, gameVersions, performanceEntries, hardware } from "@/lib/db/schema" +import { eq, and, inArray } from "drizzle-orm" + +export const compareRoutes = new Elysia({ prefix: "/compare" }) + .get( + "/games", + async ({ query, set }) => { + const gameIds = query.ids.split(",").filter(Boolean).slice(0, 4) + if (gameIds.length < 2) { + set.status = 400 + return { error: "At least 2 games required" } + } + + // Fetch games + const gameRows = await db + .select({ + id: games.id, + title: games.title, + steamAppId: games.steamAppId, + source: games.source, + capsuleImage: games.capsuleImage, + headerImage: games.headerImage, + }) + .from(games) + .where(inArray(games.id, gameIds)) + + const results = await Promise.all( + gameRows.map(async (game) => { + // Fetch all non-removed entries for this game + const entries = await db + .select({ + fpsAvg: performanceEntries.fpsAvg, + fpsLow: performanceEntries.fpsLow, + fpsHigh: performanceEntries.fpsHigh, + fpsOnePercentLow: performanceEntries.fpsOnePercentLow, + upscalerType: performanceEntries.upscalerType, + frameGenMethod: performanceEntries.frameGenMethod, + hardwareSlug: performanceEntries.hardwareSlug, + hardwareName: hardware.name, + }) + .from(performanceEntries) + .innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id)) + .innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug)) + .where( + and( + eq(gameVersions.gameId, game.id), + eq(performanceEntries.isRemoved, false) + ) + ) + + if (entries.length === 0) { + return { + ...game, + stats: { + totalEntries: 0, + avgFps: null, + medianFps: null, + bestFps: null, + avgOnePercentLow: null, + avgStability: null, + bestDevice: null, + tierBreakdown: null, + deviceBreakdown: [], + }, + } + } + + const fpsValues = entries.map((e) => e.fpsAvg ?? 0).sort((a, b) => a - b) + const avgFps = fpsValues.reduce((a, b) => a + b, 0) / fpsValues.length + const medianFps = fpsValues[Math.floor(fpsValues.length / 2)] + + const onePercentLowValues = entries + .filter((e) => e.fpsOnePercentLow != null) + .map((e) => e.fpsOnePercentLow!) + + const avgOnePercentLow = + onePercentLowValues.length > 0 + ? onePercentLowValues.reduce((a, b) => a + b, 0) / onePercentLowValues.length + : null + + const stabilityScores = entries + .filter((e) => e.fpsOnePercentLow != null && e.fpsAvg > 0) + .map((e) => Math.min(1, e.fpsOnePercentLow! / (e.fpsAvg ?? 1))) + + const avgStability = + stabilityScores.length > 0 + ? stabilityScores.reduce((a, b) => a + b, 0) / stabilityScores.length + : null + + // Best device by avg fps + const deviceFpsMap = new Map() + for (const e of entries) { + const arr = deviceFpsMap.get(e.hardwareSlug) || [] + arr.push(e.fpsAvg ?? 0) + deviceFpsMap.set(e.hardwareSlug, arr) + } + let bestDevice: string | null = null + let bestDeviceAvg = 0 + for (const [slug, fpsArr] of deviceFpsMap) { + const mean = fpsArr.reduce((a, b) => a + b, 0) / fpsArr.length + if (mean > bestDeviceAvg) { + bestDeviceAvg = mean + bestDevice = slug + } + } + + // Tier breakdown + const tiers = { unplayable: 0, playable: 0, smooth: 0, excellent: 0 } + for (const e of entries) { + const fps = e.fpsAvg ?? 0 + if (fps < 30) tiers.unplayable++ + else if (fps < 60) tiers.playable++ + else if (fps < 120) tiers.smooth++ + else tiers.excellent++ + } + + // Device breakdown + const deviceBreakdown = Array.from(deviceFpsMap.entries()).map(([slug, fpsArr]) => ({ + hardwareSlug: slug, + count: fpsArr.length, + avgFps: Math.round((fpsArr.reduce((a, b) => a + b, 0) / fpsArr.length) * 10) / 10, + })) + + return { + ...game, + stats: { + totalEntries: entries.length, + avgFps: Math.round(avgFps * 10) / 10, + medianFps: Math.round(medianFps * 10) / 10, + bestFps: Math.round(fpsValues[fpsValues.length - 1] * 10) / 10, + avgOnePercentLow: avgOnePercentLow ? Math.round(avgOnePercentLow * 10) / 10 : null, + avgStability: avgStability ? Math.round(avgStability * 100) : null, + bestDevice, + tierBreakdown: tiers, + deviceBreakdown, + }, + } + }) + ) + + return { games: results } + }, + { + query: t.Object({ ids: t.String() }), + } + ) + .get( + "/presets", + async ({ query, set }) => { + const presetIds = query.ids.split(",").filter(Boolean).slice(0, 4) + if (presetIds.length < 2) { + set.status = 400 + return { error: "At least 2 presets required" } + } + + const presetRows = await db + .select({ + id: performanceEntries.id, + fpsAvg: performanceEntries.fpsAvg, + fpsLow: performanceEntries.fpsLow, + fpsHigh: performanceEntries.fpsHigh, + fpsOnePercentLow: performanceEntries.fpsOnePercentLow, + upscalerType: performanceEntries.upscalerType, + upscalerVersion: performanceEntries.upscalerVersion, + frameGenMethod: performanceEntries.frameGenMethod, + protonVersion: performanceEntries.protonVersion, + osVersion: performanceEntries.osVersion, + loadTimeSsd: performanceEntries.loadTimeSsd, + loadTimeSd: performanceEntries.loadTimeSd, + estimatedBatteryMin: performanceEntries.estimatedBatteryMin, + settingsJson: performanceEntries.settingsJson, + launchOptions: performanceEntries.launchOptions, + hardwareSlug: performanceEntries.hardwareSlug, + hardwareName: hardware.name, + createdAt: performanceEntries.createdAt, + }) + .from(performanceEntries) + .innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug)) + .where(inArray(performanceEntries.id, presetIds)) + + return { presets: presetRows } + }, + { + query: t.Object({ ids: t.String() }), + } + ) \ No newline at end of file diff --git a/lib/api/index.ts b/lib/api/index.ts index d7070a5..43b8f29 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -16,3 +16,4 @@ export { adminPerformanceRoutes } from "./admin-performance" export { adminCommentRoutes } from "./admin-comments" export { steamgridProxyRoutes } from "./steamgrid-proxy" export { gamesManualRoutes } from "./games-manual" +export { compareRoutes } from "./compare"