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
+1
View File
@@ -142,3 +142,4 @@ dist
/db/ /db/
*.sqlite3 *.sqlite3
/scoreko-electron-dev/ /scoreko-electron-dev/
/packs/
@@ -13,10 +13,10 @@
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
import { computed, ref, watch, type InjectionKey, type Ref } from 'vue'; 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 { useScoreboardStore } from '../stores/scoreboard';
import { usePackRegistry } from './usePackRegistry'; import { usePackRegistry } from './usePackRegistry';
import type { GameSelectOption, PackRegistryEntry } from '../../../shared/pack-types';
// ── Types ───────────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────────
@@ -77,7 +77,13 @@ export function useCharacterGame() {
// ── Character state ─────────────────────────────────────────────────────── // ── 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 leftCharacterOptions = ref<CharacterOption[]>([]);
const rightCharacterOptions = ref<CharacterOption[]>([]); const rightCharacterOptions = ref<CharacterOption[]>([]);
const leftCharacterInput = ref(''); const leftCharacterInput = ref('');
@@ -150,6 +156,13 @@ export function useCharacterGame() {
} }
const options = getCharactersByGame(newGame); 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; leftCharacterOptions.value = options;
rightCharacterOptions.value = options; rightCharacterOptions.value = options;
const allowed = new Set(options.map((o) => o.value)); const allowed = new Set(options.map((o) => o.value));
@@ -219,6 +232,37 @@ export function useCharacterGame() {
{ immediate: true }, { 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 ────────────────────────────────────────────────────────────────
return { return {
@@ -241,7 +241,7 @@ export function usePackRegistry(): PackRegistryContext {
downloadStates.value[packId] ?? { status: 'idle', progress: 0 }; downloadStates.value[packId] ?? { status: 'idle', progress: 0 };
const getLocalLogoUrl = (packId: string): string => const getLocalLogoUrl = (packId: string): string =>
`/assets/${BUNDLE_NAME}/packs/${packId}/logo.png`; `/packs/${packId}/logo.png`;
const fetchRegistry = (): void => { const fetchRegistry = (): void => {
nodecg.sendMessage('fetchPackRegistry', undefined, (err) => { nodecg.sendMessage('fetchPackRegistry', undefined, (err) => {
+48 -3
View File
@@ -9,6 +9,7 @@
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
import * as fs from 'fs'; import * as fs from 'fs';
import type { IncomingMessage, ServerResponse } from 'http';
import * as path from 'path'; import * as path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { nodecg } from './util/nodecg.js'; 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 // Raíz del proyecto: 2 niveles por encima de extension/pack-manager.js
// Usamos import.meta.url porque nodecg.bundleDir no está disponible cuando // Usamos import.meta.url porque nodecg.bundleDir no está disponible cuando
// NodeCG se usa como dependencia en lugar de servidor standalone. // 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 ──────────────────────────────────────────────────────────────── // ── Replicants ────────────────────────────────────────────────────────────────
@@ -114,8 +115,49 @@ const availableUpdatesRep = nodecg.Replicant<Record<string, { installedVersion:
// ── Filesystem ──────────────────────────────────────────────────────────────── // ── Filesystem ────────────────────────────────────────────────────────────────
const packsDir = path.join(bundleDir, 'assets', 'packs'); const packsDir = path.join(bundleDir, 'packs');
fs.mkdirSync(packsDir, { recursive: true }); 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 // Verificación de integridad al arrancar
const installedAtStart = installedPacksRep.value ?? []; const installedAtStart = installedPacksRep.value ?? [];
@@ -152,7 +194,10 @@ const trySaveImage = async (
for (const ext of extensions) { for (const ext of extensions) {
try { try {
const buffer = await fetchBuffer(buildUrl(ext)); 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; return true;
} catch { /* prueba siguiente extensión */ } } catch { /* prueba siguiente extensión */ }
} }
+11 -2
View File
@@ -8,7 +8,7 @@
// /assets/<bundleName>/packs/<packId>/characters/<slug>.<ext> // /assets/<bundleName>/packs/<packId>/characters/<slug>.<ext>
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
import { BUNDLE_NAME } from './pack-config'; import { ref } from 'vue';
import type { PackManifest } from './pack-types'; import type { PackManifest } from './pack-types';
export interface FightingCharacterOption { export interface FightingCharacterOption {
@@ -243,6 +243,13 @@ export const BUNDLED_GAME_NAMES = new Set(Object.keys(characterNamesByGame));
const installedPackCharacters: Record<string, FightingCharacterOption[]> = {}; const installedPackCharacters: Record<string, FightingCharacterOption[]> = {};
const installedPackDefaults: Record<string, { leftCharacter: string; rightCharacter: string }> = {}; 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 * Registers an installed (downloaded) pack so that getCharactersByGame() and
* getDefaultCharactersByGame() return its data. * getDefaultCharactersByGame() return its data.
@@ -263,7 +270,7 @@ export const registerInstalledPack = (manifest: PackManifest): void => {
label: char.name, label: char.name,
value: char.slug, value: char.slug,
// Images are served at runtime by NodeCG's static asset handler // 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, dlc: char.dlc ?? false,
// Fallback placeholder uses the same palette as the manifest // Fallback placeholder uses the same palette as the manifest
_placeholder: buildInstalledPlaceholder(name, char.name, startColor, endColor), _placeholder: buildInstalledPlaceholder(name, char.name, startColor, endColor),
@@ -275,6 +282,7 @@ export const registerInstalledPack = (manifest: PackManifest): void => {
rightCharacter: defaultPair.right, rightCharacter: defaultPair.right,
}; };
} }
installedPacksRevision.value++;
}; };
/** /**
@@ -284,6 +292,7 @@ export const registerInstalledPack = (manifest: PackManifest): void => {
export const unregisterInstalledPack = (gameName: string): void => { export const unregisterInstalledPack = (gameName: string): void => {
delete installedPackCharacters[gameName]; delete installedPackCharacters[gameName];
delete installedPackDefaults[gameName]; delete installedPackDefaults[gameName];
installedPacksRevision.value++;
}; };
const buildInstalledPlaceholder = ( 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>/. * NodeCG serves everything under assets/ at /assets/<bundleName>/.
*/ */
export const getInstalledCharacterImageUrl = (packId: string, slug: string, ext = 'png'): string => export const getInstalledCharacterImageUrl = (packId: string, slug: string, ext = 'png'): string =>
`/assets/${BUNDLE_NAME}/packs/${packId}/characters/${slug}.${ext}`; `/packs/${packId}/characters/${slug}.${ext}`;