diff --git a/.gitignore b/.gitignore index d6bd5d2..206cd56 100644 --- a/.gitignore +++ b/.gitignore @@ -142,3 +142,4 @@ dist /db/ *.sqlite3 /scoreko-electron-dev/ +/packs/ \ No newline at end of file diff --git a/src/dashboard/scoreko-dev/composables/useCharacterGame.ts b/src/dashboard/scoreko-dev/composables/useCharacterGame.ts index b1221ca..ce7bb2f 100644 --- a/src/dashboard/scoreko-dev/composables/useCharacterGame.ts +++ b/src/dashboard/scoreko-dev/composables/useCharacterGame.ts @@ -13,10 +13,10 @@ // ───────────────────────────────────────────────────────────────────────────── import { computed, ref, watch, type InjectionKey, type Ref } from 'vue'; -import { getCharactersByGame, getDefaultCharactersByGame } from '../../../shared/fighting-characters'; +import { getCharactersByGame, getDefaultCharactersByGame, installedPacksRevision } from '../../../shared/fighting-characters'; +import type { GameSelectOption, PackRegistryEntry } from '../../../shared/pack-types'; import { useScoreboardStore } from '../stores/scoreboard'; import { usePackRegistry } from './usePackRegistry'; -import type { GameSelectOption, PackRegistryEntry } from '../../../shared/pack-types'; // ── Types ───────────────────────────────────────────────────────────────────── @@ -77,7 +77,13 @@ export function useCharacterGame() { // ── Character state ─────────────────────────────────────────────────────── - const characterOptions = computed(() => getCharactersByGame(scoreboardStore.scoreboard.game)); + const characterOptions = computed(() => { + // Subscribing to installedPacksRevision forces Vue to re-evaluate this + // computed whenever a pack is registered/unregistered at runtime, even + // though scoreboardStore.scoreboard.game itself hasn't changed. + void installedPacksRevision.value; + return getCharactersByGame(scoreboardStore.scoreboard.game); + }); const leftCharacterOptions = ref([]); const rightCharacterOptions = ref([]); const leftCharacterInput = ref(''); @@ -150,6 +156,13 @@ export function useCharacterGame() { } const options = getCharactersByGame(newGame); + + // If the game is set but has no options yet, the pack is still loading + // (installed pack whose registerInstalledPack() hasn't run yet). + // Bail out — the installedPacksRevision watcher below will restore state + // once the pack becomes available. + if (newGame && options.length === 0) return; + leftCharacterOptions.value = options; rightCharacterOptions.value = options; const allowed = new Set(options.map((o) => o.value)); @@ -219,6 +232,37 @@ export function useCharacterGame() { { immediate: true }, ); + // When an installed pack becomes available (e.g. after page refresh while + // the pack loads asynchronously), re-validate and restore the characters + // that are already in the store but couldn't be confirmed before. + watch(installedPacksRevision, () => { + const game = scoreboardStore.scoreboard.game; + if (!game) return; + + const options = getCharactersByGame(game); + if (options.length === 0) return; + + const allowed = new Set(options.map((o) => o.value)); + leftCharacterOptions.value = options; + rightCharacterOptions.value = options; + + const { leftCharacter, rightCharacter } = scoreboardStore.scoreboard; + + if (leftCharacter && allowed.has(leftCharacter)) { + leftCharacterInput.value = options.find((o) => o.value === leftCharacter)?.label ?? ''; + } else if (leftCharacter && !allowed.has(leftCharacter)) { + scoreboardStore.scoreboard.leftCharacter = ''; + leftCharacterInput.value = ''; + } + + if (rightCharacter && allowed.has(rightCharacter)) { + rightCharacterInput.value = options.find((o) => o.value === rightCharacter)?.label ?? ''; + } else if (rightCharacter && !allowed.has(rightCharacter)) { + scoreboardStore.scoreboard.rightCharacter = ''; + rightCharacterInput.value = ''; + } + }); + // ── Return ──────────────────────────────────────────────────────────────── return { @@ -240,4 +284,4 @@ export function useCharacterGame() { onLeftCharacterFilter, onRightCharacterFilter, }; -} +} \ No newline at end of file diff --git a/src/dashboard/scoreko-dev/composables/usePackRegistry.ts b/src/dashboard/scoreko-dev/composables/usePackRegistry.ts index c03f188..6699757 100644 --- a/src/dashboard/scoreko-dev/composables/usePackRegistry.ts +++ b/src/dashboard/scoreko-dev/composables/usePackRegistry.ts @@ -241,7 +241,7 @@ export function usePackRegistry(): PackRegistryContext { downloadStates.value[packId] ?? { status: 'idle', progress: 0 }; const getLocalLogoUrl = (packId: string): string => - `/assets/${BUNDLE_NAME}/packs/${packId}/logo.png`; + `/packs/${packId}/logo.png`; const fetchRegistry = (): void => { nodecg.sendMessage('fetchPackRegistry', undefined, (err) => { diff --git a/src/extension/pack-manager.ts b/src/extension/pack-manager.ts index e22672c..9e115aa 100644 --- a/src/extension/pack-manager.ts +++ b/src/extension/pack-manager.ts @@ -9,6 +9,7 @@ // ───────────────────────────────────────────────────────────────────────────── import * as fs from 'fs'; +import type { IncomingMessage, ServerResponse } from 'http'; import * as path from 'path'; import { fileURLToPath } from 'url'; import { nodecg } from './util/nodecg.js'; @@ -87,7 +88,7 @@ const IMAGE_EXTENSIONS = ['png', 'webp', 'jpg', 'jpeg', 'avif'] as const; // Raíz del proyecto: 2 niveles por encima de extension/pack-manager.js // Usamos import.meta.url porque nodecg.bundleDir no está disponible cuando // NodeCG se usa como dependencia en lugar de servidor standalone. -const bundleDir = fileURLToPath(new URL('../../', import.meta.url)); +const bundleDir = fileURLToPath(new URL('../', import.meta.url)); // ── Replicants ──────────────────────────────────────────────────────────────── @@ -114,8 +115,49 @@ const availableUpdatesRep = nodecg.Replicant/characters/.png +// independientemente de cómo NodeCG configure el resto de rutas del bundle. +const packsMiddleware = (req: IncomingMessage, res: ServerResponse) => { + const urlPath = decodeURIComponent(req.url ?? '/'); + const safe = path.normalize(urlPath).replace(/^(\.\.[/\\])+/, ''); + const file = path.join(packsDir, safe); + + // Security: only serve files inside packsDir + if (!file.startsWith(packsDir)) { + res.writeHead(403); + res.end(); + return; + } + + fs.stat(file, (statErr, stat) => { + if (statErr || !stat.isFile()) { + res.writeHead(404); + res.end(); + return; + } + const mimeTypes: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.webp': 'image/webp', + '.avif': 'image/avif', + '.json': 'application/json', + }; + const ext = path.extname(file).toLowerCase(); + res.setHeader('Content-Type', mimeTypes[ext] ?? 'application/octet-stream'); + res.setHeader('Cache-Control', 'public, max-age=3600'); + fs.createReadStream(file).pipe(res); + }); +}; + +// nodecg.mount registra el middleware en el servidor Express de NodeCG +(nodecg as unknown as { mount: (p: string, h: typeof packsMiddleware) => void }) + .mount('/packs', packsMiddleware); // Verificación de integridad al arrancar const installedAtStart = installedPacksRep.value ?? []; @@ -152,7 +194,10 @@ const trySaveImage = async ( for (const ext of extensions) { try { const buffer = await fetchBuffer(buildUrl(ext)); - fs.writeFileSync(path.join(destDir, `${filename}.${ext}`), buffer); + // Siempre guardamos como .png para que la URL del dashboard sea predecible. + // Los navegadores modernos identifican el formato por el contenido (magic bytes), + // no por la extensión, así que WebP/AVIF/JPEG se renderizan correctamente. + fs.writeFileSync(path.join(destDir, `${filename}.png`), buffer); return true; } catch { /* prueba siguiente extensión */ } } diff --git a/src/shared/fighting-characters.ts b/src/shared/fighting-characters.ts index 178e408..ede4488 100644 --- a/src/shared/fighting-characters.ts +++ b/src/shared/fighting-characters.ts @@ -8,7 +8,7 @@ // /assets//packs//characters/. // ───────────────────────────────────────────────────────────────────────────── -import { BUNDLE_NAME } from './pack-config'; +import { ref } from 'vue'; import type { PackManifest } from './pack-types'; export interface FightingCharacterOption { @@ -243,6 +243,13 @@ export const BUNDLED_GAME_NAMES = new Set(Object.keys(characterNamesByGame)); const installedPackCharacters: Record = {}; const installedPackDefaults: Record = {}; +/** + * Incremented every time a pack is registered or unregistered. + * Composables subscribe to this ref so Vue re-evaluates computed values + * that depend on installedPackCharacters (which is a plain object, not reactive). + */ +export const installedPacksRevision = ref(0); + /** * Registers an installed (downloaded) pack so that getCharactersByGame() and * getDefaultCharactersByGame() return its data. @@ -263,7 +270,7 @@ export const registerInstalledPack = (manifest: PackManifest): void => { label: char.name, value: char.slug, // Images are served at runtime by NodeCG's static asset handler - image: `/assets/${BUNDLE_NAME}/packs/${id}/characters/${char.slug}.png`, + image: `/packs/${id}/characters/${char.slug}.png`, dlc: char.dlc ?? false, // Fallback placeholder uses the same palette as the manifest _placeholder: buildInstalledPlaceholder(name, char.name, startColor, endColor), @@ -275,6 +282,7 @@ export const registerInstalledPack = (manifest: PackManifest): void => { rightCharacter: defaultPair.right, }; } + installedPacksRevision.value++; }; /** @@ -284,6 +292,7 @@ export const registerInstalledPack = (manifest: PackManifest): void => { export const unregisterInstalledPack = (gameName: string): void => { delete installedPackCharacters[gameName]; delete installedPackDefaults[gameName]; + installedPacksRevision.value++; }; const buildInstalledPlaceholder = ( diff --git a/src/shared/pack-config.ts b/src/shared/pack-config.ts index 2f2cea6..07c4dbb 100644 --- a/src/shared/pack-config.ts +++ b/src/shared/pack-config.ts @@ -51,4 +51,4 @@ export const getCharacterImageRepoUrl = (packId: string, slug: string, ext: stri * NodeCG serves everything under assets/ at /assets//. */ export const getInstalledCharacterImageUrl = (packId: string, slug: string, ext = 'png'): string => - `/assets/${BUNDLE_NAME}/packs/${packId}/characters/${slug}.${ext}`; + `/packs/${packId}/characters/${slug}.${ext}`;