From c1e9133970bdf60d477675cde0b472053b2da576 Mon Sep 17 00:00:00 2001 From: Pandipipas <62224708+Pandipipas@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:10:08 +0100 Subject: [PATCH 1/3] Make downloaded game assets persistent and offline-friendly --- .../scoreko-dev/stores/game-assets.ts | 9 ++- src/extension/game-assets.ts | 59 ++++++++++++++++++- 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/src/dashboard/scoreko-dev/stores/game-assets.ts b/src/dashboard/scoreko-dev/stores/game-assets.ts index e801877..ef3968e 100644 --- a/src/dashboard/scoreko-dev/stores/game-assets.ts +++ b/src/dashboard/scoreko-dev/stores/game-assets.ts @@ -70,8 +70,13 @@ export const useGameAssetsStore = defineStore('game-assets', () => { }; const refreshInstalledGames = async () => { - const availableResponse = await sendNodecgMessage('scoreko-assets:listRemoteGames'); - availableGames.value = Array.isArray(availableResponse) ? availableResponse : []; + try { + const availableResponse = await sendNodecgMessage('scoreko-assets:listRemoteGames'); + availableGames.value = Array.isArray(availableResponse) ? availableResponse : []; + } catch { + availableGames.value = []; + } + const response = await sendNodecgMessage('scoreko-assets:listInstalled'); installedGames.value = Array.isArray(response) ? response : []; const configResponse = await sendNodecgMessage<{ assetsBaseUrl?: string }>('scoreko-assets:getAssetsBaseUrl'); diff --git a/src/extension/game-assets.ts b/src/extension/game-assets.ts index ced286a..950b1c9 100644 --- a/src/extension/game-assets.ts +++ b/src/extension/game-assets.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { nodecg } from './util/nodecg.js'; @@ -35,7 +35,36 @@ type HttpGameTitlesFile = Record | HttpGameTitleEntry[]; const extensionDir = path.dirname(fileURLToPath(import.meta.url)); const bundleRoot = path.resolve(extensionDir, '..'); -const assetsRoot = path.join(bundleRoot, 'game-assets'); +const legacyAssetsRoot = path.join(bundleRoot, 'game-assets'); +const nodecgRoot = path.resolve(bundleRoot, '..', '..'); +const assetsRoot = path.join(nodecgRoot, 'db', `${nodecg.bundleName}-game-assets`); + +let assetsStorageReady = false; + +const ensureAssetsStorageReady = async () => { + if (assetsStorageReady) { + return; + } + + await mkdir(path.dirname(assetsRoot), { recursive: true }); + + const [currentStats, legacyStats] = await Promise.all([ + stat(assetsRoot).catch(() => null), + stat(legacyAssetsRoot).catch(() => null), + ]); + + if (!currentStats && legacyStats?.isDirectory()) { + await rename(legacyAssetsRoot, assetsRoot).catch(async () => { + await mkdir(assetsRoot, { recursive: true }); + }); + } else { + await mkdir(assetsRoot, { recursive: true }); + } + + assetsStorageReady = true; +}; + +void ensureAssetsStorageReady(); const assetsRouter = nodecg.Router(); @@ -240,10 +269,21 @@ const listHttpFiles = async (game: RemoteGame): Promise => { }; const listInstalledGames = async () => { + await ensureAssetsStorageReady(); const entries = await readdir(assetsRoot, { withFileTypes: true }).catch(() => []); return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort((left, right) => left.localeCompare(right)); }; +const listInstalledGamesAsRemote = async (): Promise => { + const installedGames = await listInstalledGames(); + return installedGames.map((slug) => ({ + slug, + repoFolder: slug, + title: titleFromSlug(slug), + logoFile: `${slug}.png`, + })); +}; + const parseCharacterNames = (content: string, gameTitle: string) => { const parsed = JSON.parse(content) as unknown; const names = Array.isArray(parsed) @@ -277,6 +317,7 @@ const listInstalledCharacterNamesByGame = async () => { }; const downloadGameAssets = async (gameSlug: string) => { + await ensureAssetsStorageReady(); const customTitles = await fetchCustomGameTitles(); const game: RemoteGame = { slug: gameSlug, @@ -330,6 +371,7 @@ const downloadGameAssets = async (gameSlug: string) => { }; const removeGameAssets = async (gameSlug: string) => { + await ensureAssetsStorageReady(); const customTitles = await fetchCustomGameTitles(); const destinationFolder = path.join(assetsRoot, gameSlug); await rm(destinationFolder, { recursive: true, force: true }); @@ -346,8 +388,19 @@ nodecg.listenFor('scoreko-assets:listRemoteGames', async (_payload: unknown, ack } try { - ack(null, await listRemoteGames()); + const remoteGames = await listRemoteGames(); + ack(null, remoteGames); } catch (error) { + try { + const installedGames = await listInstalledGamesAsRemote(); + if (installedGames.length > 0) { + ack(null, installedGames); + return; + } + } catch { + // noop + } + ack((error as Error).message); } }); From 752232eeca0ab5dc094a95b67682e658fea079cc Mon Sep 17 00:00:00 2001 From: Pandipipas <62224708+Pandipipas@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:45:48 +0100 Subject: [PATCH 2/3] Store persistent assets in bundle-local db directory --- src/extension/game-assets.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/extension/game-assets.ts b/src/extension/game-assets.ts index 950b1c9..f279ed4 100644 --- a/src/extension/game-assets.ts +++ b/src/extension/game-assets.ts @@ -36,8 +36,7 @@ type HttpGameTitlesFile = Record | HttpGameTitleEntry[]; const extensionDir = path.dirname(fileURLToPath(import.meta.url)); const bundleRoot = path.resolve(extensionDir, '..'); const legacyAssetsRoot = path.join(bundleRoot, 'game-assets'); -const nodecgRoot = path.resolve(bundleRoot, '..', '..'); -const assetsRoot = path.join(nodecgRoot, 'db', `${nodecg.bundleName}-game-assets`); +const assetsRoot = path.join(bundleRoot, 'db', `${nodecg.bundleName}-game-assets`); let assetsStorageReady = false; From 4db5c89f0a3b76b8f57b5a650578cd942415b452 Mon Sep 17 00:00:00 2001 From: Pandipipas <62224708+Pandipipas@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:52:16 +0100 Subject: [PATCH 3/3] Cache game titles for offline selector labels --- src/extension/game-assets.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/extension/game-assets.ts b/src/extension/game-assets.ts index f279ed4..32de4ba 100644 --- a/src/extension/game-assets.ts +++ b/src/extension/game-assets.ts @@ -6,6 +6,7 @@ import { nodecg } from './util/nodecg.js'; const CHARACTER_NAMES_FILE = 'fighting-characters.json'; const LOCAL_MANIFEST_FILE = 'manifest.json'; const GAME_TITLES_FILE = 'games.json'; +const CACHED_GAME_TITLES_FILE = 'games-cache.json'; type RemoteGame = { title: string; @@ -211,6 +212,26 @@ const fetchCustomGameTitles = async (): Promise> => { } }; +const loadCachedGameTitles = async (): Promise> => { + await ensureAssetsStorageReady(); + const cachePath = path.join(assetsRoot, CACHED_GAME_TITLES_FILE); + + try { + const raw = await readFile(cachePath, 'utf8'); + const parsed = JSON.parse(raw) as unknown; + return parseGameTitlesMap(parsed); + } catch { + return new Map(); + } +}; + +const saveCachedGameTitles = async (titles: Map) => { + await ensureAssetsStorageReady(); + const cachePath = path.join(assetsRoot, CACHED_GAME_TITLES_FILE); + const payload = Object.fromEntries([...titles.entries()].sort((left, right) => left[0].localeCompare(right[0]))); + await writeFile(cachePath, JSON.stringify(payload, null, 2)); +}; + const listRemoteGames = async (): Promise => { const baseUrl = getConfiguredAssetsBaseUrl(); const gamesIndexUrl = `${baseUrl}/games/`; @@ -275,10 +296,11 @@ const listInstalledGames = async () => { const listInstalledGamesAsRemote = async (): Promise => { const installedGames = await listInstalledGames(); + const cachedTitles = await loadCachedGameTitles(); return installedGames.map((slug) => ({ slug, repoFolder: slug, - title: titleFromSlug(slug), + title: cachedTitles.get(slug) ?? titleFromSlug(slug), logoFile: `${slug}.png`, })); }; @@ -388,6 +410,11 @@ nodecg.listenFor('scoreko-assets:listRemoteGames', async (_payload: unknown, ack try { const remoteGames = await listRemoteGames(); + const titlesToCache = new Map(); + remoteGames.forEach((game) => { + titlesToCache.set(game.slug, game.title); + }); + await saveCachedGameTitles(titlesToCache); ack(null, remoteGames); } catch (error) { try {