fix: auto-recalculate playability on benchmark submit and sync, fix anti-cheat logic for games without anti-cheat

This commit is contained in:
2026-04-30 21:35:38 +08:00
parent a9f78ef74c
commit fccef57d43
3 changed files with 203 additions and 172 deletions
+13
View File
@@ -7,6 +7,7 @@ import {
} from "@/lib/db/schema" } from "@/lib/db/schema"
import { eq, sql } from "drizzle-orm" import { eq, sql } from "drizzle-orm"
import { requireRole } from "@/lib/auth/guard" import { requireRole } from "@/lib/auth/guard"
import { recalculatePlayability } from "./playability"
export const performanceSubmitRoutes = new Elysia({ prefix: "/performance" }) export const performanceSubmitRoutes = new Elysia({ prefix: "/performance" })
.get( .get(
@@ -119,6 +120,18 @@ export const performanceSubmitRoutes = new Elysia({ prefix: "/performance" })
}) })
.returning() .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 set.status = 201
return { return {
id: entry.id, id: entry.id,
+70 -65
View File
@@ -6,23 +6,28 @@ import { requireContributorOrAdmin } from "@/lib/auth/guard";
/** /**
* Playability calculation rules: * Playability calculation rules:
* - great: avg FPS >= 55, no anti-cheat issues * - great: avg FPS >= 55, no upscaler/frame-gen dependency
* - playable: avg FPS >= 30, no critical anti-cheat issues * - playable: avg FPS >= 30, or >= 55 with upscaler dependency
* - needs_tweaks: avg FPS >= 20 OR has upscaler/frame-gen dependency * - needs_tweaks: avg FPS >= 20 but < 30
* - unplayable: avg FPS < 20 OR anti-cheat unsupported * - unplayable: avg FPS < 20, OR anti-cheat is relevant AND unsupported
* - unknown: no benchmark data * - 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: { function calculatePlayability(stats: {
avgFps: number | null; avgFps: number | null;
avgFpsLow: number | null; antiCheatRelevant: boolean;
antiCheatStatus: string | null; antiCheatStatus: string | null;
hasUpscalerDependency: boolean; hasUpscalerDependency: boolean;
entryCount: number; entryCount: number;
}): "great" | "playable" | "needs_tweaks" | "unplayable" | "unknown" { }): "great" | "playable" | "needs_tweaks" | "unplayable" | "unknown" {
if (!stats.avgFps || stats.entryCount === 0) return "unknown"; if (!stats.avgFps || stats.entryCount === 0) return "unknown";
// Anti-cheat unsupported = unplayable regardless of FPS // Anti-cheat unsupported = unplayable ONLY if the game actually uses anti-cheat
if (stats.antiCheatStatus === "unsupported") return "unplayable"; if (stats.antiCheatRelevant && stats.antiCheatStatus === "unsupported") {
return "unplayable";
}
const fps = stats.avgFps; const fps = stats.avgFps;
@@ -33,19 +38,15 @@ function calculatePlayability(stats: {
return "unplayable"; return "unplayable";
} }
export const playabilityRoutes = new Elysia({ prefix: "/playability" }) /**
// Auto-calculate playability for a game (all devices) * Recalculate playability for a game (all devices).
.post( * Called automatically after benchmark submission and Steam sync.
"/calculate/:gameId", * Skips devices with manual overrides.
async ({ params, request, set }) => { */
const guard = await requireContributorOrAdmin(request.headers); export async function recalculatePlayability(gameId: string): Promise<{
if (!guard.ok) { gamePlayability: string;
set.status = guard.status; deviceResults: Array<{ hardwareSlug: string; playabilityStatus: string }>;
return { error: guard.error }; }> {
}
const gameId = params.gameId;
// Get all platform support entries for this game // Get all platform support entries for this game
const platformEntries = await db const platformEntries = await db
.select() .select()
@@ -57,30 +58,25 @@ export const playabilityRoutes = new Elysia({ prefix: "/playability" })
.select({ .select({
hardwareSlug: performanceEntries.hardwareSlug, hardwareSlug: performanceEntries.hardwareSlug,
avgFps: avg(performanceEntries.fpsAvg).mapWith(Number), avgFps: avg(performanceEntries.fpsAvg).mapWith(Number),
avgFpsLow: avg(performanceEntries.fpsLow).mapWith(Number),
entryCount: count(performanceEntries.id), entryCount: count(performanceEntries.id),
upscalerEntries: sql<number>`count(case when ${performanceEntries.upscalerType} != 'none' then 1 end)`.mapWith(Number), upscalerEntries:
frameGenEntries: sql<number>`count(case when ${performanceEntries.frameGenMethod} != 'none' then 1 end)`.mapWith(Number), sql<number>`count(case when ${performanceEntries.upscalerType} != 'none' then 1 end)`.mapWith(
Number,
),
frameGenEntries:
sql<number>`count(case when ${performanceEntries.frameGenMethod} != 'none' then 1 end)`.mapWith(
Number,
),
}) })
.from(performanceEntries) .from(performanceEntries)
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id)) .innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
.where( .where(and(eq(gameVersions.gameId, gameId), eq(performanceEntries.isRemoved, false)))
and(
eq(gameVersions.gameId, gameId),
eq(performanceEntries.isRemoved, false)
)
)
.groupBy(performanceEntries.hardwareSlug); .groupBy(performanceEntries.hardwareSlug);
const results: Array<{ const results: Array<{ hardwareSlug: string; playabilityStatus: string }> = [];
hardwareSlug: string;
playabilityStatus: string;
}> = [];
for (const stat of deviceStats) { for (const stat of deviceStats) {
const platformEntry = platformEntries.find( const platformEntry = platformEntries.find((p) => p.hardwareSlug === stat.hardwareSlug);
(p) => p.hardwareSlug === stat.hardwareSlug
);
const hasUpscalerDependency = const hasUpscalerDependency =
stat.upscalerEntries > stat.entryCount * 0.5 || stat.upscalerEntries > stat.entryCount * 0.5 ||
@@ -88,7 +84,7 @@ export const playabilityRoutes = new Elysia({ prefix: "/playability" })
const status = calculatePlayability({ const status = calculatePlayability({
avgFps: stat.avgFps, avgFps: stat.avgFps,
avgFpsLow: stat.avgFpsLow, antiCheatRelevant: platformEntry?.antiCheatRelevant ?? false,
antiCheatStatus: platformEntry?.antiCheatStatus ?? null, antiCheatStatus: platformEntry?.antiCheatStatus ?? null,
hasUpscalerDependency, hasUpscalerDependency,
entryCount: stat.entryCount, entryCount: stat.entryCount,
@@ -105,8 +101,8 @@ export const playabilityRoutes = new Elysia({ prefix: "/playability" })
.where( .where(
and( and(
eq(gamePlatformSupport.gameId, gameId), eq(gamePlatformSupport.gameId, gameId),
eq(gamePlatformSupport.hardwareSlug, stat.hardwareSlug) eq(gamePlatformSupport.hardwareSlug, stat.hardwareSlug),
) ),
); );
} }
@@ -117,11 +113,22 @@ export const playabilityRoutes = new Elysia({ prefix: "/playability" })
const priority = { unplayable: 0, needs_tweaks: 1, playable: 2, great: 3, unknown: 4 }; const priority = { unplayable: 0, needs_tweaks: 1, playable: 2, great: 3, unknown: 4 };
let worstStatus: string = "unknown"; let worstStatus: string = "unknown";
for (const r of results) { for (const r of results) {
if (priority[r.playabilityStatus as keyof typeof priority] < priority[worstStatus as keyof typeof priority]) { if (
priority[r.playabilityStatus as keyof typeof priority] <
priority[worstStatus as keyof typeof priority]
) {
worstStatus = r.playabilityStatus; 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 await db
.update(games) .update(games)
.set({ .set({
@@ -129,15 +136,25 @@ export const playabilityRoutes = new Elysia({ prefix: "/playability" })
playabilityCalculatedAt: new Date(), playabilityCalculatedAt: new Date(),
}) })
.where(eq(games.id, gameId)); .where(eq(games.id, gameId));
}
return { gamePlayability: worstStatus, deviceResults: results }; 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) // Manual override for a game (admin/contributor)
.post( .post("/override/:gameId", async ({ params, body, request, set }) => {
"/override/:gameId",
async ({ params, body, request, set }) => {
const guard = await requireContributorOrAdmin(request.headers); const guard = await requireContributorOrAdmin(request.headers);
if (!guard.ok) { if (!guard.ok) {
set.status = guard.status; set.status = guard.status;
@@ -155,38 +172,27 @@ export const playabilityRoutes = new Elysia({ prefix: "/playability" })
} }
if (hardwareSlug) { if (hardwareSlug) {
// Override for specific device
await db await db
.update(gamePlatformSupport) .update(gamePlatformSupport)
.set({ .set({ playabilityStatus: status as any, playabilityOverride: true })
playabilityStatus: status as any,
playabilityOverride: true,
})
.where( .where(
and( and(
eq(gamePlatformSupport.gameId, params.gameId), eq(gamePlatformSupport.gameId, params.gameId),
eq(gamePlatformSupport.hardwareSlug, hardwareSlug) eq(gamePlatformSupport.hardwareSlug, hardwareSlug),
) ),
); );
} else { } else {
// Override for game overall
await db await db
.update(games) .update(games)
.set({ .set({ playabilityStatus: status as any, playabilityOverride: true })
playabilityStatus: status as any,
playabilityOverride: true,
})
.where(eq(games.id, params.gameId)); .where(eq(games.id, params.gameId));
} }
return { success: true }; return { success: true };
} })
)
// Clear override (revert to auto-calculated) // Clear override (revert to auto-calculated)
.post( .post("/clear-override/:gameId", async ({ params, body, request, set }) => {
"/clear-override/:gameId",
async ({ params, body, request, set }) => {
const guard = await requireContributorOrAdmin(request.headers); const guard = await requireContributorOrAdmin(request.headers);
if (!guard.ok) { if (!guard.ok) {
set.status = guard.status; set.status = guard.status;
@@ -202,8 +208,8 @@ export const playabilityRoutes = new Elysia({ prefix: "/playability" })
.where( .where(
and( and(
eq(gamePlatformSupport.gameId, params.gameId), eq(gamePlatformSupport.gameId, params.gameId),
eq(gamePlatformSupport.hardwareSlug, hardwareSlug) eq(gamePlatformSupport.hardwareSlug, hardwareSlug),
) ),
); );
} else { } else {
await db await db
@@ -213,5 +219,4 @@ export const playabilityRoutes = new Elysia({ prefix: "/playability" })
} }
return { success: true }; return { success: true };
} });
);
+13
View File
@@ -1,6 +1,7 @@
import { db } from "@/lib/db/index" import { db } from "@/lib/db/index"
import { games } from "@/lib/db/schema" import { games } from "@/lib/db/schema"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { recalculatePlayability } from "@/lib/api/playability"
interface SteamReviewData { interface SteamReviewData {
reviewScore: number | null; reviewScore: number | null;
@@ -256,6 +257,18 @@ export async function syncSteamGame(
}) })
.where(eq(games.steamAppId, steamAppId)) .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 } return { success: true }
} catch (err) { } catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err) const errorMsg = err instanceof Error ? err.message : String(err)