feat: update pack handling and character image paths; implement installed packs revision tracking

This commit is contained in:
2026-05-21 23:59:22 +02:00
parent 0bc6f60b2c
commit 618d18d8fb
6 changed files with 110 additions and 11 deletions
@@ -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<CharacterOption[]>([]);
const rightCharacterOptions = ref<CharacterOption[]>([]);
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,
};
}
}
@@ -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) => {
+48 -3
View File
@@ -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<Record<string, { installedVersion:
// ── Filesystem ────────────────────────────────────────────────────────────────
const packsDir = path.join(bundleDir, 'assets', 'packs');
const packsDir = path.join(bundleDir, 'packs');
fs.mkdirSync(packsDir, { recursive: true });
nodecg.log.info(`[pack-manager] Packs directory: ${packsDir}`);
// Registrar el directorio de packs como ruta estática usando nodecg.mount().
// Las imágenes quedan accesibles en /packs/<packId>/characters/<slug>.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<string, string> = {
'.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 */ }
}
+11 -2
View File
@@ -8,7 +8,7 @@
// /assets/<bundleName>/packs/<packId>/characters/<slug>.<ext>
// ─────────────────────────────────────────────────────────────────────────────
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<string, FightingCharacterOption[]> = {};
const installedPackDefaults: Record<string, { leftCharacter: string; rightCharacter: string }> = {};
/**
* 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 = (
+1 -1
View File
@@ -51,4 +51,4 @@ export const getCharacterImageRepoUrl = (packId: string, slug: string, ext: stri
* NodeCG serves everything under assets/ at /assets/<bundleName>/.
*/
export const getInstalledCharacterImageUrl = (packId: string, slug: string, ext = 'png'): string =>
`/assets/${BUNDLE_NAME}/packs/${packId}/characters/${slug}.${ext}`;
`/packs/${packId}/characters/${slug}.${ext}`;