feat: update character images for Tekken 8 and enhance pack management

- Updated character images for Tekken 8, including Jin, Jun, Kazuya, and others.
- Introduced a new pack configuration system to manage character packs from a Gitea instance.
- Added types for pack management, including PackCharacter, PackManifest, and PackRegistry.
- Implemented functions to register and unregister installed packs, allowing dynamic character loading.
- Enhanced the character image retrieval system to support both bundled and installed packs.
This commit is contained in:
2026-05-21 17:59:13 +02:00
parent 04f2c2037a
commit 88aeedb5ff
50 changed files with 1621 additions and 409 deletions
+1
View File
@@ -11,4 +11,5 @@ export default async (nodecg: NodeCGServerAPI) => {
await import('./example.js');
await import('./startgg.js');
await import('./challonge.js');
await import('./pack-manager.js');
};
+394
View File
@@ -0,0 +1,394 @@
// src/extension/pack-manager.ts
// ─────────────────────────────────────────────────────────────────────────────
// Módulo autocontenido: no importa nada de src/shared/ para respetar el
// rootDir del tsconfig de la extensión. Las constantes de Gitea y los tipos
// necesarios están definidos aquí directamente.
//
// Para activarlo, añade UNA línea en src/extension/index.ts:
// await import('./pack-manager.js');
// ─────────────────────────────────────────────────────────────────────────────
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { nodecg } from './util/nodecg.js';
// ── Configuración de Gitea ────────────────────────────────────────────────────
// Edita estas constantes para apuntar a tu instancia.
const GITEA_BASE_URL = 'http://localhost:3000';
const GITEA_OWNER = 'admin';
const GITEA_REPO = 'fighting-game-packs';
const GITEA_BRANCH = 'main';
const rawUrl = (repoPath: string) =>
`${GITEA_BASE_URL}/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${repoPath}`;
const REGISTRY_URL = rawUrl('registry.json');
const getManifestUrl = (id: string) => rawUrl(`${id}/manifest.json`);
const getPackLogoUrl = (id: string) => rawUrl(`${id}/logo.png`);
const getCharacterImageRepoUrl = (id: string, slug: string, ext: string) =>
rawUrl(`${id}/characters/${slug}.${ext}`);
// ── Tipos locales ─────────────────────────────────────────────────────────────
interface PackCharacter {
name: string;
slug: string;
dlc?: boolean;
sizeBytes: number;
}
interface PackManifest {
id: string;
name: string;
version: string;
palette: { start: string; end: string };
defaultPair?: { left: string; right: string };
characters: PackCharacter[];
}
interface PackRegistry {
schemaVersion: number;
updatedAt: string;
packs: Array<{
id: string;
name: string;
version: string;
totalSizeBytes: number;
logoPath: string;
characterCount: number;
palette: { start: string; end: string };
bundled: boolean;
}>;
}
interface PackDownloadState {
status: 'idle' | 'fetching-manifest' | 'downloading' | 'done' | 'error';
progress: number;
error?: string;
}
// Replicamos la forma exacta del tipo Acknowledgement de NodeCG sin necesidad
// de importar @nodecg/types. HandledAcknowledgement NO es callable (es un objeto),
// UnhandledAcknowledgement SÍ lo es. El helper reply() comprueba cuál es antes de llamar.
type HandledAcknowledgement = { handled: true };
type UnhandledAcknowledgement = ((error?: Error | null, ...args: unknown[]) => void) & { handled: false };
type Acknowledgement = HandledAcknowledgement | UnhandledAcknowledgement;
const reply = (ack: Acknowledgement | undefined, err: Error | null, result?: unknown): void => {
if (ack && !ack.handled) ack(err ?? undefined, result);
};
// ── Constantes ────────────────────────────────────────────────────────────────
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));
// ── Replicants ────────────────────────────────────────────────────────────────
const installedPacksRep = nodecg.Replicant<string[]>('installedPacks', {
defaultValue: [],
persistent: true,
});
const packRegistryRep = nodecg.Replicant<PackRegistry | null>('packRegistry', {
defaultValue: null,
persistent: true,
});
const downloadStatesRep = nodecg.Replicant<Record<string, PackDownloadState>>('downloadStates', {
defaultValue: {},
persistent: false,
});
/** Packs instalados para los que hay una versión más nueva en el registro. */
const availableUpdatesRep = nodecg.Replicant<Record<string, { installedVersion: string; latestVersion: string }>>('availableUpdates', {
defaultValue: {},
persistent: false,
});
// ── Filesystem ────────────────────────────────────────────────────────────────
const packsDir = path.join(bundleDir, 'assets', 'packs');
fs.mkdirSync(packsDir, { recursive: true });
// Verificación de integridad al arrancar
const installedAtStart = installedPacksRep.value ?? [];
const verified = installedAtStart.filter((id) =>
fs.existsSync(path.join(packsDir, id, 'manifest.json')),
);
if (verified.length !== installedAtStart.length) {
nodecg.log.warn('[pack-manager] Algunos packs instalados no estaban en disco y se han eliminado del registro.');
installedPacksRep.value = verified;
}
// ── Helpers internos ──────────────────────────────────────────────────────────
const setDownloadState = (packId: string, patch: Partial<PackDownloadState>): void => {
const current = downloadStatesRep.value?.[packId] ?? { status: 'idle', progress: 0 };
downloadStatesRep.value = {
...downloadStatesRep.value,
[packId]: { ...current, ...patch },
};
};
const fetchBuffer = async (url: string): Promise<Buffer> => {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}${url}`);
return Buffer.from(await response.arrayBuffer());
};
const trySaveImage = async (
destDir: string,
filename: string,
extensions: readonly string[],
buildUrl: (ext: string) => string,
): Promise<boolean> => {
for (const ext of extensions) {
try {
const buffer = await fetchBuffer(buildUrl(ext));
fs.writeFileSync(path.join(destDir, `${filename}.${ext}`), buffer);
return true;
} catch { /* prueba siguiente extensión */ }
}
return false;
};
// ── Detección de actualizaciones ─────────────────────────────────────────────
// Compara la versión en el manifest.json local de cada pack instalado contra
// la versión en el registro de Gitea. Solo aplica a packs descargados (no bundled).
const checkForUpdates = (): void => {
const registry = packRegistryRep.value;
const installed = installedPacksRep.value ?? [];
if (!registry || installed.length === 0) {
availableUpdatesRep.value = {};
return;
}
const updates: Record<string, { installedVersion: string; latestVersion: string }> = {};
for (const packId of installed) {
const registryEntry = registry.packs.find((p) => p.id === packId);
if (!registryEntry) continue;
const manifestPath = path.join(packsDir, packId, 'manifest.json');
try {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as PackManifest;
if (manifest.version !== registryEntry.version) {
updates[packId] = {
installedVersion: manifest.version,
latestVersion: registryEntry.version,
};
nodecg.log.info(
`[pack-manager] Actualización disponible para "${packId}": ${manifest.version}${registryEntry.version}`,
);
}
} catch {
// Manifest ilegible — ignorar este pack
}
}
availableUpdatesRep.value = updates;
};
// Comprobar al arrancar si ya hay un registro cacheado
checkForUpdates();
// ── Mensaje: fetchPackRegistry ────────────────────────────────────────────────
nodecg.listenFor('fetchPackRegistry', async (_data: unknown, ack: Acknowledgement | undefined) => {
try {
const response = await fetch(REGISTRY_URL);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const registry = await response.json() as PackRegistry;
packRegistryRep.value = registry;
checkForUpdates(); // re-evaluar actualizaciones con el registro nuevo
reply(ack, null, registry);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
nodecg.log.error(`[pack-manager] Error al obtener el registro: ${message}`);
reply(ack, new Error(message));
}
});
// ── Mensaje: downloadPack ─────────────────────────────────────────────────────
nodecg.listenFor('downloadPack', async (packId: unknown, ack: Acknowledgement | undefined) => {
if (typeof packId !== 'string' || !packId) {
return reply(ack, new Error('downloadPack requiere un packId no vacío.'));
}
if (installedPacksRep.value?.includes(packId)) {
return reply(ack, null, { alreadyInstalled: true });
}
if (downloadStatesRep.value?.[packId]?.status === 'downloading') {
return reply(ack, new Error(`El pack "${packId}" ya se está descargando.`));
}
setDownloadState(packId, { status: 'fetching-manifest', progress: 0, error: undefined });
try {
const manifestRes = await fetch(getManifestUrl(packId));
if (!manifestRes.ok) throw new Error(`No se puede obtener el manifest: HTTP ${manifestRes.status}`);
const manifest = await manifestRes.json() as PackManifest;
const packDir = path.join(packsDir, packId);
const charsDir = path.join(packDir, 'characters');
fs.mkdirSync(charsDir, { recursive: true });
fs.writeFileSync(path.join(packDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
setDownloadState(packId, { status: 'downloading', progress: 2 });
try {
const logoBuffer = await fetchBuffer(getPackLogoUrl(packId));
fs.writeFileSync(path.join(packDir, 'logo.png'), logoBuffer);
} catch {
nodecg.log.warn(`[pack-manager] No se encontró logo para "${packId}" — se omite.`);
}
const total = manifest.characters.length;
for (let i = 0; i < total; i++) {
const char = manifest.characters[i]!;
const saved = await trySaveImage(
charsDir,
char.slug,
IMAGE_EXTENSIONS,
(ext) => getCharacterImageRepoUrl(packId, char.slug, ext),
);
if (!saved) {
nodecg.log.warn(`[pack-manager] Sin imagen para "${packId}/${char.slug}" — se usará placeholder.`);
}
setDownloadState(packId, { progress: 5 + Math.round(((i + 1) / total) * 93) });
}
const current = installedPacksRep.value ?? [];
if (!current.includes(packId)) installedPacksRep.value = [...current, packId];
setDownloadState(packId, { status: 'done', progress: 100 });
reply(ack, null, { packId, characterCount: manifest.characters.length });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
nodecg.log.error(`[pack-manager] Error al descargar "${packId}": ${message}`);
setDownloadState(packId, { status: 'error', error: message });
reply(ack, new Error(message));
}
});
// ── Mensaje: uninstallPack ────────────────────────────────────────────────────
nodecg.listenFor('uninstallPack', (packId: unknown, ack: Acknowledgement | undefined) => {
if (typeof packId !== 'string' || !packId) {
return reply(ack, new Error('uninstallPack requiere un packId no vacío.'));
}
try {
fs.rmSync(path.join(packsDir, packId), { recursive: true, force: true });
installedPacksRep.value = (installedPacksRep.value ?? []).filter((id) => id !== packId);
const states = { ...downloadStatesRep.value };
delete states[packId];
downloadStatesRep.value = states;
const updates = { ...availableUpdatesRep.value };
delete updates[packId];
availableUpdatesRep.value = updates;
reply(ack, null);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
nodecg.log.error(`[pack-manager] Error al desinstalar "${packId}": ${message}`);
reply(ack, new Error(message));
}
});
// ── Mensaje: updatePack ──────────────────────────────────────────────────────
// Dashboard → Extension: "Actualiza el pack <packId> a la última versión."
// Borra las imágenes antiguas y descarga las nuevas desde Gitea.
nodecg.listenFor('updatePack', async (packId: unknown, ack: Acknowledgement | undefined) => {
if (typeof packId !== 'string' || !packId) {
return reply(ack, new Error('updatePack requiere un packId no vacío.'));
}
if (!installedPacksRep.value?.includes(packId)) {
return reply(ack, new Error(`El pack "${packId}" no está instalado. Usa downloadPack primero.`));
}
if (downloadStatesRep.value?.[packId]?.status === 'downloading') {
return reply(ack, new Error(`El pack "${packId}" ya se está actualizando.`));
}
setDownloadState(packId, { status: 'fetching-manifest', progress: 0, error: undefined });
try {
// 1. Obtener nuevo manifest
const manifestRes = await fetch(getManifestUrl(packId));
if (!manifestRes.ok) throw new Error(`No se puede obtener el manifest: HTTP ${manifestRes.status}`);
const manifest = await manifestRes.json() as PackManifest;
const packDir = path.join(packsDir, packId);
const charsDir = path.join(packDir, 'characters');
// 2. Limpiar imágenes antiguas (evita residuos de personajes renombrados/eliminados)
if (fs.existsSync(charsDir)) {
fs.rmSync(charsDir, { recursive: true, force: true });
}
fs.mkdirSync(charsDir, { recursive: true });
// 3. Guardar nuevo manifest en disco
fs.writeFileSync(path.join(packDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
// 4. Logo
setDownloadState(packId, { status: 'downloading', progress: 2 });
try {
const logoBuffer = await fetchBuffer(getPackLogoUrl(packId));
fs.writeFileSync(path.join(packDir, 'logo.png'), logoBuffer);
} catch {
nodecg.log.warn(`[pack-manager] No se encontró logo para "${packId}" — se omite.`);
}
// 5. Imágenes de personajes
const total = manifest.characters.length;
for (let i = 0; i < total; i++) {
const char = manifest.characters[i]!;
const saved = await trySaveImage(
charsDir,
char.slug,
IMAGE_EXTENSIONS,
(ext) => getCharacterImageRepoUrl(packId, char.slug, ext),
);
if (!saved) {
nodecg.log.warn(`[pack-manager] Sin imagen para "${packId}/${char.slug}" — se usará placeholder.`);
}
setDownloadState(packId, { progress: 5 + Math.round(((i + 1) / total) * 93) });
}
// 6. Quitar de availableUpdates
const updates = { ...availableUpdatesRep.value };
delete updates[packId];
availableUpdatesRep.value = updates;
setDownloadState(packId, { status: 'done', progress: 100 });
nodecg.log.info(`[pack-manager] Pack "${packId}" actualizado a v${manifest.version}.`);
reply(ack, null, { packId, version: manifest.version });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
nodecg.log.error(`[pack-manager] Error al actualizar "${packId}": ${message}`);
setDownloadState(packId, { status: 'error', error: message });
reply(ack, new Error(message));
}
});
// ── Mensaje: readLocalManifest ────────────────────────────────────────────────
nodecg.listenFor('readLocalManifest', (packId: unknown, ack: Acknowledgement | undefined) => {
if (typeof packId !== 'string' || !packId) {
return reply(ack, new Error('readLocalManifest requiere un packId no vacío.'));
}
const manifestPath = path.join(packsDir, packId, 'manifest.json');
try {
const raw = fs.readFileSync(manifestPath, 'utf-8');
reply(ack, null, JSON.parse(raw) as PackManifest);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
reply(ack, new Error(`No se puede leer el manifest de "${packId}": ${message}`));
}
});