314 lines
11 KiB
TypeScript
314 lines
11 KiB
TypeScript
import { Elysia, t } from "elysia"
|
|
import { db } from "@/lib/db/index"
|
|
import {
|
|
performanceEntries,
|
|
gameVersions,
|
|
games,
|
|
hardware,
|
|
gamePlatformSupport,
|
|
} from "@/lib/db/schema"
|
|
import { eq, and } from "drizzle-orm"
|
|
import { requireAuthWithApiKeyFallback } from "@/lib/auth/api-key-guard"
|
|
import { recalculatePlayability } from "./playability"
|
|
|
|
const VALID_UPSCALER_TYPES = ["none", "fsr", "dlss", "xess", "lsfg", "other"] as const
|
|
const VALID_FRAME_GEN_METHODS = ["none", "fsr_fg", "dlss_fg", "lsfg", "other"] as const
|
|
const VALID_ANTICHEAT_STATUSES = ["none", "supported", "unsupported", "unknown"] as const
|
|
|
|
type UpscalerType = (typeof VALID_UPSCALER_TYPES)[number]
|
|
type FrameGenMethod = (typeof VALID_FRAME_GEN_METHODS)[number]
|
|
type AntiCheatStatus = (typeof VALID_ANTICHEAT_STATUSES)[number]
|
|
|
|
const FPS_MIN_AVG = 1
|
|
const FPS_MIN_OTHER = 0
|
|
const FPS_MAX = 1000
|
|
|
|
export type FpsInput = {
|
|
fpsAvg: number
|
|
fpsLow?: number | null
|
|
fpsOnePercentLow?: number | null
|
|
fpsHigh?: number | null
|
|
}
|
|
|
|
export type FpsValues = {
|
|
fpsAvg: number
|
|
fpsLow: number | null
|
|
fpsOnePercentLow: number | null
|
|
fpsHigh: number | null
|
|
}
|
|
|
|
export type FpsValidationResult =
|
|
| { ok: true; values: FpsValues }
|
|
| { ok: false; error: string }
|
|
|
|
export function validateFps(input: FpsInput): FpsValidationResult {
|
|
const fpsAvg = Number(input.fpsAvg)
|
|
if (input.fpsAvg == null || isNaN(fpsAvg) || fpsAvg < FPS_MIN_AVG || fpsAvg > FPS_MAX) {
|
|
return { ok: false, error: `fpsAvg must be between ${FPS_MIN_AVG} and ${FPS_MAX}` }
|
|
}
|
|
const fpsLow = input.fpsLow != null ? Number(input.fpsLow) : null
|
|
if (fpsLow !== null && (isNaN(fpsLow) || fpsLow < FPS_MIN_OTHER || fpsLow > FPS_MAX)) {
|
|
return { ok: false, error: `fpsLow must be between ${FPS_MIN_OTHER} and ${FPS_MAX}` }
|
|
}
|
|
const fpsOnePercentLow = input.fpsOnePercentLow != null ? Number(input.fpsOnePercentLow) : null
|
|
if (fpsOnePercentLow !== null && (isNaN(fpsOnePercentLow) || fpsOnePercentLow < FPS_MIN_OTHER || fpsOnePercentLow > FPS_MAX)) {
|
|
return { ok: false, error: `fpsOnePercentLow must be between ${FPS_MIN_OTHER} and ${FPS_MAX}` }
|
|
}
|
|
const fpsHigh = input.fpsHigh != null ? Number(input.fpsHigh) : null
|
|
if (fpsHigh !== null && (isNaN(fpsHigh) || fpsHigh < FPS_MIN_OTHER || fpsHigh > FPS_MAX)) {
|
|
return { ok: false, error: `fpsHigh must be between ${FPS_MIN_OTHER} and ${FPS_MAX}` }
|
|
}
|
|
return { ok: true, values: { fpsAvg, fpsLow, fpsOnePercentLow, fpsHigh } }
|
|
}
|
|
|
|
export const performanceImportRoutes = new Elysia({
|
|
prefix: "/performance",
|
|
detail: { tags: ["Performance"] },
|
|
}).post(
|
|
"/import",
|
|
async ({ body, request, set }) => {
|
|
// ── Auth: session or API key ──────────────────────────────────
|
|
const guard = await requireAuthWithApiKeyFallback(request.headers)
|
|
if (!guard.ok) {
|
|
set.status = guard.status
|
|
return { error: guard.error }
|
|
}
|
|
|
|
// ── Validate version (1 only for now) ─────────────────────────
|
|
if (body.version !== 1) {
|
|
set.status = 400
|
|
return { error: "Unsupported import format version" }
|
|
}
|
|
|
|
// ── Validate FPS fields (before game lookup so a 400 is never masked by a 404) ──
|
|
const fpsResult = validateFps({
|
|
fpsAvg: body.fpsAvg,
|
|
fpsLow: body.fpsLow,
|
|
fpsOnePercentLow: body.fpsOnePercentLow,
|
|
fpsHigh: body.fpsHigh,
|
|
})
|
|
if (!fpsResult.ok) {
|
|
set.status = 400
|
|
return { error: fpsResult.error }
|
|
}
|
|
const { fpsAvg, fpsLow, fpsOnePercentLow, fpsHigh } = fpsResult.values
|
|
|
|
// ── Resolve game version from steamAppId ──────────────────────
|
|
const steamAppId = body.steamAppId
|
|
if (!steamAppId) {
|
|
set.status = 400
|
|
return { error: "steamAppId is required" }
|
|
}
|
|
|
|
const [game] = await db
|
|
.select({ id: games.id })
|
|
.from(games)
|
|
.where(eq(games.steamAppId, steamAppId))
|
|
.limit(1)
|
|
|
|
if (!game) {
|
|
set.status = 404
|
|
return {
|
|
error: `No game found with steamAppId ${steamAppId}. Submit the game on DeckyVault first.`,
|
|
}
|
|
}
|
|
|
|
// Find the latest version, or create one if needed
|
|
let [version] = await db
|
|
.select({ id: gameVersions.id })
|
|
.from(gameVersions)
|
|
.where(
|
|
and(
|
|
eq(gameVersions.gameId, game.id),
|
|
eq(gameVersions.isLatest, true),
|
|
),
|
|
)
|
|
.limit(1)
|
|
|
|
if (!version) {
|
|
// Get the most recent version
|
|
const [existing] = await db
|
|
.select({ id: gameVersions.id })
|
|
.from(gameVersions)
|
|
.where(eq(gameVersions.gameId, game.id))
|
|
.orderBy(gameVersions.createdAt)
|
|
.limit(1)
|
|
|
|
if (existing) {
|
|
version = existing
|
|
} else {
|
|
// Create a stub version so we can create the entry
|
|
const [newVersion] = await db
|
|
.insert(gameVersions)
|
|
.values({
|
|
gameId: game.id,
|
|
isLatest: true,
|
|
versionString: body.versionString ?? null,
|
|
buildId: body.buildId ?? null,
|
|
})
|
|
.returning({ id: gameVersions.id })
|
|
version = newVersion
|
|
}
|
|
}
|
|
|
|
// ── Validate hardware ─────────────────────────────────────────
|
|
const hardwareSlug = body.hardwareSlug
|
|
const [device] = await db
|
|
.select({ slug: hardware.slug, deviceType: hardware.deviceType })
|
|
.from(hardware)
|
|
.where(eq(hardware.slug, hardwareSlug))
|
|
.limit(1)
|
|
|
|
if (!device) {
|
|
set.status = 400
|
|
return {
|
|
error: `Unknown hardware slug: "${hardwareSlug}". Available devices: see /api/hardware`,
|
|
}
|
|
}
|
|
|
|
// ── Validate enums ────────────────────────────────────────────
|
|
const rawUpscalerType = body.upscalerType ?? "none"
|
|
const upscalerType: UpscalerType = VALID_UPSCALER_TYPES.includes(
|
|
rawUpscalerType as UpscalerType,
|
|
)
|
|
? (rawUpscalerType as UpscalerType)
|
|
: "none"
|
|
|
|
const rawFrameGenMethod = body.frameGenMethod ?? "none"
|
|
const frameGenMethod: FrameGenMethod = VALID_FRAME_GEN_METHODS.includes(
|
|
rawFrameGenMethod as FrameGenMethod,
|
|
)
|
|
? (rawFrameGenMethod as FrameGenMethod)
|
|
: "none"
|
|
|
|
// ── Validate other numeric fields ─────────────────────────────
|
|
const tdpWatts = body.tdpWatts != null ? Number(body.tdpWatts) : null
|
|
if (tdpWatts !== null && (isNaN(tdpWatts) || tdpWatts <= 0)) {
|
|
set.status = 400
|
|
return { error: "tdpWatts must be greater than 0" }
|
|
}
|
|
|
|
const loadTimeSsd = body.loadTimeSsd != null ? Number(body.loadTimeSsd) : null
|
|
const loadTimeSd = body.loadTimeSd != null ? Number(body.loadTimeSd) : null
|
|
|
|
// ── Validate userNotes length ─────────────────────────────────
|
|
const userNotes = body.userNotes ?? null
|
|
if (userNotes && typeof userNotes === "string" && userNotes.length > 5000) {
|
|
set.status = 400
|
|
return { error: "userNotes must be 5000 characters or less" }
|
|
}
|
|
|
|
// ── Create the performance entry ──────────────────────────────
|
|
const [entry] = await db
|
|
.insert(performanceEntries)
|
|
.values({
|
|
versionId: version.id,
|
|
hardwareSlug,
|
|
userId: guard.user.id,
|
|
fpsAvg,
|
|
fpsLow,
|
|
fpsOnePercentLow,
|
|
fpsHigh,
|
|
protonVersion: body.protonVersion ?? null,
|
|
osVersion: body.osVersion ?? null,
|
|
upscalerType,
|
|
upscalerVersion: body.upscalerVersion ?? null,
|
|
frameGenMethod,
|
|
loadTimeSsd,
|
|
loadTimeSd,
|
|
tdpWatts,
|
|
launchOptions: body.launchOptions ?? null,
|
|
settingsJson: body.settingsJson ?? null,
|
|
userNotes,
|
|
customSystem: body.customSystem ?? false,
|
|
})
|
|
.returning()
|
|
|
|
// ── Update / create gamePlatformSupport ──────────────────────
|
|
const [existingSupport] = await db
|
|
.select()
|
|
.from(gamePlatformSupport)
|
|
.where(
|
|
and(
|
|
eq(gamePlatformSupport.gameId, game.id),
|
|
eq(gamePlatformSupport.hardwareSlug, hardwareSlug),
|
|
),
|
|
)
|
|
.limit(1)
|
|
|
|
if (existingSupport) {
|
|
await db
|
|
.update(gamePlatformSupport)
|
|
.set({
|
|
antiCheatRelevant:
|
|
body.antiCheatRelevant ?? existingSupport.antiCheatRelevant,
|
|
antiCheatName: body.antiCheatRelevant
|
|
? (body.antiCheatName ?? existingSupport.antiCheatName)
|
|
: null,
|
|
antiCheatStatus: (body.antiCheatStatus ??
|
|
existingSupport.antiCheatStatus) as AntiCheatStatus,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(gamePlatformSupport.id, existingSupport.id))
|
|
} else {
|
|
await db.insert(gamePlatformSupport).values({
|
|
gameId: game.id,
|
|
hardwareSlug,
|
|
isSupported: true,
|
|
protonStatus: "unknown",
|
|
antiCheatRelevant: body.antiCheatRelevant ?? false,
|
|
antiCheatName: body.antiCheatRelevant ? (body.antiCheatName ?? null) : null,
|
|
antiCheatStatus: (body.antiCheatStatus ?? "unknown") as AntiCheatStatus,
|
|
playabilityStatus: "unknown",
|
|
})
|
|
}
|
|
|
|
// Fire-and-forget playability recalculation
|
|
recalculatePlayability(game.id).catch((err) =>
|
|
console.error("Failed to recalculate playability:", err),
|
|
)
|
|
|
|
set.status = 201
|
|
return {
|
|
id: entry.id,
|
|
gameId: game.id,
|
|
versionId: version.id,
|
|
createdAt: entry.createdAt.toISOString(),
|
|
authMethod: guard.keyId ? "api-key" : "session",
|
|
}
|
|
},
|
|
{
|
|
body: t.Object({
|
|
version: t.Number(),
|
|
steamAppId: t.Number(),
|
|
hardwareSlug: t.String(),
|
|
fpsAvg: t.Number(),
|
|
fpsLow: t.Optional(t.Nullable(t.Number())),
|
|
fpsOnePercentLow: t.Optional(t.Nullable(t.Number())),
|
|
fpsHigh: t.Optional(t.Nullable(t.Number())),
|
|
protonVersion: t.Optional(t.Nullable(t.String())),
|
|
osVersion: t.Optional(t.Nullable(t.String())),
|
|
versionString: t.Optional(t.Nullable(t.String())),
|
|
buildId: t.Optional(t.Nullable(t.String())),
|
|
upscalerType: t.Optional(t.String()),
|
|
upscalerVersion: t.Optional(t.Nullable(t.String())),
|
|
frameGenMethod: t.Optional(t.String()),
|
|
tdpWatts: t.Optional(t.Nullable(t.Number())),
|
|
loadTimeSsd: t.Optional(t.Nullable(t.Number())),
|
|
loadTimeSd: t.Optional(t.Nullable(t.Number())),
|
|
launchOptions: t.Optional(t.Nullable(t.String())),
|
|
settingsJson: t.Optional(t.Nullable(t.Any())),
|
|
userNotes: t.Optional(t.Nullable(t.String())),
|
|
customSystem: t.Optional(t.Boolean()),
|
|
antiCheatRelevant: t.Optional(t.Boolean()),
|
|
antiCheatName: t.Optional(t.Nullable(t.String())),
|
|
antiCheatStatus: t.Optional(t.String()),
|
|
}),
|
|
detail: {
|
|
description:
|
|
"Import a performance benchmark from a DeckyVault plugin export (.deckyvault.json). " +
|
|
"Accepts either session cookies or an x-api-key header for authentication. " +
|
|
"The steamAppId is used to resolve the game and its latest version automatically.",
|
|
},
|
|
},
|
|
) |