mirror of
https://github.com/Pandipipas/scoreko-dev.git
synced 2026-06-05 19:22:07 +00:00
feat: update pack handling and character image paths; implement installed packs revision tracking
This commit is contained in:
@@ -142,3 +142,4 @@ dist
|
||||
/db/
|
||||
*.sqlite3
|
||||
/scoreko-electron-dev/
|
||||
/packs/
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
Reference in New Issue
Block a user