Files
netris-nestri/apps/api/app/routes/game.ts
2026-08-06 22:13:51 +03:00

615 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Actor } from '@nestri/core/actor';
import { ErrorCodes, VisibleError } from '@nestri/core/error';
import { Examples } from '@nestri/core/examples';
import { Depot } from '@nestri/core/game/depot';
import { GameDownload } from '@nestri/core/game/download';
import { GameDownloadStatus } from '@nestri/core/game/download.sql';
import { Game } from '@nestri/core/game/index';
import { Identifier } from '@nestri/core/id';
import { Library } from '@nestri/core/user/library';
import { Hono } from 'hono';
import { describeRoute } from 'hono-openapi';
import { z } from 'zod';
import { ErrorResponses, adminOnly, machineOrAdmin, notPublic, Result, validator } from '../utils';
const SyncGameSchema = z.object({
steamAppId: z.number().int(),
name: z.string(),
type: z.string().optional(),
clientIcon: z.string().optional(),
icon: z.string().optional(),
shortDescription: z.string().optional(),
description: z.string().optional(),
developers: z.array(z.string()).optional(),
publishers: z.array(z.string()).optional(),
primaryGenre: z.string().optional(),
genres: z.array(z.string()).optional(),
categories: z.array(z.string()).optional(),
oslist: z.array(z.string()).optional(),
sizeDownload: z.number().optional(),
sizeOnDisk: z.number().optional(),
controllerSupport: z.string().optional(),
steamDeckCompat: z.string().optional(),
reviewScorePercent: z.number().int().optional(),
reviewCount: z.number().int().optional(),
metacriticScore: z.number().int().optional(),
steamChangeNumber: z.number().int().optional(),
publicBuildId: z.number().int().optional(),
releaseDate: z.string().optional(),
enriched: z.boolean().default(false),
depots: z
.array(
z.object({
depotId: z.number().int(),
branch: z.string().default('public'),
steamManifestId: z.string().optional(),
steamBuildId: z.number().int().optional(),
sizeDownload: z.number().optional(),
sizeOnDisk: z.number().optional(),
oslist: z.string().optional()
})
)
.optional()
});
const SyncLibrarySchema = z.object({
steamAppId: z.number().int(),
playtimeForeverMin: z.number().int().optional(),
playtime2WeeksMin: z.number().int().optional(),
lastPlayed: z.string().optional()
});
export namespace GameApi {
export const route = new Hono()
.use(notPublic)
.get(
'/',
describeRoute({
tags: ['Games'],
summary: 'List games',
description: 'List all games in the catalog, with optional search',
responses: {
200: {
content: {
'application/json': {
schema: Result(
z.array(Game.Info).meta({
description: 'All games matching the optional query',
example: [Examples.Game]
})
)
}
},
description: 'List of games'
},
400: ErrorResponses[400],
401: ErrorResponses[401]
}
}),
validator(
'query',
z.object({
q: z.string().optional().meta({
description: 'Search query to filter games by name',
example: 'Counter-Strike'
})
})
),
async (c) => {
const { q } = c.req.valid('query');
const games = await Game.searchByName(q ?? '');
return c.json({ data: games });
}
)
.get(
'/:id',
describeRoute({
tags: ['Games'],
summary: 'Get a game by ID',
description: 'Retrieve a single game from the catalog',
responses: {
200: {
content: {
'application/json': {
schema: Result(
Game.Info.meta({
description: 'The game',
example: Examples.Game
})
)
}
},
description: 'The game'
},
401: ErrorResponses[401],
404: ErrorResponses[404]
}
}),
validator(
'param',
z.object({
id: z.string().meta({
description: 'ID of the game',
example: Examples.Game.id
})
})
),
async (c) => {
const { id } = c.req.valid('param');
const game = await Game.fromID(id);
if (!game) {
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
`Game ${id} not found`
);
}
return c.json({ data: game });
}
)
.post(
'/sync',
adminOnly,
describeRoute({
tags: ['Games'],
summary: 'Batch sync games, library entries, and depots',
description:
'Bulk upsert games, library entries, and depot info from Steam sync. Admin only.',
responses: {
200: {
content: {
'application/json': {
schema: Result(
z.object({
gamesSynced: z.number(),
libraryEntries: z.number(),
depotEntries: z.number(),
failedEntries: z.array(z.number())
})
)
}
},
description: 'Sync result'
},
400: ErrorResponses[400],
401: ErrorResponses[401],
403: ErrorResponses[403]
}
}),
validator(
'json',
z.object({
userId: z.string(),
games: z.array(SyncGameSchema).default([]),
library: z.array(SyncLibrarySchema).default([])
})
),
async (c) => {
const { userId, games, library } = c.req.valid('json');
const existingGames = await Game.listByAppIDs(games.map((g) => g.steamAppId));
const existingByAppId = new Map(existingGames.map((g) => [g.steamAppId, g]));
const gameIdByAppId = new Map<number, string>();
const failedSteamIDs = new Set<number>();
const gamePromises = [];
// 1. Queue Games
for (const g of games) {
const existing = existingByAppId.get(g.steamAppId);
const gameId = existing?.id ?? Identifier.ascending('game');
gameIdByAppId.set(g.steamAppId, gameId);
const slug =
g.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '') || `app-${g.steamAppId}`;
const now = new Date().toISOString();
const { enriched } = g;
gamePromises.push(
Game.upsert({
id: gameId,
steamAppId: g.steamAppId,
slug,
name: g.name,
type: g.type ?? null,
clientIcon: g.clientIcon ?? null,
icon: g.icon ?? null,
shortDescription: g.shortDescription ?? null,
description: g.description ?? null,
developers: g.developers ?? null,
publishers: g.publishers ?? null,
primaryGenre: g.primaryGenre ?? null,
genres: g.genres ?? null,
categories: g.categories ?? null,
oslist: g.oslist ?? null,
sizeDownload: g.sizeDownload ?? null,
sizeOnDisk: g.sizeOnDisk ?? null,
controllerSupport: g.controllerSupport ?? null,
steamDeckCompat: g.steamDeckCompat ?? null,
reviewScorePercent: g.reviewScorePercent ?? null,
reviewCount: g.reviewCount ?? null,
metacriticScore: g.metacriticScore ?? null,
steamChangeNumber: g.steamChangeNumber ?? null,
publicBuildId: g.publicBuildId ?? null,
releaseDate: g.releaseDate ?? null,
timeEnriched: enriched ? now : (existing?.timeEnriched?.toISOString() ?? null)
})
);
}
const gameResults = await Promise.allSettled(gamePromises);
let gamesSynced = 0;
const depotPromises = [];
const depotSteamIds = [];
const libraryPromises = [];
const librarySteamIds = [];
// 2. Evaluate Games & Queue Dependents
for (let i = 0; i < gameResults.length; i++) {
const g = games[i];
if (gameResults[i].status === 'rejected') {
failedSteamIDs.add(g.steamAppId);
// Drop it from the map so the Library loop below ignores it
gameIdByAppId.delete(g.steamAppId);
continue;
}
gamesSynced++;
if (g.depots) {
const gameId = gameIdByAppId.get(g.steamAppId)!;
for (const d of g.depots) {
const depotId = Identifier.ascending('gameDepot');
depotPromises.push(
Depot.upsert({
id: depotId,
gameId: gameId,
depotId: d.depotId,
branch: d.branch,
steamManifestId: d.steamManifestId ?? null,
steamBuildId: d.steamBuildId ?? null,
sizeDownload: d.sizeDownload ?? null,
sizeOnDisk: d.sizeOnDisk ?? null,
oslist: d.oslist ?? null,
status: 'pending' as const
})
);
depotSteamIds.push(g.steamAppId);
}
}
}
for (const l of library) {
// This naturally filters out entries for games that failed in step 2
const gameId = gameIdByAppId.get(l.steamAppId);
if (!gameId) continue;
const entryId = Identifier.ascending('userLibrary');
libraryPromises.push(
Library.upsert({
id: entryId,
userId,
gameId,
playtime2w: l.playtime2WeeksMin ?? null,
playtimeForever: l.playtimeForeverMin ?? null,
lastPlayed: l.lastPlayed ?? null
})
);
librarySteamIds.push(l.steamAppId);
}
// 3. Execute Dependents in parallel
const [depotResults, libraryResults] = await Promise.all([
Promise.allSettled(depotPromises),
Promise.allSettled(libraryPromises)
]);
let depotEntries = 0;
for (let i = 0; i < depotResults.length; i++) {
if (depotResults[i].status === 'rejected') {
failedSteamIDs.add(depotSteamIds[i]);
} else {
depotEntries++;
}
}
let libraryEntries = 0;
for (let i = 0; i < libraryResults.length; i++) {
if (libraryResults[i].status === 'rejected') {
failedSteamIDs.add(librarySteamIds[i]);
} else {
libraryEntries++;
}
}
return c.json({
data: {
gamesSynced,
libraryEntries,
depotEntries,
failedEntries: Array.from(failedSteamIDs)
}
});
}
)
.get(
'/:id/download-state',
describeRoute({
tags: ['Games'],
summary: 'Get download states for a game',
description:
'Returns the per-host download states for a game. Optionally filter by hostId. Protected read route for initial/fallback data; SSH-connected clients use the live SSH snapshot.',
responses: {
200: {
content: {
'application/json': {
schema: Result(
z.array(GameDownload.Info).meta({
description: 'Download states for the game',
example: [Examples.GameDownload]
})
)
}
},
description: 'Download states'
},
401: ErrorResponses[401],
404: ErrorResponses[404]
}
}),
validator(
'param',
z.object({
id: z.string().meta({
description: 'ID of the game',
example: Examples.Game.id
})
})
),
validator(
'query',
z.object({
hostId: z.string().optional().meta({
description: 'Optional host ID to filter by',
example: Examples.GameDownload.hostId
})
})
),
async (c) => {
const { id } = c.req.valid('param');
const { hostId } = c.req.valid('query');
const game = await Game.fromID(id);
if (!game) {
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
`Game ${id} not found`
);
}
const rows = hostId
? await GameDownload.findByHostAndGame({ hostId, gameId: id }).then((row) =>
row ? [row] : []
)
: await GameDownload.listByGame(id);
const data = rows.map((row) => GameDownload.serialize(row));
return c.json({ data });
}
)
.post(
'/download-state',
machineOrAdmin,
describeRoute({
tags: ['Games'],
summary: 'Report a download state change',
description:
'Update the shared per-host download state for a game. Called by nessh on terminal events (start/verifying/complete/fail). A registered host reports as itself and cannot name another; admin must supply the hostId explicitly.',
responses: {
200: {
content: {
'application/json': {
schema: Result(
z.object({
downloadId: z.string(),
download: GameDownload.Info
})
)
}
},
description: 'Download state updated'
},
400: ErrorResponses[400],
401: ErrorResponses[401],
403: ErrorResponses[403],
404: ErrorResponses[404]
}
}),
validator(
'json',
z.object({
hostId: z.string().optional().meta({
description:
'The nessh host reporting the download. Required for admin callers; ignored for machines, which report as themselves.',
example: Examples.GameDownload.hostId
}),
steamAppId: z.number().int().meta({
description: 'Steam application ID',
example: Examples.Game.steamAppId
}),
status: z.enum(GameDownloadStatus.enumValues).meta({
description: 'New download status',
example: Examples.GameDownload.status
}),
progressBytes: z.number().int().optional().meta({
description: 'Bytes downloaded so far',
example: Examples.GameDownload.progressBytes
}),
totalBytes: z.number().int().optional().meta({
description: 'Total bytes to download',
example: Examples.GameDownload.totalBytes
}),
errorMessage: z.string().nullable().optional().meta({
description: 'Error message if status is failed',
example: null
})
})
),
async (c) => {
const { hostId, steamAppId, status, progressBytes, totalBytes, errorMessage } =
c.req.valid('json');
// A machine reports as itself. Taking the id from the body would
// mean any holder of a shared secret could write download state
// under any box's id, which is the whole reason boxes register.
const actor = Actor.use();
let reportingHostId: string;
if (actor.type === 'machine') {
if (hostId && hostId !== actor.properties.machineID) {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.FORBIDDEN,
'A machine may only report its own download state'
);
}
reportingHostId = actor.properties.machineID;
} else {
if (!hostId) {
throw new VisibleError(
'validation',
ErrorCodes.Validation.MISSING_REQUIRED_FIELD,
'hostId is required when reporting on behalf of a host',
'hostId'
);
}
reportingHostId = hostId;
}
const game = await Game.fromSteamAppID(steamAppId);
if (!game) {
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
`Game with steamAppId ${steamAppId} not found`
);
}
const row = await GameDownload.upsertState({
hostId: reportingHostId,
gameId: game.id,
status,
progressBytes: progressBytes ?? undefined,
totalBytes: totalBytes ?? undefined,
errorMessage: errorMessage ?? undefined
});
return c.json({
data: { downloadId: row.id, download: GameDownload.serialize(row) }
});
}
)
.post(
'/',
adminOnly,
describeRoute({
tags: ['Games'],
summary: 'Create or update a game',
description: 'Upsert a game by Steam app ID. Admin only.',
responses: {
201: {
content: {
'application/json': {
schema: Result(
Game.Info.meta({
description: 'The created or updated game',
example: Examples.Game
})
)
}
},
description: 'Game created or updated'
},
400: ErrorResponses[400],
401: ErrorResponses[401],
403: ErrorResponses[403]
}
}),
validator(
'json',
z.object({
steamAppId: z.number().int().meta({
description: 'Steam application ID',
example: Examples.Game.steamAppId
}),
name: z.string().meta({
description: 'Game title',
example: Examples.Game.name
}),
slug: z.string().optional().meta({
description: 'URL-friendly slug',
example: Examples.Game.slug
}),
type: z.string().nullable().optional().meta({
description: 'Content type',
example: Examples.Game.type
}),
clientIcon: z.string().nullable().optional().meta({
description: 'Steam client icon hash (256×256 square)',
example: Examples.Game.clientIcon
}),
icon: z.string().nullable().optional().meta({
description: 'Steam icon hash (32×32)',
example: Examples.Game.icon
}),
shortDescription: z.string().nullable().optional().meta({
description: 'Short description',
example: Examples.Game.shortDescription
}),
description: z.string().nullable().optional().meta({
description: 'Full description',
example: Examples.Game.description
}),
developers: z.array(z.string()).nullable().optional().meta({
description: 'Game developers',
example: Examples.Game.developers
}),
publishers: z.array(z.string()).nullable().optional().meta({
description: 'Game publishers',
example: Examples.Game.publishers
}),
genres: z.array(z.string()).nullable().optional().meta({
description: 'Game genres',
example: Examples.Game.genres
}),
oslist: z.array(z.string()).nullable().optional().meta({
description: 'Supported OS list',
example: Examples.Game.oslist
}),
releaseDate: z.string().nullable().optional().meta({
description: 'Release date ISO string',
example: Examples.Game.releaseDate
})
})
),
async (c) => {
const body = c.req.valid('json');
const id = Identifier.ascending('game');
const slug =
body.slug ??
body.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
const game = await Game.upsert({ ...body, id, slug });
return c.json({ data: game[0] }, 201);
}
);
}