From 5f98f18151dc517f38f5d20b50db1ac9ae7842c06507fc822429ca8d4376aa7f Mon Sep 17 00:00:00 2001 From: JohannesBOT Date: Fri, 29 May 2026 19:17:13 +0200 Subject: [PATCH] ... --- src/lib/iconRegistry.ts | 238 ++++++++++++++++++++++++++++++++++++++++ src/routes/icon.ts | 55 ++++++++++ test/update-loop.ts | 68 ++++++++++++ 3 files changed, 361 insertions(+) create mode 100644 src/lib/iconRegistry.ts create mode 100644 src/routes/icon.ts create mode 100644 test/update-loop.ts diff --git a/src/lib/iconRegistry.ts b/src/lib/iconRegistry.ts new file mode 100644 index 0000000..442a81a --- /dev/null +++ b/src/lib/iconRegistry.ts @@ -0,0 +1,238 @@ +import AdmZip from 'adm-zip'; +import { existsSync } from 'node:fs'; +import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { config } from '../config.js'; + +interface JarStamp { + mtime: number; + size: number; +} + +interface Manifest { + version: number; + jars: Record; + icons: string[]; +} + +const MANIFEST_VERSION = 1; +// Matches assets//textures/(item|block)/.png, where may +// contain subdirectories. Only the basename is used to build the icon id. +const TEXTURE_PATTERN = /^assets\/([a-z0-9_\-]+)\/textures\/(item|block)\/(.+)\.png$/i; + +let icons = new Set(); +let loaded = false; + +/* ------------------------------------------------------------------ */ +/* Manifest */ +/* ------------------------------------------------------------------ */ + +function manifestPath(): string { + return join(resolve(config.iconCacheDir), 'manifest.json'); +} + +async function readManifest(): Promise { + try { + const raw = await readFile(manifestPath(), 'utf8'); + const parsed = JSON.parse(raw) as Manifest; + if (parsed.version !== MANIFEST_VERSION) return null; + return parsed; + } catch { + return null; + } +} + +async function writeManifest(manifest: Manifest): Promise { + await mkdir(dirname(manifestPath()), { recursive: true }); + await writeFile(manifestPath(), JSON.stringify(manifest, null, 2), 'utf8'); +} + +/* ------------------------------------------------------------------ */ +/* Jar discovery */ +/* ------------------------------------------------------------------ */ + +async function statJar(path: string): Promise<{ path: string; stamp: JarStamp } | null> { + try { + const st = await stat(path); + if (!st.isFile()) return null; + return { path, stamp: { mtime: st.mtimeMs, size: st.size } }; + } catch { + return null; + } +} + +async function listJars(): Promise<{ path: string; stamp: JarStamp }[]> { + const result: { path: string; stamp: JarStamp }[] = []; + + const dir = resolve(config.modsDir); + if (existsSync(dir)) { + const entries = await readdir(dir); + for (const name of entries) { + if (!name.toLowerCase().endsWith('.jar')) continue; + const jar = await statJar(join(dir, name)); + if (jar) result.push(jar); + } + console.log(`[icons] Scanning ${dir} — found ${result.length} .jar file(s)`); + } else { + console.warn(`[icons] MODS_DIR does not exist: ${dir}`); + } + + if (config.vanillaJar) { + const vanillaPath = resolve(config.vanillaJar); + const jar = await statJar(vanillaPath); + if (jar) { + result.push(jar); + console.log(`[icons] Including vanilla jar: ${vanillaPath}`); + } else { + console.warn(`[icons] VANILLA_JAR not readable: ${vanillaPath}`); + } + } + + result.sort((a, b) => a.path.localeCompare(b.path)); + return result; +} + +function jarsMatchManifest( + jars: { path: string; stamp: JarStamp }[], + manifest: Manifest, +): boolean { + const expectedKeys = Object.keys(manifest.jars); + if (expectedKeys.length !== jars.length) return false; + for (const j of jars) { + const e = manifest.jars[j.path]; + if (!e || e.mtime !== j.stamp.mtime || e.size !== j.stamp.size) return false; + } + return true; +} + +/* ------------------------------------------------------------------ */ +/* Extraction */ +/* ------------------------------------------------------------------ */ + +function openJar(path: string): AdmZip | null { + try { + return new AdmZip(path); + } catch { + return null; + } +} + +async function writeIcon( + cacheDir: string, + mod: string, + basename: string, + data: Buffer, +): Promise { + const target = join(cacheDir, mod, `${basename}.png`); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, data); +} + +async function extractAll(jars: { path: string; stamp: JarStamp }[]): Promise> { + const cacheDir = resolve(config.iconCacheDir); + console.log(`[icons] Extracting textures into ${cacheDir}`); + await rm(cacheDir, { recursive: true, force: true }); + await mkdir(cacheDir, { recursive: true }); + + const result = new Set(); + const startedAt = Date.now(); + + // Two passes so that an `item` texture from any jar always wins over a + // `block` texture sharing the same basename — players see things as items. + for (const kindPass of ['item', 'block'] as const) { + let processed = 0; + for (const jar of jars) { + const zip = openJar(jar.path); + processed++; + if (!zip) { + console.warn(`[icons] skipped (unreadable): ${jar.path}`); + continue; + } + if (kindPass === 'item' && processed % 25 === 0) { + console.log(`[icons] ${processed}/${jars.length} jars scanned…`); + } + for (const entry of zip.getEntries()) { + if (entry.isDirectory) continue; + const m = TEXTURE_PATTERN.exec(entry.entryName); + if (!m) continue; + const modRaw = m[1]; + const kindRaw = m[2]; + const restRaw = m[3]; + if (!modRaw || !kindRaw || !restRaw) continue; + if (kindRaw.toLowerCase() !== kindPass) continue; + + const mod = modRaw.toLowerCase(); + const segments = restRaw.split('/'); + const last = segments[segments.length - 1]; + if (!last) continue; + const basename = last.toLowerCase(); + const id = `${mod}:${basename}`; + if (result.has(id)) continue; + + try { + await writeIcon(cacheDir, mod, basename, entry.getData()); + result.add(id); + } catch { + // skip unreadable entry + } + } + } + } + + console.log( + `[icons] Extracted ${result.size} unique icons from ${jars.length} jar(s) ` + + `in ${((Date.now() - startedAt) / 1000).toFixed(1)}s`, + ); + return result; +} + +/* ------------------------------------------------------------------ */ +/* Public API */ +/* ------------------------------------------------------------------ */ + +export async function loadIcons(): Promise { + if (loaded) return; + + const jars = await listJars(); + const manifest = await readManifest(); + + if (manifest && jarsMatchManifest(jars, manifest)) { + icons = new Set(manifest.icons); + console.log(`[icons] Cache hit — reusing ${icons.size} icons from previous run`); + loaded = true; + return; + } + + if (manifest) { + console.log('[icons] Cache miss — jar set or mtimes changed, re-extracting'); + } else { + console.log('[icons] No cache manifest found — extracting from scratch'); + } + + icons = await extractAll(jars); + await writeManifest({ + version: MANIFEST_VERSION, + jars: Object.fromEntries(jars.map((j) => [j.path, j.stamp])), + icons: [...icons].sort(), + }); + loaded = true; +} + +export function iconUrl(name: string): string | null { + const id = name.toLowerCase(); + if (!icons.has(id)) return null; + const sep = id.indexOf(':'); + if (sep <= 0) return null; + const mod = id.slice(0, sep); + const item = id.slice(sep + 1); + const base = config.publicUrl.replace(/\/+$/, ''); + return `${base}/api/icon/${encodeURIComponent(mod)}/${encodeURIComponent(item)}.png`; +} + +export function iconFilePath(mod: string, item: string): string { + return join(resolve(config.iconCacheDir), mod, `${item}.png`); +} + +export function iconCount(): number { + return icons.size; +} diff --git a/src/routes/icon.ts b/src/routes/icon.ts new file mode 100644 index 0000000..15acaf4 --- /dev/null +++ b/src/routes/icon.ts @@ -0,0 +1,55 @@ +import type { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox'; +import { Type } from '@sinclair/typebox'; +import { createReadStream } from 'node:fs'; +import { stat } from 'node:fs/promises'; +import { iconFilePath } from '../lib/iconRegistry.js'; + +const SAFE_SEGMENT = /^[a-z0-9_\-]+$/; + +export const iconRoutes: FastifyPluginAsyncTypebox = async (app) => { + app.get( + '/api/icon/:mod/:filename', + { + schema: { + tags: ['me-system'], + summary: 'Returns the cached PNG icon for a single item.', + description: + 'The filename must be `.png`. Returns 404 when no texture was ' + + 'extracted for that `:` combination.', + params: Type.Object({ + mod: Type.String(), + filename: Type.String(), + }), + }, + }, + async (request, reply) => { + const { mod, filename } = request.params; + + const lowerFilename = filename.toLowerCase(); + if (!lowerFilename.endsWith('.png')) { + return reply.code(404).send({ error: 'Not found' }); + } + const itemLower = lowerFilename.slice(0, -4); + const modLower = mod.toLowerCase(); + + if (!SAFE_SEGMENT.test(modLower) || !SAFE_SEGMENT.test(itemLower)) { + return reply.code(404).send({ error: 'Not found' }); + } + + const filePath = iconFilePath(modLower, itemLower); + try { + const st = await stat(filePath); + if (!st.isFile()) { + return reply.code(404).send({ error: 'Not found' }); + } + return reply + .header('content-type', 'image/png') + .header('cache-control', 'public, max-age=86400, immutable') + .header('content-length', st.size) + .send(createReadStream(filePath)); + } catch { + return reply.code(404).send({ error: 'Not found' }); + } + }, + ); +}; diff --git a/test/update-loop.ts b/test/update-loop.ts new file mode 100644 index 0000000..911179d --- /dev/null +++ b/test/update-loop.ts @@ -0,0 +1,68 @@ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +type UpdateItem = { + name: string; + amount: number; + displayName?: string | null; +}; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const TARGET_URL = process.env.UPDATE_URL ?? 'https://localhost:3000/api/me-system/update'; +const INTERVAL_MS = Number(process.env.INTERVAL_MS ?? 10_000); +const JSON_PATH = process.env.JSON_PATH ?? resolve(__dirname, 'me_system_update.json'); +const MAX_VARIATION = Number(process.env.MAX_VARIATION ?? 0.05); + +if (TARGET_URL.startsWith('https://')) { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; +} + +function mutate(items: UpdateItem[]): UpdateItem[] { + return items.map((item) => { + const delta = (Math.random() * 2 - 1) * MAX_VARIATION; + const next = Math.max(0, Math.round(item.amount * (1 + delta))); + return { ...item, amount: next }; + }); +} + +async function sendUpdate(items: UpdateItem[]): Promise { + const startedAt = Date.now(); + try { + const res = await fetch(TARGET_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(items), + }); + const took = Date.now() - startedAt; + const text = await res.text(); + console.log( + `[${new Date().toISOString()}] POST ${res.status} (${took} ms, ${items.length} items) -> ${text}`, + ); + } catch (err) { + console.error(`[${new Date().toISOString()}] request failed:`, err); + } +} + +async function main(): Promise { + const raw = await readFile(JSON_PATH, 'utf8'); + let current: UpdateItem[] = JSON.parse(raw); + + console.log( + `Loaded ${current.length} items from ${JSON_PATH}\n` + + `Posting to ${TARGET_URL} every ${INTERVAL_MS} ms (max ±${MAX_VARIATION * 100}% per tick).`, + ); + + await sendUpdate(current); + + setInterval(() => { + current = mutate(current); + void sendUpdate(current); + }, INTERVAL_MS); +} + +main().catch((err) => { + console.error('Fatal:', err); + process.exit(1); +});