diff --git a/src/dashboard/scoreko-dev/components/GamePackDownloadDialog.vue b/src/dashboard/scoreko-dev/components/GamePackDownloadDialog.vue new file mode 100644 index 0000000..ff8d333 --- /dev/null +++ b/src/dashboard/scoreko-dev/components/GamePackDownloadDialog.vue @@ -0,0 +1,301 @@ + + + + + diff --git a/src/dashboard/scoreko-dev/components/ScoreCenterPanel.vue b/src/dashboard/scoreko-dev/components/ScoreCenterPanel.vue index 26faae3..6e2a276 100644 --- a/src/dashboard/scoreko-dev/components/ScoreCenterPanel.vue +++ b/src/dashboard/scoreko-dev/components/ScoreCenterPanel.vue @@ -1,11 +1,28 @@ + +/* Atenúa visualmente los juegos no instalados en el desplegable */ +.pack-option--unavailable { + opacity: 0.6; +} + +.pack-option--unavailable:hover { + opacity: 1; +} + \ 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 db0dd4c..b1221ca 100644 --- a/src/dashboard/scoreko-dev/composables/useCharacterGame.ts +++ b/src/dashboard/scoreko-dev/composables/useCharacterGame.ts @@ -1,60 +1,90 @@ +// src/dashboard/scoreboard/composables/useCharacterGame.ts +// ───────────────────────────────────────────────────────────────────────────── +// Manages game selection and character state for both PlayerSidePanels. +// Must be called ONCE in ScoreboardPanel and provided via CHARACTER_GAME_KEY. +// +// Changes from original: +// - fightingGameOptions is now driven by the pack registry (allGameOptions) +// rather than a static hardcoded list. It falls back to bundled names +// while the registry loads. +// - Game selection is intercepted: selecting an unavailable game triggers +// the download dialog instead of updating the store. +// - pendingDownloadEntry / showDownloadDialog are exposed for ScoreCenterPanel. +// ───────────────────────────────────────────────────────────────────────────── + import { computed, ref, watch, type InjectionKey, type Ref } from 'vue'; import { getCharactersByGame, getDefaultCharactersByGame } from '../../../shared/fighting-characters'; import { useScoreboardStore } from '../stores/scoreboard'; +import { usePackRegistry } from './usePackRegistry'; +import type { GameSelectOption, PackRegistryEntry } from '../../../shared/pack-types'; -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -export const ALL_FIGHTING_GAME_OPTIONS = [ - - '2XKO', - 'FATAL FURY: City of the Wolves', - 'Guilty Gear -Strive-', - 'Invincible VS', - 'Mortal Kombat 1', - 'Street Fighter 6', - 'TEKKEN 8', - 'THE KING OF FIGHTERS XV', - -].map((game) => ({ label: game, value: game })); +// ── Types ───────────────────────────────────────────────────────────────────── export type CharacterOption = ReturnType[number]; - -// --------------------------------------------------------------------------- -// Injection key (type-safe provide/inject) -// --------------------------------------------------------------------------- - export type CharacterGameContext = ReturnType; export const CHARACTER_GAME_KEY: InjectionKey = Symbol('characterGame'); -// --------------------------------------------------------------------------- -// Composable -// --------------------------------------------------------------------------- +// ── Composable ──────────────────────────────────────────────────────────────── -/** - * Manages game selection and character state for both sides. - * Must be called ONCE in the parent (ScoreboardPanel) and provided via - * CHARACTER_GAME_KEY so both PlayerSidePanel instances share the same state. - */ export function useCharacterGame() { const scoreboardStore = useScoreboardStore(); + const packRegistry = usePackRegistry(); + + // ── Game selector state ─────────────────────────────────────────────────── - // Game selector const gameInput = ref(''); - const fightingGameOptions = ref(ALL_FIGHTING_GAME_OPTIONS); - // Per-side character state + /** + * Game options surfaced to the QSelect. + * Populated from the pack registry when available; falls back to bundled games. + * GameSelectOption includes an `available` flag used to show the download icon. + */ + const fightingGameOptions = ref(packRegistry.allGameOptions.value); + + // Keep fightingGameOptions in sync when the registry updates + watch( + packRegistry.allGameOptions, + (options) => { + fightingGameOptions.value = options; + }, + ); + + // ── Download dialog state ───────────────────────────────────────────────── + + /** Set when the user selects a game that isn't installed yet. */ + const pendingDownloadEntry = ref(null); + const showDownloadDialog = ref(false); + + /** + * Intercepting setter for the game selector. + * If the selected game is not available, opens the download dialog instead + * of writing to the store. + */ + const handleGameSelect = (gameName: string) => { + if (!gameName) { + scoreboardStore.scoreboard.game = ''; + return; + } + if (!packRegistry.isGameAvailable(gameName)) { + const entry = fightingGameOptions.value.find((o) => o.value === gameName)?.registryEntry ?? null; + pendingDownloadEntry.value = entry; + showDownloadDialog.value = true; + // Do NOT update the store — the game isn't installed + return; + } + scoreboardStore.scoreboard.game = gameName; + }; + + // ── Character state ─────────────────────────────────────────────────────── + const characterOptions = computed(() => getCharactersByGame(scoreboardStore.scoreboard.game)); const leftCharacterOptions = ref([]); const rightCharacterOptions = ref([]); const leftCharacterInput = ref(''); const rightCharacterInput = ref(''); - // Remembers selected characters per game so swapping games restores them const charactersByGame = ref>({}); - // Character images for preview const leftCharacterImage = computed(() => { const match = characterOptions.value.find( (o) => o.value === scoreboardStore.scoreboard.leftCharacter, @@ -69,20 +99,21 @@ export function useCharacterGame() { return match?.image ?? ''; }); - // --------------------------------------------------------------------------- - // Filter handlers - // --------------------------------------------------------------------------- + // ── Filter handlers ─────────────────────────────────────────────────────── const onGameFilter = (value: string, update: (fn: () => void) => void) => { update(() => { const needle = value.toLowerCase().trim(); fightingGameOptions.value = needle - ? ALL_FIGHTING_GAME_OPTIONS.filter((g) => g.label.toLowerCase().includes(needle)) - : ALL_FIGHTING_GAME_OPTIONS; + ? packRegistry.allGameOptions.value.filter((g) => + g.label.toLowerCase().includes(needle), + ) + : packRegistry.allGameOptions.value; }); }; - const makeCharacterFilter = (target: Ref) => + const makeCharacterFilter = + (target: Ref) => (value: string, update: (fn: () => void) => void) => { update(() => { const needle = value.toLowerCase().trim(); @@ -95,16 +126,14 @@ export function useCharacterGame() { const onLeftCharacterFilter = makeCharacterFilter(leftCharacterOptions); const onRightCharacterFilter = makeCharacterFilter(rightCharacterOptions); - // --------------------------------------------------------------------------- - // Watchers - // --------------------------------------------------------------------------- + // ── Watchers ────────────────────────────────────────────────────────────── - // Keep gameInput display value in sync + // Keep gameInput display value in sync with the store watch( () => scoreboardStore.scoreboard.game, (value) => { - const match = ALL_FIGHTING_GAME_OPTIONS.find((o) => o.value === value); - gameInput.value = match?.label ?? ''; + const match = fightingGameOptions.value.find((o) => o.value === value); + gameInput.value = match?.label ?? value; }, { immediate: true }, ); @@ -133,7 +162,6 @@ export function useCharacterGame() { if (!allowed.has(nextLeft)) nextLeft = ''; if (!allowed.has(nextRight)) nextRight = ''; - // Apply defaults only when neither side had a character yet if ((!nextLeft || !nextRight) && (!curLeft || !curRight)) { const defaults = getDefaultCharactersByGame(newGame); if (defaults) { @@ -159,7 +187,6 @@ export function useCharacterGame() { { immediate: true }, ); - // Keep left character display input and charactersByGame cache in sync watch( () => scoreboardStore.scoreboard.leftCharacter, (value) => { @@ -176,7 +203,6 @@ export function useCharacterGame() { { immediate: true }, ); - // Keep right character display input and charactersByGame cache in sync watch( () => scoreboardStore.scoreboard.rightCharacter, (value) => { @@ -193,16 +219,24 @@ export function useCharacterGame() { { immediate: true }, ); + // ── Return ──────────────────────────────────────────────────────────────── + return { + // Game selector gameInput, fightingGameOptions, + onGameFilter, + handleGameSelect, + // Download dialog + pendingDownloadEntry, + showDownloadDialog, + // Character state leftCharacterOptions, rightCharacterOptions, leftCharacterInput, rightCharacterInput, leftCharacterImage, rightCharacterImage, - onGameFilter, onLeftCharacterFilter, onRightCharacterFilter, }; diff --git a/src/dashboard/scoreko-dev/composables/usePackRegistry.ts b/src/dashboard/scoreko-dev/composables/usePackRegistry.ts new file mode 100644 index 0000000..c03f188 --- /dev/null +++ b/src/dashboard/scoreko-dev/composables/usePackRegistry.ts @@ -0,0 +1,288 @@ +// src/dashboard/scoreboard/composables/usePackRegistry.ts +// ───────────────────────────────────────────────────────────────────────────── +// Singleton composable. The first caller sets up NodeCG replicant listeners; +// subsequent calls return the same reactive state. This avoids duplicate event +// listeners when multiple components call usePackRegistry(). +// ───────────────────────────────────────────────────────────────────────────── + +import { computed, ref, type ComputedRef, type InjectionKey } from 'vue'; +import { + BUNDLED_GAME_NAMES, + registerInstalledPack, + unregisterInstalledPack, +} from '../../../shared/fighting-characters'; +import { BUNDLE_NAME } from '../../../shared/pack-config'; +import type { + GameSelectOption, + PackDownloadState, + PackManifest, + PackRegistry, + PackRegistryEntry, +} from '../../../shared/pack-types'; + +// ── NodeCG global type declarations ────────────────────────────────────────── +// NodeCG injects these into the browser window via its bundle script. + +declare const NodeCG: { + Replicant: ( + name: string, + bundleName: string, + opts?: { defaultValue?: T }, + ) => { + value: T; + on(event: 'change', handler: (newVal: T, oldVal?: T) => void): void; + off(event: string, handler: (...args: unknown[]) => void): void; + }; + waitForReplicants: (...reps: unknown[]) => Promise; +}; + +declare const nodecg: { + sendMessage(name: string, data?: unknown): void; + sendMessage( + name: string, + data: unknown, + cb: (err: Error | null, result?: unknown) => void, + ): void; +}; + +// ── Module-level singleton state ────────────────────────────────────────────── + +let initialized = false; + +const registry = ref(null); +const installedPackIds = ref([]); +const downloadStates = ref>({}); +const availableUpdates = ref>({}); + +// Tracks which installed pack manifests have been loaded into fighting-characters.ts +const loadedManifestIds = new Set(); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const formatBytes = (bytes: number): string => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +}; + +/** + * Asks the NodeCG extension to read the local manifest.json for an installed + * pack and registers the characters in fighting-characters.ts. + */ +const loadInstalledManifest = (packId: string): void => { + if (loadedManifestIds.has(packId)) return; + + nodecg.sendMessage('readLocalManifest', packId, (err, result) => { + if (err) { + console.error(`[usePackRegistry] Failed to load manifest for "${packId}":`, err); + return; + } + const manifest = result as PackManifest; + registerInstalledPack(manifest); + loadedManifestIds.add(packId); + }); +}; + +// ── Replicant setup (runs once) ─────────────────────────────────────────────── + +const initReplicants = (): void => { + if (initialized) return; + initialized = true; + + const registryRep = NodeCG.Replicant('packRegistry', BUNDLE_NAME, { + defaultValue: null, + }); + const installedRep = NodeCG.Replicant('installedPacks', BUNDLE_NAME, { + defaultValue: [], + }); + const statesRep = NodeCG.Replicant>('downloadStates', BUNDLE_NAME, { + defaultValue: {}, + }); + + const updatesRep = NodeCG.Replicant>('availableUpdates', BUNDLE_NAME, { + defaultValue: {}, + }); + + NodeCG.waitForReplicants(registryRep, installedRep, statesRep, updatesRep).then(() => { + // Hydrate initial values + registry.value = registryRep.value; + installedPackIds.value = installedRep.value ?? []; + downloadStates.value = statesRep.value ?? {}; + availableUpdates.value = updatesRep.value ?? {}; + + // Load manifests for packs already installed before this session + for (const id of installedPackIds.value) { + if (!BUNDLED_GAME_NAMES.has(getGameNameById(id))) { + loadInstalledManifest(id); + } + } + + // Subscribe to changes + registryRep.on('change', (val) => { + registry.value = val; + }); + + installedRep.on('change', (newVal, oldVal) => { + const next = newVal ?? []; + const prev = oldVal ?? []; + installedPackIds.value = next; + + // Load manifests for newly installed packs + const added = next.filter((id) => !prev.includes(id)); + for (const id of added) { + if (!BUNDLED_GAME_NAMES.has(getGameNameById(id))) { + loadInstalledManifest(id); + } + } + + // Unregister packs that were removed + const removed = prev.filter((id) => !next.includes(id)); + for (const id of removed) { + const gameName = getGameNameById(id); + unregisterInstalledPack(gameName); + loadedManifestIds.delete(id); + } + }); + + statesRep.on('change', (val) => { + downloadStates.value = val ?? {}; + }); + + updatesRep.on('change', (val) => { + availableUpdates.value = val ?? {}; + }); + }); +}; + +/** + * Given a pack ID (e.g. "street-fighter-6"), returns the matching game name + * from the current registry, or an empty string if the registry isn't loaded. + */ +const getGameNameById = (packId: string): string => + registry.value?.packs.find((p) => p.id === packId)?.name ?? ''; + +// ── Public composable ───────────────────────────────────────────────────────── + +export interface PackRegistryContext { + /** Full registry fetched from Gitea (null until first fetch). */ + registry: typeof registry; + /** IDs of packs installed on disk (bundled packs are NOT in this list). */ + installedPackIds: typeof installedPackIds; + /** Per-pack download state. */ + downloadStates: typeof downloadStates; + /** Checks if a game is available (bundled OR installed). */ + isGameAvailable: (gameName: string) => boolean; + /** Returns the download state for a pack, or a default idle state. */ + getDownloadState: (packId: string) => PackDownloadState; + /** All games from the registry, enriched with availability info. */ + allGameOptions: ReturnType; + /** Tells the extension to fetch the latest registry.json from Gitea. */ + fetchRegistry: () => void; + /** Tells the extension to download and install a pack. */ + downloadPack: (packId: string) => void; + /** Tells the extension to uninstall a pack and delete its files. */ + uninstallPack: (packId: string) => void; + /** Tells the extension to download and apply an update for an installed pack. */ + updatePack: (packId: string) => void; + /** Map of packId → version info for packs that have a newer version available. */ + availableUpdates: typeof availableUpdates; + /** Total number of packs with available updates. */ + updateCount: ComputedRef; + /** Human-readable file size. */ + formatBytes: typeof formatBytes; + /** Returns the URL for the pack's logo served by NodeCG (installed packs only). */ + getLocalLogoUrl: (packId: string) => string; +} + +export const PACK_REGISTRY_KEY: InjectionKey = Symbol('packRegistry'); + +const buildAllGameOptions = () => + computed(() => { + if (!registry.value) { + // Registry not loaded yet — surface only the bundled games as available + return Array.from(BUNDLED_GAME_NAMES).map((name) => ({ + label: name, + value: name, + available: true, + registryEntry: { + id: '', + name, + version: '', + totalSizeBytes: 0, + logoPath: '', + characterCount: 0, + palette: { start: '#334155', end: '#0f172a' }, + bundled: true, + } satisfies PackRegistryEntry, + })); + } + + return registry.value.packs.map((entry) => ({ + label: entry.name, + value: entry.name, + available: entry.bundled || installedPackIds.value.includes(entry.id), + registryEntry: entry, + updateInfo: availableUpdates.value[entry.id], + })); + }); + +export function usePackRegistry(): PackRegistryContext { + initReplicants(); + + const allGameOptions = buildAllGameOptions(); + + const isGameAvailable = (gameName: string): boolean => { + const entry = registry.value?.packs.find((p) => p.name === gameName); + if (!entry) return BUNDLED_GAME_NAMES.has(gameName); + return entry.bundled || installedPackIds.value.includes(entry.id); + }; + + const getDownloadState = (packId: string): PackDownloadState => + downloadStates.value[packId] ?? { status: 'idle', progress: 0 }; + + const getLocalLogoUrl = (packId: string): string => + `/assets/${BUNDLE_NAME}/packs/${packId}/logo.png`; + + const fetchRegistry = (): void => { + nodecg.sendMessage('fetchPackRegistry', undefined, (err) => { + if (err) console.error('[usePackRegistry] fetchPackRegistry failed:', err); + }); + }; + + const downloadPack = (packId: string): void => { + nodecg.sendMessage('downloadPack', packId, (err) => { + if (err) console.error(`[usePackRegistry] downloadPack "${packId}" failed:`, err); + }); + }; + + const uninstallPack = (packId: string): void => { + nodecg.sendMessage('uninstallPack', packId, (err) => { + if (err) console.error(`[usePackRegistry] uninstallPack "${packId}" failed:`, err); + }); + }; + + const updatePack = (packId: string): void => { + nodecg.sendMessage('updatePack', packId, (err) => { + if (err) console.error(`[usePackRegistry] updatePack "${packId}" failed:`, err); + }); + }; + + const updateCount = computed(() => Object.keys(availableUpdates.value).length); + + return { + registry, + installedPackIds, + downloadStates, + isGameAvailable, + getDownloadState, + allGameOptions, + fetchRegistry, + downloadPack, + uninstallPack, + updatePack, + availableUpdates, + updateCount, + formatBytes, + getLocalLogoUrl, + }; +} diff --git a/src/extension/index.ts b/src/extension/index.ts index d8ce32c..f56c18c 100644 --- a/src/extension/index.ts +++ b/src/extension/index.ts @@ -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'); }; diff --git a/src/extension/pack-manager.ts b/src/extension/pack-manager.ts new file mode 100644 index 0000000..9471600 --- /dev/null +++ b/src/extension/pack-manager.ts @@ -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('installedPacks', { + defaultValue: [], + persistent: true, +}); + +const packRegistryRep = nodecg.Replicant('packRegistry', { + defaultValue: null, + persistent: true, +}); + +const downloadStatesRep = nodecg.Replicant>('downloadStates', { + defaultValue: {}, + persistent: false, +}); + +/** Packs instalados para los que hay una versión más nueva en el registro. */ +const availableUpdatesRep = nodecg.Replicant>('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): void => { + const current = downloadStatesRep.value?.[packId] ?? { status: 'idle', progress: 0 }; + downloadStatesRep.value = { + ...downloadStatesRep.value, + [packId]: { ...current, ...patch }, + }; +}; + +const fetchBuffer = async (url: string): Promise => { + 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 => { + 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 = {}; + + 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 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}`)); + } +}); diff --git a/src/shared/character-images/tekken-8/alisa.png b/src/shared/character-images/tekken-8/alisa.png index a0550c0..d4fad0b 100644 Binary files a/src/shared/character-images/tekken-8/alisa.png and b/src/shared/character-images/tekken-8/alisa.png differ diff --git a/src/shared/character-images/tekken-8/anna.png b/src/shared/character-images/tekken-8/anna.png index e4ed0de..2b8ce83 100644 Binary files a/src/shared/character-images/tekken-8/anna.png and b/src/shared/character-images/tekken-8/anna.png differ diff --git a/src/shared/character-images/tekken-8/armor-king.png b/src/shared/character-images/tekken-8/armor-king.png index 31dcfde..209a97c 100644 Binary files a/src/shared/character-images/tekken-8/armor-king.png and b/src/shared/character-images/tekken-8/armor-king.png differ diff --git a/src/shared/character-images/tekken-8/asuka.png b/src/shared/character-images/tekken-8/asuka.png index 84142f0..3c541fb 100644 Binary files a/src/shared/character-images/tekken-8/asuka.png and b/src/shared/character-images/tekken-8/asuka.png differ diff --git a/src/shared/character-images/tekken-8/azucena.png b/src/shared/character-images/tekken-8/azucena.png index f833628..4060612 100644 Binary files a/src/shared/character-images/tekken-8/azucena.png and b/src/shared/character-images/tekken-8/azucena.png differ diff --git a/src/shared/character-images/tekken-8/bryan.png b/src/shared/character-images/tekken-8/bryan.png index b297b5c..ae0b9ee 100644 Binary files a/src/shared/character-images/tekken-8/bryan.png and b/src/shared/character-images/tekken-8/bryan.png differ diff --git a/src/shared/character-images/tekken-8/claudio.png b/src/shared/character-images/tekken-8/claudio.png index 5ac8bef..c4f85a7 100644 Binary files a/src/shared/character-images/tekken-8/claudio.png and b/src/shared/character-images/tekken-8/claudio.png differ diff --git a/src/shared/character-images/tekken-8/clive.png b/src/shared/character-images/tekken-8/clive.png index 6f13560..94d105b 100644 Binary files a/src/shared/character-images/tekken-8/clive.png and b/src/shared/character-images/tekken-8/clive.png differ diff --git a/src/shared/character-images/tekken-8/devil-jin.png b/src/shared/character-images/tekken-8/devil-jin.png index 0e86e89..55b4db8 100644 Binary files a/src/shared/character-images/tekken-8/devil-jin.png and b/src/shared/character-images/tekken-8/devil-jin.png differ diff --git a/src/shared/character-images/tekken-8/dragunov.png b/src/shared/character-images/tekken-8/dragunov.png index 99d29d7..3249afc 100644 Binary files a/src/shared/character-images/tekken-8/dragunov.png and b/src/shared/character-images/tekken-8/dragunov.png differ diff --git a/src/shared/character-images/tekken-8/eddy.png b/src/shared/character-images/tekken-8/eddy.png index 49b42f7..03c6f5b 100644 Binary files a/src/shared/character-images/tekken-8/eddy.png and b/src/shared/character-images/tekken-8/eddy.png differ diff --git a/src/shared/character-images/tekken-8/fahkumram.png b/src/shared/character-images/tekken-8/fahkumram.png index 4e6f886..0a9b12f 100644 Binary files a/src/shared/character-images/tekken-8/fahkumram.png and b/src/shared/character-images/tekken-8/fahkumram.png differ diff --git a/src/shared/character-images/tekken-8/feng.png b/src/shared/character-images/tekken-8/feng.png index d518c66..8913697 100644 Binary files a/src/shared/character-images/tekken-8/feng.png and b/src/shared/character-images/tekken-8/feng.png differ diff --git a/src/shared/character-images/tekken-8/heihachi.png b/src/shared/character-images/tekken-8/heihachi.png index 0301ee7..d0d4133 100644 Binary files a/src/shared/character-images/tekken-8/heihachi.png and b/src/shared/character-images/tekken-8/heihachi.png differ diff --git a/src/shared/character-images/tekken-8/hwoarang.png b/src/shared/character-images/tekken-8/hwoarang.png index 7b06087..2300d8f 100644 Binary files a/src/shared/character-images/tekken-8/hwoarang.png and b/src/shared/character-images/tekken-8/hwoarang.png differ diff --git a/src/shared/character-images/tekken-8/jack-8.png b/src/shared/character-images/tekken-8/jack-8.png index be635ba..8a4cf9d 100644 Binary files a/src/shared/character-images/tekken-8/jack-8.png and b/src/shared/character-images/tekken-8/jack-8.png differ diff --git a/src/shared/character-images/tekken-8/jin.png b/src/shared/character-images/tekken-8/jin.png index dcd31cc..11574a6 100644 Binary files a/src/shared/character-images/tekken-8/jin.png and b/src/shared/character-images/tekken-8/jin.png differ diff --git a/src/shared/character-images/tekken-8/jun.png b/src/shared/character-images/tekken-8/jun.png index a71618c..5d9f277 100644 Binary files a/src/shared/character-images/tekken-8/jun.png and b/src/shared/character-images/tekken-8/jun.png differ diff --git a/src/shared/character-images/tekken-8/kazuya.png b/src/shared/character-images/tekken-8/kazuya.png index af2cdd2..ad58bde 100644 Binary files a/src/shared/character-images/tekken-8/kazuya.png and b/src/shared/character-images/tekken-8/kazuya.png differ diff --git a/src/shared/character-images/tekken-8/king.png b/src/shared/character-images/tekken-8/king.png index 136dbcf..d74f92a 100644 Binary files a/src/shared/character-images/tekken-8/king.png and b/src/shared/character-images/tekken-8/king.png differ diff --git a/src/shared/character-images/tekken-8/kuma.png b/src/shared/character-images/tekken-8/kuma.png index b50b8b3..5a5ea9b 100644 Binary files a/src/shared/character-images/tekken-8/kuma.png and b/src/shared/character-images/tekken-8/kuma.png differ diff --git a/src/shared/character-images/tekken-8/lars.png b/src/shared/character-images/tekken-8/lars.png index fbafba4..29ec023 100644 Binary files a/src/shared/character-images/tekken-8/lars.png and b/src/shared/character-images/tekken-8/lars.png differ diff --git a/src/shared/character-images/tekken-8/law.png b/src/shared/character-images/tekken-8/law.png index 6a2c5ee..c2cd270 100644 Binary files a/src/shared/character-images/tekken-8/law.png and b/src/shared/character-images/tekken-8/law.png differ diff --git a/src/shared/character-images/tekken-8/lee.png b/src/shared/character-images/tekken-8/lee.png index e47b806..00774ca 100644 Binary files a/src/shared/character-images/tekken-8/lee.png and b/src/shared/character-images/tekken-8/lee.png differ diff --git a/src/shared/character-images/tekken-8/leo.png b/src/shared/character-images/tekken-8/leo.png index f0d1a3e..4b34752 100644 Binary files a/src/shared/character-images/tekken-8/leo.png and b/src/shared/character-images/tekken-8/leo.png differ diff --git a/src/shared/character-images/tekken-8/leroy.png b/src/shared/character-images/tekken-8/leroy.png index e697bee..913fff7 100644 Binary files a/src/shared/character-images/tekken-8/leroy.png and b/src/shared/character-images/tekken-8/leroy.png differ diff --git a/src/shared/character-images/tekken-8/lili.png b/src/shared/character-images/tekken-8/lili.png index fc7fd90..d9abd24 100644 Binary files a/src/shared/character-images/tekken-8/lili.png and b/src/shared/character-images/tekken-8/lili.png differ diff --git a/src/shared/character-images/tekken-8/miary-zo.png b/src/shared/character-images/tekken-8/miary-zo.png index ee5854d..3dc80e4 100644 Binary files a/src/shared/character-images/tekken-8/miary-zo.png and b/src/shared/character-images/tekken-8/miary-zo.png differ diff --git a/src/shared/character-images/tekken-8/nina.png b/src/shared/character-images/tekken-8/nina.png index f530209..04ed006 100644 Binary files a/src/shared/character-images/tekken-8/nina.png and b/src/shared/character-images/tekken-8/nina.png differ diff --git a/src/shared/character-images/tekken-8/panda.png b/src/shared/character-images/tekken-8/panda.png index 1456c55..165bc6f 100644 Binary files a/src/shared/character-images/tekken-8/panda.png and b/src/shared/character-images/tekken-8/panda.png differ diff --git a/src/shared/character-images/tekken-8/paul.png b/src/shared/character-images/tekken-8/paul.png index b4b8617..d107bcb 100644 Binary files a/src/shared/character-images/tekken-8/paul.png and b/src/shared/character-images/tekken-8/paul.png differ diff --git a/src/shared/character-images/tekken-8/raven.png b/src/shared/character-images/tekken-8/raven.png index 409edd0..5da6c31 100644 Binary files a/src/shared/character-images/tekken-8/raven.png and b/src/shared/character-images/tekken-8/raven.png differ diff --git a/src/shared/character-images/tekken-8/reina.png b/src/shared/character-images/tekken-8/reina.png index 99e65ca..7a78663 100644 Binary files a/src/shared/character-images/tekken-8/reina.png and b/src/shared/character-images/tekken-8/reina.png differ diff --git a/src/shared/character-images/tekken-8/shaheen.png b/src/shared/character-images/tekken-8/shaheen.png index 1a13a45..47c89df 100644 Binary files a/src/shared/character-images/tekken-8/shaheen.png and b/src/shared/character-images/tekken-8/shaheen.png differ diff --git a/src/shared/character-images/tekken-8/steve.png b/src/shared/character-images/tekken-8/steve.png index af4c265..9c78bf1 100644 Binary files a/src/shared/character-images/tekken-8/steve.png and b/src/shared/character-images/tekken-8/steve.png differ diff --git a/src/shared/character-images/tekken-8/victor.png b/src/shared/character-images/tekken-8/victor.png index c8560ed..4da09f5 100644 Binary files a/src/shared/character-images/tekken-8/victor.png and b/src/shared/character-images/tekken-8/victor.png differ diff --git a/src/shared/character-images/tekken-8/xiaoyu.png b/src/shared/character-images/tekken-8/xiaoyu.png index d58509e..d2b0469 100644 Binary files a/src/shared/character-images/tekken-8/xiaoyu.png and b/src/shared/character-images/tekken-8/xiaoyu.png differ diff --git a/src/shared/character-images/tekken-8/yoshimitsu.png b/src/shared/character-images/tekken-8/yoshimitsu.png index b9f4f43..e1be974 100644 Binary files a/src/shared/character-images/tekken-8/yoshimitsu.png and b/src/shared/character-images/tekken-8/yoshimitsu.png differ diff --git a/src/shared/character-images/tekken-8/zafina.png b/src/shared/character-images/tekken-8/zafina.png index c9ab2b7..21dcdc0 100644 Binary files a/src/shared/character-images/tekken-8/zafina.png and b/src/shared/character-images/tekken-8/zafina.png differ diff --git a/src/shared/fighting-characters.ts b/src/shared/fighting-characters.ts index 9946fbc..178e408 100644 --- a/src/shared/fighting-characters.ts +++ b/src/shared/fighting-characters.ts @@ -1,3 +1,16 @@ +// src/shared/fighting-characters.ts +// ───────────────────────────────────────────────────────────────────────────── +// Two sources of character data: +// 1. BUNDLED — shipped with the app, images loaded at build time via +// import.meta.glob (unchanged from before). +// 2. INSTALLED — downloaded from Gitea at runtime, registered via +// registerInstalledPack(). Images served by NodeCG from +// /assets//packs//characters/. +// ───────────────────────────────────────────────────────────────────────────── + +import { BUNDLE_NAME } from './pack-config'; +import type { PackManifest } from './pack-types'; + export interface FightingCharacterOption { label: string; value: string; @@ -10,301 +23,81 @@ type GamePalette = readonly [startColor: string, endColor: string]; const DEFAULT_PLACEHOLDER_PALETTE: GamePalette = ['#334155', '#0f172a']; const MAX_INITIALS = 2; +// ───────────────────────────────────────────────────────────────────────────── +// BUNDLED DATA +// ───────────────────────────────────────────────────────────────────────────── + const characterNamesByGame: Record = { '2XKO': [ - 'Ahri', - 'Akali', - 'Braum', - 'Caitlyn', - 'Darius', - 'Ekko', - 'Illaoi', - 'Jinx', - 'Senna', - 'Teemo', - 'Vi', - 'Warwick', - 'Yasuo', + 'Ahri', 'Akali', 'Braum', 'Caitlyn', 'Darius', 'Ekko', + 'Illaoi', 'Jinx', 'Senna', 'Teemo', 'Vi', 'Warwick', 'Yasuo', ], 'FATAL FURY: City of the Wolves': [ - 'Andy Bogard', - 'B. Jenet', - 'Billy Kane', - 'Blue Mary', - 'Chun-Li', - 'Cristiano Ronaldo', - 'Gato', - 'Hokutomaru', - 'Hotaru Futaba', - 'Joe Higashi', - 'Kain R. Heinlein', - 'Ken Masters', - 'Kenshiro', - 'Kevin Rian', - 'Kim Dong Hwan', - 'Kim Jae Hoon', - 'Mai Shiranui', - 'Marco Rodrigues', - 'Mr. Big', - 'Mr. Karate', - 'Nightmare Geese', - 'Preecha', - 'Rock Howard', - 'Salvatore Ganacci', - 'Terry Bogard', - 'Tizoc', - 'Vox Reaper', - 'Wolfgang Krauser', + 'Andy Bogard', 'B. Jenet', 'Billy Kane', 'Blue Mary', 'Chun-Li', + 'Cristiano Ronaldo', 'Gato', 'Hokutomaru', 'Hotaru Futaba', 'Joe Higashi', + 'Kain R. Heinlein', 'Ken Masters', 'Kenshiro', 'Kevin Rian', + 'Kim Dong Hwan', 'Kim Jae Hoon', 'Mai Shiranui', 'Marco Rodrigues', + 'Mr. Big', 'Mr. Karate', 'Nightmare Geese', 'Preecha', 'Rock Howard', + 'Salvatore Ganacci', 'Terry Bogard', 'Tizoc', 'Vox Reaper', 'Wolfgang Krauser', ], 'Guilty Gear -Strive-': [ - 'A.B.A', - 'Anji Mito', - 'Asuka R.', - 'Axl Low', - 'Baiken', - 'Bedman?', - 'Bridget', - 'Chipp Zanuff', - 'Dizzy', - 'Elphelt Valentine', - 'Faust', - 'Giovanna', - 'Goldlewis Dickinson', - 'Happy Chaos', - 'I-No', - 'Jack-O', - 'Johnny', - 'Ky Kiske', - 'Leo Whitefang', - 'Lucy', - 'May', - 'Millia Rage', - 'Nagoriyuki', - 'Potemkin', - 'Ramlethal Valentine', - 'Sin Kiske', - 'Slayer', - 'Sol Badguy', - 'Testament', - 'Unika', - 'Venom', - 'Zato-1', + 'A.B.A', 'Anji Mito', 'Asuka R.', 'Axl Low', 'Baiken', 'Bedman?', + 'Bridget', 'Chipp Zanuff', 'Dizzy', 'Elphelt Valentine', 'Faust', + 'Giovanna', 'Goldlewis Dickinson', 'Happy Chaos', 'I-No', 'Jack-O', + 'Johnny', 'Ky Kiske', 'Leo Whitefang', 'Lucy', 'May', 'Millia Rage', + 'Nagoriyuki', 'Potemkin', 'Ramlethal Valentine', 'Sin Kiske', 'Slayer', + 'Sol Badguy', 'Testament', 'Unika', 'Venom', 'Zato-1', ], 'Invincible VS': [ - 'Allen the Alien', - 'Anissa', - 'Atom Eve', - 'Battle Beast', - 'Bulletproof', - 'Cecil', - 'Conquest', - 'Dupli-Kate', - 'Ella Mental', - 'Immortal', - 'Invincible', - 'Lucan', - 'Monster Girl', - 'Omni-Man', - 'Powerplex', - 'Rex Splode', - 'Robot', - 'Thula', - 'Titan', - 'Universa', + 'Allen the Alien', 'Anissa', 'Atom Eve', 'Battle Beast', 'Bulletproof', + 'Cecil', 'Conquest', 'Dupli-Kate', 'Ella Mental', 'Immortal', 'Invincible', + 'Lucan', 'Monster Girl', 'Omni-Man', 'Powerplex', 'Rex Splode', 'Robot', + 'Thula', 'Titan', 'Universa', ], 'Mortal Kombat 1': [ - 'Ashrah', - 'Baraka', - 'Conan the Barbarian', - 'Cyrax', - 'Ermac', - 'Geras', - 'Ghostface', - 'Havik', - 'Homelander', - 'Johnny Cage', - 'Kenshi', - 'Kitana', - 'Kung Lao', - 'Li Mei', - 'Liu Kang', - 'Mileena', - 'Nitara', - 'Noob Saibot', - 'Omni-Man', - 'Peacemaker', - 'Quan Chi', - 'Raiden', - 'Rain', - 'Reiko', - 'Reptile', - 'Scorpion', - 'Sektor', - 'Shang Tsung', - 'Sindel', - 'Smoke', - 'Sub-Zero', - 'Takeda', - 'Tanya', - 'T-1000', - ], + 'Ashrah', 'Baraka', 'Conan the Barbarian', 'Cyrax', 'Ermac', 'Geras', + 'Ghostface', 'Havik', 'Homelander', 'Johnny Cage', 'Kenshi', 'Kitana', + 'Kung Lao', 'Li Mei', 'Liu Kang', 'Mileena', 'Nitara', 'Noob Saibot', + 'Omni-Man', 'Peacemaker', 'Quan Chi', 'Raiden', 'Rain', 'Reiko', 'Reptile', + 'Scorpion', 'Sektor', 'Shang Tsung', 'Sindel', 'Smoke', 'Sub-Zero', + 'Takeda', 'Tanya', 'T-1000', + ], 'Street Fighter 6': [ - 'A.K.I.', - 'Akuma', - 'Alex', - 'Bison', - 'Blanka', - 'Cammy', - 'Chun-Li', - 'Dee Jay', - 'Dhalsim', - 'E. Honda', - 'Ed', - 'Elena', - 'Guile', - 'Jamie', - 'JP', - 'Juri', - 'Ken', - 'Kimberly', - 'Lily', - 'Luke', - 'Mai', - 'Manon', - 'Marisa', - 'Rashid', - 'Ryu', - 'Sagat', - 'Terry', - 'Viper', - 'Zangief', + 'A.K.I.', 'Akuma', 'Alex', 'Bison', 'Blanka', 'Cammy', 'Chun-Li', + 'Dee Jay', 'Dhalsim', 'E. Honda', 'Ed', 'Elena', 'Guile', 'Jamie', 'JP', + 'Juri', 'Ken', 'Kimberly', 'Lily', 'Luke', 'Mai', 'Manon', 'Marisa', + 'Rashid', 'Ryu', 'Sagat', 'Terry', 'Viper', 'Zangief', ], 'TEKKEN 8': [ - 'Alisa', - 'Anna', - 'Armor King', - 'Asuka', - 'Azucena', - 'Bob', - 'Bryan', - 'Claudio', - 'Clive', - 'Devil Jin', - 'Dragunov', - 'Eddy', - 'Fahkumram', - 'Feng', - 'Heihachi', - 'Hwoarang', - 'Jack-8', - 'Jin', - 'Jun', - 'Kazuya', - 'King', - 'Kuma', - 'Kunimitsu', - 'Lars', - 'Law', - 'Lee', - 'Leo', - 'Leroy', - 'Lidia', - 'Lili', - 'Miary Zo', - 'Nina', - 'Panda', - 'Paul', - 'Raven', - 'Reina', - 'Roger Jr', - 'Shaheen', - 'Steve', - 'Victor', - 'Xiaoyu', - 'Yoshimitsu', - 'Zafina', + 'Alisa', 'Anna', 'Armor King', 'Asuka', 'Azucena', 'Bob', 'Bryan', + 'Claudio', 'Clive', 'Devil Jin', 'Dragunov', 'Eddy', 'Fahkumram', 'Feng', + 'Heihachi', 'Hwoarang', 'Jack-8', 'Jin', 'Jun', 'Kazuya', 'King', 'Kuma', + 'Kunimitsu', 'Lars', 'Law', 'Lee', 'Leo', 'Leroy', 'Lidia', 'Lili', + 'Miary Zo', 'Nina', 'Panda', 'Paul', 'Raven', 'Reina', 'Roger Jr', + 'Shaheen', 'Steve', 'Victor', 'Xiaoyu', 'Yoshimitsu', 'Zafina', ], 'THE KING OF FIGHTERS XV': [ - 'Angel', - 'Antonov', - 'Ash Crimson', - 'Athena Asamiya', - 'Benimaru Nikaido', - 'Billy Kane', - 'Blue Mary', - 'Chizuru Kagura', - 'Chris', - 'Clark Still', - 'Dolores', - 'Duo Lon', - 'Elisabeth Blanctorche', - 'Gato', - 'Geese Howard', - 'Goenitz', - 'Heidern', - 'Hinako Shijo', - 'Iori Yagami', - 'Isla', - 'Joe Higashi', - "K'", - 'Kim Kaphwan', - 'King', - 'King of Dinosaurs', - 'Krohnen McDougall', - 'Kula Diamond', - 'Kukri', - 'Kyo Kusanagi', - 'Leona Heidern', - 'Luong', - 'Mai Shiranui', - 'Maxima', - 'Meitenkun', - 'Najd', - 'Orochi Chris', - 'Orochi Shermie', - 'Orochi Yashiro', - 'Ralf Jones', - 'Ramón', - 'Robert Garcia', - 'Rock Howard', - 'Ryo Sakazaki', - 'Ryuji Yamazaki', - 'Shermie', - 'Shingo Yabuki', - 'Sylvie Paula Paula', - 'Terry Bogard', - 'Vanessa', - 'Whip', - 'Yashiro Nanakase', + 'Angel', 'Antonov', 'Ash Crimson', 'Athena Asamiya', 'Benimaru Nikaido', + 'Billy Kane', 'Blue Mary', 'Chizuru Kagura', 'Chris', 'Clark Still', + 'Dolores', 'Duo Lon', 'Elisabeth Blanctorche', 'Gato', 'Geese Howard', + 'Goenitz', 'Heidern', 'Hinako Shijo', 'Iori Yagami', 'Isla', 'Joe Higashi', + "K'", 'Kim Kaphwan', 'King', 'King of Dinosaurs', 'Krohnen McDougall', + 'Kula Diamond', 'Kukri', 'Kyo Kusanagi', 'Leona Heidern', 'Luong', + 'Mai Shiranui', 'Maxima', 'Meitenkun', 'Najd', 'Orochi Chris', + 'Orochi Shermie', 'Orochi Yashiro', 'Ralf Jones', 'Ramón', 'Robert Garcia', + 'Rock Howard', 'Ryo Sakazaki', 'Ryuji Yamazaki', 'Shermie', 'Shingo Yabuki', + 'Sylvie Paula Paula', 'Terry Bogard', 'Vanessa', 'Whip', 'Yashiro Nanakase', 'Yuri Sakazaki', ], }; const defaultCharacterPairByGame: Record = { - 'Guilty Gear -Strive-': { - leftCharacter: 'sol-badguy', - rightCharacter: 'ky-kiske', - }, - 'Street Fighter 6': { - leftCharacter: 'ryu', - rightCharacter: 'chun-li', - }, - 'TEKKEN 8': { - leftCharacter: 'jin', - rightCharacter: 'kazuya', - }, - '2XKO': { - leftCharacter: 'ahri', - rightCharacter: 'yasuo', - }, - 'Mortal Kombat 1': { - leftCharacter: 'scorpion', - rightCharacter: 'sub-zero', - }, - 'THE KING OF FIGHTERS XV': { - leftCharacter: 'kyo-kusanagi', - rightCharacter: 'iori-yagami', - }, + 'Guilty Gear -Strive-': { leftCharacter: 'sol-badguy', rightCharacter: 'ky-kiske' }, + 'Street Fighter 6': { leftCharacter: 'ryu', rightCharacter: 'chun-li' }, + 'TEKKEN 8': { leftCharacter: 'jin', rightCharacter: 'kazuya' }, + '2XKO': { leftCharacter: 'ahri', rightCharacter: 'yasuo' }, + 'Mortal Kombat 1': { leftCharacter: 'scorpion', rightCharacter: 'sub-zero' }, + 'THE KING OF FIGHTERS XV': { leftCharacter: 'kyo-kusanagi', rightCharacter: 'iori-yagami' }, }; const paletteByGame: Record = { @@ -316,11 +109,49 @@ const paletteByGame: Record = { 'THE KING OF FIGHTERS XV': ['#0ea5e9', '#1e3a8a'], }; -const toSlug = (value: string) => value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); +const dlcCharactersByGame: Record> = { + 'FATAL FURY: City of the Wolves': new Set([ + 'Chun-Li', 'Cristiano Ronaldo', 'Ken Masters', 'Kenshiro', + 'Nightmare Geese', 'Salvatore Ganacci', 'Vox Reaper', + ]), + 'Guilty Gear -Strive-': new Set([ + 'Goldlewis Dickinson', 'Jack-O', 'Happy Chaos', 'Baiken', 'Testament', + 'Bridget', 'Sin Kiske', 'Bedman?', 'Asuka R. Kreutz', 'Johnny', + 'Elphelt Valentine', 'A.B.A', 'Slayer', 'Dizzy', 'Venom', + 'Lucy', 'Unika', + ]), + 'Mortal Kombat 1': new Set([ + 'Ermac', 'Homelander', 'Omni-Man', 'Peacemaker', 'Quan Chi', 'Tanya', + 'Conan the Barbarian', 'Cyrax', 'Ghostface', 'Noob Saibot', 'Sektor', + 'Shang Tsung', 'Takeda', 'T-1000', + ]), + 'Street Fighter 6': new Set([ + 'A.K.I.', 'Akuma', 'Bison', 'Ed', + 'Alex', 'Elena', 'Mai', 'Sagat', 'Terry', 'Viper', + ]), + 'TEKKEN 8': new Set([ + 'Clive', 'Eddy', 'Heihachi', 'Lidia', + 'Anna', 'Fahkumram', 'Kunimitsu', 'Miary Zo', 'Roger Jr', + ]), + 'THE KING OF FIGHTERS XV': new Set([ + 'Antonov', 'Elisabeth Blanctorche', 'Gato', 'Geese Howard', 'Goenitz', + 'Hinako Shijo', 'Krohnen McDougall', 'Kukri', 'Luong', 'Najd', + 'Orochi Chris', 'Orochi Shermie', 'Orochi Yashiro', 'Rock Howard', + 'Sylvie Paula Paula', + ]), +}; -const toDataUrl = (svg: string) => `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; +// ───────────────────────────────────────────────────────────────────────────── +// Image resolution — BUNDLED +// ───────────────────────────────────────────────────────────────────────────── -const buildCharacterPlaceholder = (game: string, character: string) => { +const toSlug = (value: string): string => + value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); + +const toDataUrl = (svg: string): string => + `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; + +const buildCharacterPlaceholder = (game: string, character: string): string => { const [startColor, endColor] = paletteByGame[game] ?? DEFAULT_PLACEHOLDER_PALETTE; const initials = character .split(/\s+/) @@ -347,108 +178,154 @@ const buildCharacterPlaceholder = (game: string, character: string) => { return toDataUrl(svg.trim()); }; -const characterImageModules = import.meta.glob('/src/shared/character-images/**/*.{png,jpg,jpeg,webp,avif,svg}', { - eager: true, - import: 'default', - query: '?url', -}) as Record; +const characterImageModules = import.meta.glob( + '/src/shared/character-images/**/*.{png,jpg,jpeg,webp,avif,svg}', + { eager: true, import: 'default', query: '?url' }, +) as Record; const resolveImageKey = (path: string): string | null => { const segments = path.split('/'); const gameFolder = segments.at(-2); const filename = segments.at(-1); - - if (!gameFolder || !filename) { - return null; - } - - const characterSlug = filename.replace(/\.[^.]+$/, ''); - return `${gameFolder}/${characterSlug}`; + if (!gameFolder || !filename) return null; + return `${gameFolder}/${filename.replace(/\.[^.]+$/, '')}`; }; -const characterImageByKey = Object.entries(characterImageModules).reduce>((acc, [path, url]) => { - const key = resolveImageKey(path); - if (!key) { +const characterImageByKey = Object.entries(characterImageModules).reduce>( + (acc, [path, url]) => { + const key = resolveImageKey(path); + if (key) acc[key] = url; return acc; - } + }, + {}, +); - acc[key] = url; - return acc; -}, {}); - -const getCharacterImage = (game: string, character: string, characterValue: string) => { +const getBundledCharacterImage = (game: string, character: string, slug: string): string => { const gameSlug = toSlug(game); - const key = `${gameSlug}/${characterValue}`; + const key = `${gameSlug}/${slug}`; return characterImageByKey[key] ?? buildCharacterPlaceholder(game, character); }; -/** - * DLC characters per game. Update as new content is released. - * Characters not listed here are treated as base-roster. - */ -const dlcCharactersByGame: Record> = { - 'FATAL FURY: City of the Wolves': new Set([ - 'Chun-Li', // Season Pass (crossover) - 'Cristiano Ronaldo', // Season Pass (celebrity) - 'Ken Masters', // Season Pass (crossover) - 'Kenshiro', // Season Pass (crossover) - 'Nightmare Geese', // Season Pass - 'Salvatore Ganacci', // Season Pass (celebrity) - 'Vox Reaper', // Season Pass - ]), - 'Guilty Gear -Strive-': new Set([ - // Season 1 - 'Goldlewis Dickinson', 'Jack-O', 'Happy Chaos', 'Baiken', 'Testament', - // Season 2 - 'Bridget', 'Sin Kiske', 'Bedman?', 'Asuka R. Kreutz', 'Johnny', - // Season 3 - 'Elphelt Valentine', 'A.B.A', 'Slayer', 'Dizzy', 'Venom', - // Season 4 - 'Lucy', 'Unika', - ]), - 'Mortal Kombat 1': new Set([ - // Kombat Pack 1 - 'Ermac', 'Homelander', 'Omni-Man', 'Peacemaker', 'Quan Chi', 'Tanya', - // Kombat Pack 2 - 'Conan the Barbarian', 'Cyrax', 'Ghostface', 'Noob Saibot', 'Sektor', - 'Shang Tsung', 'Takeda', 'T-1000', - ]), - 'Street Fighter 6': new Set([ - // Year 1 - 'A.K.I.', 'Akuma', 'Bison', 'Ed', - // Year 2 - 'Alex', 'Elena', 'Mai', 'Sagat', 'Terry', 'Viper', - ]), - 'TEKKEN 8': new Set([ - // Season 1 - 'Clive', 'Eddy', 'Heihachi', 'Lidia', - // Season 2 - 'Anna', 'Fahkumram', 'Kunimitsu', 'Miary Zo', 'Roger Jr', - ]), - 'THE KING OF FIGHTERS XV': new Set([ - 'Antonov', 'Elisabeth Blanctorche', 'Gato', 'Geese Howard', 'Goenitz', - 'Hinako Shijo', 'Krohnen McDougall', 'Kukri', 'Luong', 'Najd', - 'Orochi Chris', 'Orochi Shermie', 'Orochi Yashiro', 'Rock Howard', - 'Sylvie Paula Paula', - ]), -}; +// ───────────────────────────────────────────────────────────────────────────── +// Compile bundled game options +// ───────────────────────────────────────────────────────────────────────────── export const fightingCharactersByGame: Record = Object.fromEntries( Object.entries(characterNamesByGame).map(([game, characterNames]) => [ game, characterNames.map((character) => { const value = toSlug(character); - // Prefer packaged artwork and gracefully fallback to a generated image. return { label: character, value, - image: getCharacterImage(game, character, value), + image: getBundledCharacterImage(game, character, value), dlc: dlcCharactersByGame[game]?.has(character) ?? false, }; }), ]), ); -export const getCharactersByGame = (game: string) => fightingCharactersByGame[game] ?? []; +/** + * The set of game names that are bundled with the application. + * Used by usePackRegistry to determine if a pack needs to be downloaded. + */ +export const BUNDLED_GAME_NAMES = new Set(Object.keys(characterNamesByGame)); -export const getDefaultCharactersByGame = (game: string) => defaultCharacterPairByGame[game]; +// ───────────────────────────────────────────────────────────────────────────── +// INSTALLED PACK REGISTRY (runtime, populated by usePackRegistry) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Runtime character data for packs that have been downloaded from Gitea. + * Keyed by game display name (same as PackManifest.name) so that + * getCharactersByGame() can look them up with the same key as bundled games. + */ +const installedPackCharacters: Record = {}; +const installedPackDefaults: Record = {}; + +/** + * Registers an installed (downloaded) pack so that getCharactersByGame() and + * getDefaultCharactersByGame() return its data. + * + * Called by usePackRegistry when: + * - The composable mounts and an installed pack's manifest is read from disk. + * - A new pack finishes downloading. + * + * Images are served by NodeCG from /assets//packs//characters/. + * The function tries the most common extension; the browser will 404 gracefully + * for missing files (placeholder is shown by the img error handler in the template). + */ +export const registerInstalledPack = (manifest: PackManifest): void => { + const { id, name, palette, characters, defaultPair } = manifest; + const [startColor, endColor] = [palette.start, palette.end]; + + installedPackCharacters[name] = characters.map((char) => ({ + 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`, + dlc: char.dlc ?? false, + // Fallback placeholder uses the same palette as the manifest + _placeholder: buildInstalledPlaceholder(name, char.name, startColor, endColor), + })); + + if (defaultPair) { + installedPackDefaults[name] = { + leftCharacter: defaultPair.left, + rightCharacter: defaultPair.right, + }; + } +}; + +/** + * Removes a previously registered installed pack. + * Called by usePackRegistry when a pack is uninstalled. + */ +export const unregisterInstalledPack = (gameName: string): void => { + delete installedPackCharacters[gameName]; + delete installedPackDefaults[gameName]; +}; + +const buildInstalledPlaceholder = ( + game: string, + character: string, + startColor: string, + endColor: string, +): string => { + const initials = character + .split(/\s+/) + .map((p) => p[0]) + .join('') + .slice(0, MAX_INITIALS) + .toUpperCase(); + + const svg = ` + + + + + + + + + + ${initials} + ${game} + ${character} +`; + return toDataUrl(svg.trim()); +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Public API +// ───────────────────────────────────────────────────────────────────────────── + +/** Returns the character list for a game, checking both bundled and installed packs. */ +export const getCharactersByGame = (game: string): FightingCharacterOption[] => + fightingCharactersByGame[game] ?? installedPackCharacters[game] ?? []; + +/** Returns the default character pair for a game, checking both bundled and installed packs. */ +export const getDefaultCharactersByGame = ( + game: string, +): { leftCharacter: string; rightCharacter: string } | undefined => + defaultCharacterPairByGame[game] ?? installedPackDefaults[game]; diff --git a/src/shared/pack-config.js b/src/shared/pack-config.js new file mode 100644 index 0000000..8f82432 --- /dev/null +++ b/src/shared/pack-config.js @@ -0,0 +1,37 @@ +// src/shared/pack-config.ts +// ───────────────────────────────────────────────────────────────────────────── +// Edit ONLY this file to point the pack system at your Gitea instance. +// All other files import their Gitea/NodeCG constants from here. +// ───────────────────────────────────────────────────────────────────────────── +/** Base URL of your Gitea instance — no trailing slash. */ +export const GITEA_BASE_URL = 'http://10.0.0.10:3002'; +/** Gitea owner (user or organisation) that owns the packs repository. */ +export const GITEA_OWNER = 'Pandipipas'; +/** Name of the repository that contains all game packs. */ +export const GITEA_REPO = 'fighting-game-packs'; +/** Branch to pull assets from. */ +export const GITEA_BRANCH = 'main'; +/** + * NodeCG bundle name. + * Must match the "name" field in your package.json / nodecg config. + */ +export const BUNDLE_NAME = 'scoreko-dev'; +// ── Derived URL helpers (do not edit below this line) ──────────────────────── +/** Returns the Gitea raw-file URL for any repo-relative path. */ +export const getGiteaRawUrl = (repoPath) => `${GITEA_BASE_URL}/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${repoPath}`; +/** URL of the master registry file that lists every available pack. */ +export const REGISTRY_URL = getGiteaRawUrl('registry.json'); +/** Returns the URL for a specific pack's manifest.json. */ +export const getManifestUrl = (packId) => getGiteaRawUrl(`${packId}/manifest.json`); +/** Returns the URL for a pack's logo. */ +export const getPackLogoUrl = (packId) => getGiteaRawUrl(`${packId}/logo.png`); +/** + * Returns the URL for a specific character image stored in the Gitea repo. + * Used during download; at runtime installed packs are served by NodeCG. + */ +export const getCharacterImageRepoUrl = (packId, slug, ext) => getGiteaRawUrl(`${packId}/characters/${slug}.${ext}`); +/** + * Returns the runtime URL for a character image from an *installed* (downloaded) pack. + * NodeCG serves everything under assets/ at /assets//. + */ +export const getInstalledCharacterImageUrl = (packId, slug, ext = 'png') => `/assets/${BUNDLE_NAME}/packs/${packId}/characters/${slug}.${ext}`; diff --git a/src/shared/pack-config.ts b/src/shared/pack-config.ts new file mode 100644 index 0000000..2f2cea6 --- /dev/null +++ b/src/shared/pack-config.ts @@ -0,0 +1,54 @@ +// src/shared/pack-config.ts +// ───────────────────────────────────────────────────────────────────────────── +// Edit ONLY this file to point the pack system at your Gitea instance. +// All other files import their Gitea/NodeCG constants from here. +// ───────────────────────────────────────────────────────────────────────────── + +/** Base URL of your Gitea instance — no trailing slash. */ +export const GITEA_BASE_URL = 'http://10.0.0.10:3002'; + +/** Gitea owner (user or organisation) that owns the packs repository. */ +export const GITEA_OWNER = 'Pandipipas'; + +/** Name of the repository that contains all game packs. */ +export const GITEA_REPO = 'fighting-game-packs'; + +/** Branch to pull assets from. */ +export const GITEA_BRANCH = 'main'; + +/** + * NodeCG bundle name. + * Must match the "name" field in your package.json / nodecg config. + */ +export const BUNDLE_NAME = 'scoreko-dev'; + +// ── Derived URL helpers (do not edit below this line) ──────────────────────── + +/** Returns the Gitea raw-file URL for any repo-relative path. */ +export const getGiteaRawUrl = (repoPath: string): string => + `${GITEA_BASE_URL}/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${repoPath}`; + +/** URL of the master registry file that lists every available pack. */ +export const REGISTRY_URL = getGiteaRawUrl('registry.json'); + +/** Returns the URL for a specific pack's manifest.json. */ +export const getManifestUrl = (packId: string): string => + getGiteaRawUrl(`${packId}/manifest.json`); + +/** Returns the URL for a pack's logo. */ +export const getPackLogoUrl = (packId: string): string => + getGiteaRawUrl(`${packId}/logo.png`); + +/** + * Returns the URL for a specific character image stored in the Gitea repo. + * Used during download; at runtime installed packs are served by NodeCG. + */ +export const getCharacterImageRepoUrl = (packId: string, slug: string, ext: string): string => + getGiteaRawUrl(`${packId}/characters/${slug}.${ext}`); + +/** + * Returns the runtime URL for a character image from an *installed* (downloaded) pack. + * 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}`; diff --git a/src/shared/pack-types.js b/src/shared/pack-types.js new file mode 100644 index 0000000..ba55a92 --- /dev/null +++ b/src/shared/pack-types.js @@ -0,0 +1,6 @@ +// src/shared/pack-types.ts +// ───────────────────────────────────────────────────────────────────────────── +// Shared between the NodeCG extension (Node.js) and the dashboard (browser). +// Do NOT import anything that is browser-only or Node-only from this file. +// ───────────────────────────────────────────────────────────────────────────── +export {}; diff --git a/src/shared/pack-types.ts b/src/shared/pack-types.ts new file mode 100644 index 0000000..bd99112 --- /dev/null +++ b/src/shared/pack-types.ts @@ -0,0 +1,87 @@ +// src/shared/pack-types.ts +// ───────────────────────────────────────────────────────────────────────────── +// Shared between the NodeCG extension (Node.js) and the dashboard (browser). +// Do NOT import anything that is browser-only or Node-only from this file. +// ───────────────────────────────────────────────────────────────────────────── + +/** A single character entry inside a pack manifest. */ +export interface PackCharacter { + /** Display name, e.g. "Chun-Li" */ + name: string; + /** URL-safe slug that matches the image filename, e.g. "chun-li" */ + slug: string; + /** True when the character is paid DLC (shown with the DLC badge in the UI). */ + dlc?: boolean; + /** Approximate compressed size of the character image file in bytes. */ + sizeBytes: number; +} + +/** + * Lightweight entry in the top-level registry.json. + * Enough for the UI to render the game list and the download dialog preview + * without having to fetch the full manifest. + */ +export interface PackRegistryEntry { + /** Unique identifier — must match the folder name in the repo, e.g. "street-fighter-6". */ + id: string; + /** Human-readable game title shown in the selector, e.g. "Street Fighter 6". */ + name: string; + /** Semantic version of this pack, e.g. "1.0.0". Bump when adding/updating characters. */ + version: string; + /** Total download size (sum of all character images + logo) in bytes. */ + totalSizeBytes: number; + /** Repo-relative path to the game's logo image, e.g. "street-fighter-6/logo.png". */ + logoPath: string; + /** Pre-computed character count so the dialog can show it without loading the manifest. */ + characterCount: number; + /** Gradient used for placeholder images when a character has no artwork. */ + palette: { start: string; end: string }; + /** + * True when the pack ships inside the application bundle (bundled via Vite's + * import.meta.glob). Bundled packs are always "installed" and never show the + * download button, but they still appear in the registry so the app can detect + * updates (version mismatch between bundle and registry). + */ + bundled: boolean; +} + +/** Full pack data — lives at /manifest.json in the repo. */ +export interface PackManifest { + /** Must match PackRegistryEntry.id and the folder name. */ + id: string; + /** Must match PackRegistryEntry.name. */ + name: string; + version: string; + palette: { start: string; end: string }; + /** Default characters pre-selected when this game is first chosen. */ + defaultPair?: { left: string; right: string }; + /** Full character roster, in the order they should appear in the selector. */ + characters: PackCharacter[]; +} + +/** Top-level registry.json structure. */ +export interface PackRegistry { + schemaVersion: number; + updatedAt: string; + packs: PackRegistryEntry[]; +} + +/** Tracks the download lifecycle of a single pack. */ +export interface PackDownloadState { + status: 'idle' | 'fetching-manifest' | 'downloading' | 'done' | 'error'; + /** Progress percentage 0–100. */ + progress: number; + error?: string; +} + +/** Shape of the option objects surfaced by usePackRegistry.allGameOptions. */ +export interface GameSelectOption { + /** Display label for the QSelect. */ + label: string; + /** Value stored in the scoreboard (equals PackRegistryEntry.name for installed games). */ + value: string; + /** Whether the pack can be used right now (bundled or already downloaded). */ + available: boolean; + /** Mirrors PackRegistryEntry so the download dialog can be populated inline. */ + registryEntry: PackRegistryEntry; +}