fix: auto-recalculate playability on benchmark submit and sync, fix anti-cheat logic for games without anti-cheat
This commit is contained in:
@@ -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,
|
||||
|
||||
+70
-65
@@ -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,19 +38,15 @@ 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 };
|
||||
}
|
||||
|
||||
const gameId = params.gameId;
|
||||
|
||||
/**
|
||||
* 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()
|
||||
@@ -57,30 +58,25 @@ export const playabilityRoutes = new Elysia({ prefix: "/playability" })
|
||||
.select({
|
||||
hardwareSlug: performanceEntries.hardwareSlug,
|
||||
avgFps: avg(performanceEntries.fpsAvg).mapWith(Number),
|
||||
avgFpsLow: avg(performanceEntries.fpsLow).mapWith(Number),
|
||||
entryCount: count(performanceEntries.id),
|
||||
upscalerEntries: 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),
|
||||
upscalerEntries:
|
||||
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)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.where(
|
||||
and(
|
||||
eq(gameVersions.gameId, gameId),
|
||||
eq(performanceEntries.isRemoved, false)
|
||||
)
|
||||
)
|
||||
.where(and(eq(gameVersions.gameId, gameId), eq(performanceEntries.isRemoved, false)))
|
||||
.groupBy(performanceEntries.hardwareSlug);
|
||||
|
||||
const results: Array<{
|
||||
hardwareSlug: string;
|
||||
playabilityStatus: string;
|
||||
}> = [];
|
||||
const results: Array<{ hardwareSlug: string; playabilityStatus: string }> = [];
|
||||
|
||||
for (const stat of deviceStats) {
|
||||
const platformEntry = platformEntries.find(
|
||||
(p) => p.hardwareSlug === stat.hardwareSlug
|
||||
);
|
||||
const platformEntry = platformEntries.find((p) => p.hardwareSlug === stat.hardwareSlug);
|
||||
|
||||
const hasUpscalerDependency =
|
||||
stat.upscalerEntries > stat.entryCount * 0.5 ||
|
||||
@@ -88,7 +84,7 @@ export const playabilityRoutes = new Elysia({ prefix: "/playability" })
|
||||
|
||||
const status = calculatePlayability({
|
||||
avgFps: stat.avgFps,
|
||||
avgFpsLow: stat.avgFpsLow,
|
||||
antiCheatRelevant: platformEntry?.antiCheatRelevant ?? false,
|
||||
antiCheatStatus: platformEntry?.antiCheatStatus ?? null,
|
||||
hasUpscalerDependency,
|
||||
entryCount: stat.entryCount,
|
||||
@@ -105,8 +101,8 @@ export const playabilityRoutes = new Elysia({ prefix: "/playability" })
|
||||
.where(
|
||||
and(
|
||||
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 };
|
||||
let worstStatus: string = "unknown";
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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({
|
||||
@@ -129,15 +136,25 @@ export const playabilityRoutes = new Elysia({ prefix: "/playability" })
|
||||
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 }) => {
|
||||
.post("/override/:gameId", async ({ params, body, request, set }) => {
|
||||
const guard = await requireContributorOrAdmin(request.headers);
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status;
|
||||
@@ -155,38 +172,27 @@ export const playabilityRoutes = new Elysia({ prefix: "/playability" })
|
||||
}
|
||||
|
||||
if (hardwareSlug) {
|
||||
// Override for specific device
|
||||
await db
|
||||
.update(gamePlatformSupport)
|
||||
.set({
|
||||
playabilityStatus: status as any,
|
||||
playabilityOverride: true,
|
||||
})
|
||||
.set({ playabilityStatus: status as any, playabilityOverride: true })
|
||||
.where(
|
||||
and(
|
||||
eq(gamePlatformSupport.gameId, params.gameId),
|
||||
eq(gamePlatformSupport.hardwareSlug, hardwareSlug)
|
||||
)
|
||||
eq(gamePlatformSupport.hardwareSlug, hardwareSlug),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Override for game overall
|
||||
await db
|
||||
.update(games)
|
||||
.set({
|
||||
playabilityStatus: status as any,
|
||||
playabilityOverride: true,
|
||||
})
|
||||
.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 }) => {
|
||||
.post("/clear-override/:gameId", async ({ params, body, request, set }) => {
|
||||
const guard = await requireContributorOrAdmin(request.headers);
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status;
|
||||
@@ -202,8 +208,8 @@ export const playabilityRoutes = new Elysia({ prefix: "/playability" })
|
||||
.where(
|
||||
and(
|
||||
eq(gamePlatformSupport.gameId, params.gameId),
|
||||
eq(gamePlatformSupport.hardwareSlug, hardwareSlug)
|
||||
)
|
||||
eq(gamePlatformSupport.hardwareSlug, hardwareSlug),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await db
|
||||
@@ -213,5 +219,4 @@ export const playabilityRoutes = new Elysia({ prefix: "/playability" })
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user