import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { nodecg } from './util/nodecg.js'; const CHARACTER_NAMES_FILE = 'fighting-characters.json'; const LOCAL_MANIFEST_FILE = 'manifest.json'; const gameCatalog = [ { title: 'Street Fighter 6', slug: 'street-fighter-6', repoFolder: 'street-fighter-6' }, { title: 'TEKKEN 8', slug: 'tekken-8', repoFolder: 'tekken-8' }, { title: '2XKO', slug: '2xko', repoFolder: '2xko' }, { title: 'Guilty Gear -Strive-', slug: 'guilty-gear-strive', repoFolder: 'guilty-gear-strive' }, { title: 'Mortal Kombat 1', slug: 'mortal-kombat-1', repoFolder: 'mortal-kombat-1' }, { title: 'THE KING OF FIGHTERS XV', slug: 'the-king-of-fighters-xv', repoFolder: 'the-king-of-fighters-xv' }, ] as const; type AssetFileEntry = { path: string; size: number; downloadUrl: string; }; type HttpManifestEntry = string | { path?: unknown; size?: unknown; url?: unknown; }; const extensionDir = path.dirname(fileURLToPath(import.meta.url)); const bundleRoot = path.resolve(extensionDir, '..'); const assetsRoot = path.join(bundleRoot, 'game-assets'); const assetsRouter = nodecg.Router(); assetsRouter.get('/*', async (req, res) => { const wildcardParam = (req.params as Record)['0'] ?? (req.params as Record)['']; const requestedPath = Array.isArray(wildcardParam) ? String(wildcardParam[0] ?? '') : typeof wildcardParam === 'string' ? wildcardParam : ''; const normalizedPath = path.normalize(requestedPath).replace(/^(\.\.(?:[\\/]|$))+/, ''); const filePath = path.resolve(assetsRoot, normalizedPath); if (!filePath.startsWith(assetsRoot)) { res.status(400).send('Invalid asset path.'); return; } try { const fileStats = await stat(filePath); if (!fileStats.isFile()) { res.status(404).send('Asset not found.'); return; } res.type(path.extname(filePath)); res.send(await readFile(filePath)); } catch { res.status(404).send('Asset not found.'); } }); nodecg.mount(`/bundles/${nodecg.bundleName}/game-assets`, assetsRouter); const requestHeaders = { 'User-Agent': 'scoreko-dev-nodecg-bundle', }; const getConfiguredAssetsBaseUrl = () => { const configuredValue = nodecg.bundleConfig.assetsBaseUrl; if (typeof configuredValue !== 'string') { return 'http://localhost'; } const trimmed = configuredValue.trim(); if (!trimmed) { return 'http://localhost'; } return trimmed.replace(/\/+$/, ''); }; const emitProgress = (title: string, progress: number, status: 'downloading' | 'completed' | 'error') => { nodecg.sendMessage('scoreko-assets:downloadProgress', { title, progress: Math.max(0, Math.min(100, progress)), status, }); }; const fetchJson = async (url: string): Promise => { const response = await fetch(url, { headers: requestHeaders }); if (!response.ok) { throw new Error(`Error HTTP (${response.status}) al solicitar ${url}`); } return response.json() as Promise; }; const normalizeManifestEntry = (entry: HttpManifestEntry, gameTitle: string) => { if (typeof entry === 'string') { return { path: entry, size: 0, explicitUrl: null, }; } if (typeof entry === 'object' && entry !== null && typeof entry.path === 'string') { return { path: entry.path, size: typeof entry.size === 'number' ? entry.size : 0, explicitUrl: typeof entry.url === 'string' ? entry.url : null, }; } throw new Error(`El ${LOCAL_MANIFEST_FILE} de ${gameTitle} contiene entradas inválidas.`); }; const listHttpFiles = async (game: (typeof gameCatalog)[number]): Promise => { const baseUrl = getConfiguredAssetsBaseUrl(); const manifestUrl = `${baseUrl}/games/${game.repoFolder}/${LOCAL_MANIFEST_FILE}`; const entries = await fetchJson(manifestUrl); if (!Array.isArray(entries) || entries.length === 0) { throw new Error(`No se encontraron archivos en ${manifestUrl}.`); } return entries.map((rawEntry) => { const normalized = normalizeManifestEntry(rawEntry, game.title); const cleanPath = normalized.path.replace(/^\/+/, ''); return { path: `games/${game.repoFolder}/${cleanPath}`, size: Math.max(0, normalized.size), downloadUrl: normalized.explicitUrl ?? `${baseUrl}/games/${game.repoFolder}/${cleanPath}`, }; }); }; const listInstalledGames = async () => { const entries = await readdir(assetsRoot, { withFileTypes: true }).catch(() => []); const installedSlugs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name); return gameCatalog.filter((game) => installedSlugs.includes(game.slug)).map((game) => game.title); }; const parseCharacterNames = (content: string, gameTitle: string) => { const parsed = JSON.parse(content) as unknown; const names = Array.isArray(parsed) ? parsed : typeof parsed === 'object' && parsed !== null && Array.isArray((parsed as { characters?: unknown }).characters) ? (parsed as { characters: unknown[] }).characters : null; if (!names || names.some((name) => typeof name !== 'string')) { throw new Error(`El archivo ${CHARACTER_NAMES_FILE} de ${gameTitle} no tiene un formato válido.`); } return names; }; const listInstalledCharacterNamesByGame = async () => { const charactersByGame = await Promise.all(gameCatalog.map(async (game) => { const sourcePath = path.join(assetsRoot, game.slug, CHARACTER_NAMES_FILE); try { const fileContent = await readFile(sourcePath, 'utf8'); const names = parseCharacterNames(fileContent, game.title); return [game.title, names] as const; } catch { return [game.title, []] as const; } })); return Object.fromEntries(charactersByGame) as Record; }; const downloadGameAssets = async (gameTitle: string) => { const game = gameCatalog.find((entry) => entry.title === gameTitle); if (!game) { throw new Error('Juego no soportado en el catálogo.'); } emitProgress(game.title, 0, 'downloading'); const files = await listHttpFiles(game); if (!files.length) { throw new Error(`No se encontraron archivos para ${game.title}.`); } const hasCharacterNamesFile = files.some((file) => file.path.endsWith(`/${CHARACTER_NAMES_FILE}`)); if (!hasCharacterNamesFile) { throw new Error(`No se encontró ${CHARACTER_NAMES_FILE} para ${game.title}.`); } const totalBytes = files.reduce((acc, file) => acc + (file.size || 0), 0); let downloadedBytes = 0; const destinationFolder = path.join(assetsRoot, game.slug); await rm(destinationFolder, { recursive: true, force: true }); for (const file of files) { const relativePath = file.path.replace(`games/${game.repoFolder}/`, ''); const targetPath = path.join(destinationFolder, relativePath); await mkdir(path.dirname(targetPath), { recursive: true }); const response = await fetch(file.downloadUrl, { headers: requestHeaders }); if (!response.ok) { throw new Error(`No se pudo descargar ${file.path} (status ${response.status}).`); } const arrayBuffer = await response.arrayBuffer(); await writeFile(targetPath, Buffer.from(arrayBuffer)); downloadedBytes += file.size || 0; const progress = totalBytes > 0 ? Math.round((downloadedBytes / totalBytes) * 100) : 100; emitProgress(game.title, progress, 'downloading'); } emitProgress(game.title, 100, 'completed'); return { title: game.title, slug: game.slug, }; }; const removeGameAssets = async (gameTitle: string) => { const game = gameCatalog.find((entry) => entry.title === gameTitle); if (!game) { throw new Error('Juego no soportado en el catálogo.'); } const destinationFolder = path.join(assetsRoot, game.slug); await rm(destinationFolder, { recursive: true, force: true }); return { title: game.title, slug: game.slug, }; }; nodecg.listenFor('scoreko-assets:listInstalled', async (_payload: unknown, ack) => { if (typeof ack !== 'function') { return; } try { ack(null, await listInstalledGames()); } catch (error) { ack((error as Error).message); } }); nodecg.listenFor('scoreko-assets:listCharactersByGame', async (_payload: unknown, ack) => { if (typeof ack !== 'function') { return; } try { ack(null, await listInstalledCharacterNamesByGame()); } catch (error) { ack((error as Error).message); } }); nodecg.listenFor('scoreko-assets:getAssetsBaseUrl', async (_payload: unknown, ack) => { if (typeof ack !== 'function') { return; } try { ack(null, { assetsBaseUrl: getConfiguredAssetsBaseUrl() }); } catch (error) { ack((error as Error).message); } }); nodecg.listenFor('scoreko-assets:downloadGame', async (payload: unknown, ack) => { if (typeof ack !== 'function') { return; } try { const title = typeof payload === 'object' && payload !== null ? (payload as { title?: unknown }).title : undefined; if (typeof title !== 'string') { throw new Error('Título de juego inválido.'); } const downloaded = await downloadGameAssets(title); ack(null, { downloaded, installedGames: await listInstalledGames(), }); } catch (error) { if (typeof payload === 'object' && payload !== null && typeof (payload as { title?: unknown }).title === 'string') { emitProgress((payload as { title: string }).title, 0, 'error'); } ack((error as Error).message); } }); nodecg.listenFor('scoreko-assets:removeGame', async (payload: unknown, ack) => { if (typeof ack !== 'function') { return; } try { const title = typeof payload === 'object' && payload !== null ? (payload as { title?: unknown }).title : undefined; if (typeof title !== 'string') { throw new Error('Título de juego inválido.'); } const removed = await removeGameAssets(title); ack(null, { removed, installedGames: await listInstalledGames(), }); } catch (error) { ack((error as Error).message); } });