From fccef57d43e85ca2e7cad396021993abede1bd8c Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Thu, 30 Apr 2026 21:35:38 +0800 Subject: [PATCH] fix: auto-recalculate playability on benchmark submit and sync, fix anti-cheat logic for games without anti-cheat --- lib/api/performance-submit.ts | 13 ++ lib/api/playability.ts | 349 +++++++++++++++++----------------- lib/steam/sync.ts | 13 ++ 3 files changed, 203 insertions(+), 172 deletions(-) diff --git a/lib/api/performance-submit.ts b/lib/api/performance-submit.ts index 4b03235..59497f7 100644 --- a/lib/api/performance-submit.ts +++ b/lib/api/performance-submit.ts @@ -7,6 +7,7 @@ import { } from "@/lib/db/schema" import { eq, sql } from "drizzle-orm" import { requireRole } from "@/lib/auth/guard" +import { recalculatePlayability } from "./playability" export const performanceSubmitRoutes = new Elysia({ prefix: "/performance" }) .get( @@ -119,6 +120,18 @@ export const performanceSubmitRoutes = new Elysia({ prefix: "/performance" }) }) .returning() + // Recalculate playability for this game (fire and forget) + const [gameVersion] = await db + .select({ gameId: gameVersions.gameId }) + .from(gameVersions) + .where(eq(gameVersions.id, body.versionId)) + .limit(1) + if (gameVersion) { + recalculatePlayability(gameVersion.gameId).catch((err) => + console.error("Failed to recalculate playability:", err), + ) + } + set.status = 201 return { id: entry.id, diff --git a/lib/api/playability.ts b/lib/api/playability.ts index c4a7e2a..04f2834 100644 --- a/lib/api/playability.ts +++ b/lib/api/playability.ts @@ -6,23 +6,28 @@ import { requireContributorOrAdmin } from "@/lib/auth/guard"; /** * Playability calculation rules: - * - great: avg FPS >= 55, no anti-cheat issues - * - playable: avg FPS >= 30, no critical anti-cheat issues - * - needs_tweaks: avg FPS >= 20 OR has upscaler/frame-gen dependency - * - unplayable: avg FPS < 20 OR anti-cheat unsupported + * - great: avg FPS >= 55, no upscaler/frame-gen dependency + * - playable: avg FPS >= 30, or >= 55 with upscaler dependency + * - needs_tweaks: avg FPS >= 20 but < 30 + * - unplayable: avg FPS < 20, OR anti-cheat is relevant AND unsupported * - unknown: no benchmark data + * + * IMPORTANT: Anti-cheat only blocks playability if the game actually uses anti-cheat + * (antiCheatRelevant = true). Games without anti-cheat are unaffected. */ function calculatePlayability(stats: { avgFps: number | null; - avgFpsLow: number | null; + antiCheatRelevant: boolean; antiCheatStatus: string | null; hasUpscalerDependency: boolean; entryCount: number; }): "great" | "playable" | "needs_tweaks" | "unplayable" | "unknown" { if (!stats.avgFps || stats.entryCount === 0) return "unknown"; - // Anti-cheat unsupported = unplayable regardless of FPS - if (stats.antiCheatStatus === "unsupported") return "unplayable"; + // Anti-cheat unsupported = unplayable ONLY if the game actually uses anti-cheat + if (stats.antiCheatRelevant && stats.antiCheatStatus === "unsupported") { + return "unplayable"; + } const fps = stats.avgFps; @@ -33,185 +38,185 @@ function calculatePlayability(stats: { return "unplayable"; } -export const playabilityRoutes = new Elysia({ prefix: "/playability" }) - // Auto-calculate playability for a game (all devices) - .post( - "/calculate/:gameId", - async ({ params, request, set }) => { - const guard = await requireContributorOrAdmin(request.headers); - if (!guard.ok) { - set.status = guard.status; - return { error: guard.error }; - } +/** + * Recalculate playability for a game (all devices). + * Called automatically after benchmark submission and Steam sync. + * Skips devices with manual overrides. + */ +export async function recalculatePlayability(gameId: string): Promise<{ + gamePlayability: string; + deviceResults: Array<{ hardwareSlug: string; playabilityStatus: string }>; +}> { + // Get all platform support entries for this game + const platformEntries = await db + .select() + .from(gamePlatformSupport) + .where(eq(gamePlatformSupport.gameId, gameId)); - const gameId = params.gameId; + // Get performance stats per device + const deviceStats = await db + .select({ + hardwareSlug: performanceEntries.hardwareSlug, + avgFps: avg(performanceEntries.fpsAvg).mapWith(Number), + entryCount: count(performanceEntries.id), + upscalerEntries: + sql`count(case when ${performanceEntries.upscalerType} != 'none' then 1 end)`.mapWith( + Number, + ), + frameGenEntries: + sql`count(case when ${performanceEntries.frameGenMethod} != 'none' then 1 end)`.mapWith( + Number, + ), + }) + .from(performanceEntries) + .innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id)) + .where(and(eq(gameVersions.gameId, gameId), eq(performanceEntries.isRemoved, false))) + .groupBy(performanceEntries.hardwareSlug); - // Get all platform support entries for this game - const platformEntries = await db - .select() - .from(gamePlatformSupport) - .where(eq(gamePlatformSupport.gameId, gameId)); + const results: Array<{ hardwareSlug: string; playabilityStatus: string }> = []; - // Get performance stats per device - const deviceStats = await db - .select({ - hardwareSlug: performanceEntries.hardwareSlug, - avgFps: avg(performanceEntries.fpsAvg).mapWith(Number), - avgFpsLow: avg(performanceEntries.fpsLow).mapWith(Number), - entryCount: count(performanceEntries.id), - upscalerEntries: sql`count(case when ${performanceEntries.upscalerType} != 'none' then 1 end)`.mapWith(Number), - frameGenEntries: sql`count(case when ${performanceEntries.frameGenMethod} != 'none' then 1 end)`.mapWith(Number), - }) - .from(performanceEntries) - .innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id)) - .where( - and( - eq(gameVersions.gameId, gameId), - eq(performanceEntries.isRemoved, false) - ) - ) - .groupBy(performanceEntries.hardwareSlug); + for (const stat of deviceStats) { + const platformEntry = platformEntries.find((p) => p.hardwareSlug === stat.hardwareSlug); - const results: Array<{ - hardwareSlug: string; - playabilityStatus: string; - }> = []; + const hasUpscalerDependency = + stat.upscalerEntries > stat.entryCount * 0.5 || + stat.frameGenEntries > stat.entryCount * 0.5; - for (const stat of deviceStats) { - const platformEntry = platformEntries.find( - (p) => p.hardwareSlug === stat.hardwareSlug - ); - - const hasUpscalerDependency = - stat.upscalerEntries > stat.entryCount * 0.5 || - stat.frameGenEntries > stat.entryCount * 0.5; - - const status = calculatePlayability({ - avgFps: stat.avgFps, - avgFpsLow: stat.avgFpsLow, - antiCheatStatus: platformEntry?.antiCheatStatus ?? null, - hasUpscalerDependency, - entryCount: stat.entryCount, - }); - - // Only update if not manually overridden - if (platformEntry && !platformEntry.playabilityOverride) { - await db - .update(gamePlatformSupport) - .set({ - playabilityStatus: status as any, - playabilityCalculatedAt: new Date(), - }) - .where( - and( - eq(gamePlatformSupport.gameId, gameId), - eq(gamePlatformSupport.hardwareSlug, stat.hardwareSlug) - ) - ); - } - - results.push({ hardwareSlug: stat.hardwareSlug, playabilityStatus: status }); - } - - // Update aggregate game-level playability (worst of all devices) - const priority = { unplayable: 0, needs_tweaks: 1, playable: 2, great: 3, unknown: 4 }; - let worstStatus: string = "unknown"; - for (const r of results) { - if (priority[r.playabilityStatus as keyof typeof priority] < priority[worstStatus as keyof typeof priority]) { - worstStatus = r.playabilityStatus; - } - } + const status = calculatePlayability({ + avgFps: stat.avgFps, + antiCheatRelevant: platformEntry?.antiCheatRelevant ?? false, + antiCheatStatus: platformEntry?.antiCheatStatus ?? null, + hasUpscalerDependency, + entryCount: stat.entryCount, + }); + // Only update if not manually overridden + if (platformEntry && !platformEntry.playabilityOverride) { await db - .update(games) + .update(gamePlatformSupport) .set({ - playabilityStatus: worstStatus as any, + playabilityStatus: status as any, playabilityCalculatedAt: new Date(), }) - .where(eq(games.id, gameId)); - - return { gamePlayability: worstStatus, deviceResults: results }; + .where( + and( + eq(gamePlatformSupport.gameId, gameId), + eq(gamePlatformSupport.hardwareSlug, stat.hardwareSlug), + ), + ); } - ) + + results.push({ hardwareSlug: stat.hardwareSlug, playabilityStatus: status }); + } + + // Update aggregate game-level playability (worst of all devices) + const priority = { unplayable: 0, needs_tweaks: 1, playable: 2, great: 3, unknown: 4 }; + let worstStatus: string = "unknown"; + for (const r of results) { + if ( + priority[r.playabilityStatus as keyof typeof priority] < + priority[worstStatus as keyof typeof priority] + ) { + worstStatus = r.playabilityStatus; + } + } + + // Only update game-level if not manually overridden + const [game] = await db + .select({ playabilityOverride: games.playabilityOverride }) + .from(games) + .where(eq(games.id, gameId)) + .limit(1); + + if (!game?.playabilityOverride) { + await db + .update(games) + .set({ + playabilityStatus: worstStatus as any, + playabilityCalculatedAt: new Date(), + }) + .where(eq(games.id, gameId)); + } + + return { gamePlayability: worstStatus, deviceResults: results }; +} + +export const playabilityRoutes = new Elysia({ prefix: "/playability" }) + // Manual trigger for recalculation (admin/contributor) + .post("/calculate/:gameId", async ({ params, request, set }) => { + const guard = await requireContributorOrAdmin(request.headers); + if (!guard.ok) { + set.status = guard.status; + return { error: guard.error }; + } + + return recalculatePlayability(params.gameId); + }) // Manual override for a game (admin/contributor) - .post( - "/override/:gameId", - async ({ params, body, request, set }) => { - const guard = await requireContributorOrAdmin(request.headers); - if (!guard.ok) { - set.status = guard.status; - return { error: guard.error }; - } - - const { status, hardwareSlug } = body as { - status: string; - hardwareSlug?: string; - }; - - if (!["great", "playable", "needs_tweaks", "unplayable"].includes(status)) { - set.status = 400; - return { error: "Invalid playability status" }; - } - - if (hardwareSlug) { - // Override for specific device - await db - .update(gamePlatformSupport) - .set({ - playabilityStatus: status as any, - playabilityOverride: true, - }) - .where( - and( - eq(gamePlatformSupport.gameId, params.gameId), - eq(gamePlatformSupport.hardwareSlug, hardwareSlug) - ) - ); - } else { - // Override for game overall - await db - .update(games) - .set({ - playabilityStatus: status as any, - playabilityOverride: true, - }) - .where(eq(games.id, params.gameId)); - } - - return { success: true }; + .post("/override/:gameId", async ({ params, body, request, set }) => { + const guard = await requireContributorOrAdmin(request.headers); + if (!guard.ok) { + set.status = guard.status; + return { error: guard.error }; } - ) + + const { status, hardwareSlug } = body as { + status: string; + hardwareSlug?: string; + }; + + if (!["great", "playable", "needs_tweaks", "unplayable"].includes(status)) { + set.status = 400; + return { error: "Invalid playability status" }; + } + + if (hardwareSlug) { + await db + .update(gamePlatformSupport) + .set({ playabilityStatus: status as any, playabilityOverride: true }) + .where( + and( + eq(gamePlatformSupport.gameId, params.gameId), + eq(gamePlatformSupport.hardwareSlug, hardwareSlug), + ), + ); + } else { + await db + .update(games) + .set({ playabilityStatus: status as any, playabilityOverride: true }) + .where(eq(games.id, params.gameId)); + } + + return { success: true }; + }) // Clear override (revert to auto-calculated) - .post( - "/clear-override/:gameId", - async ({ params, body, request, set }) => { - const guard = await requireContributorOrAdmin(request.headers); - if (!guard.ok) { - set.status = guard.status; - return { error: guard.error }; - } - - const { hardwareSlug } = body as { hardwareSlug?: string }; - - if (hardwareSlug) { - await db - .update(gamePlatformSupport) - .set({ playabilityOverride: false }) - .where( - and( - eq(gamePlatformSupport.gameId, params.gameId), - eq(gamePlatformSupport.hardwareSlug, hardwareSlug) - ) - ); - } else { - await db - .update(games) - .set({ playabilityOverride: false }) - .where(eq(games.id, params.gameId)); - } - - return { success: true }; + .post("/clear-override/:gameId", async ({ params, body, request, set }) => { + const guard = await requireContributorOrAdmin(request.headers); + if (!guard.ok) { + set.status = guard.status; + return { error: guard.error }; } - ); \ No newline at end of file + + const { hardwareSlug } = body as { hardwareSlug?: string }; + + if (hardwareSlug) { + await db + .update(gamePlatformSupport) + .set({ playabilityOverride: false }) + .where( + and( + eq(gamePlatformSupport.gameId, params.gameId), + eq(gamePlatformSupport.hardwareSlug, hardwareSlug), + ), + ); + } else { + await db + .update(games) + .set({ playabilityOverride: false }) + .where(eq(games.id, params.gameId)); + } + + return { success: true }; + }); diff --git a/lib/steam/sync.ts b/lib/steam/sync.ts index 5848b42..03f003f 100644 --- a/lib/steam/sync.ts +++ b/lib/steam/sync.ts @@ -1,6 +1,7 @@ import { db } from "@/lib/db/index" import { games } from "@/lib/db/schema" import { eq } from "drizzle-orm" +import { recalculatePlayability } from "@/lib/api/playability" interface SteamReviewData { reviewScore: number | null; @@ -256,6 +257,18 @@ export async function syncSteamGame( }) .where(eq(games.steamAppId, steamAppId)) + // Recalculate playability after sync (fire and forget) + const [game] = await db + .select({ id: games.id }) + .from(games) + .where(eq(games.steamAppId, steamAppId)) + .limit(1) + if (game) { + recalculatePlayability(game.id).catch((err) => + console.error("Failed to recalculate playability after sync:", err), + ) + } + return { success: true } } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err)