...
This commit is contained in:
@@ -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<string, JarStamp>;
|
||||||
|
icons: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MANIFEST_VERSION = 1;
|
||||||
|
// Matches assets/<modid>/textures/(item|block)/<path>.png, where <path> 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<string>();
|
||||||
|
let loaded = false;
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Manifest */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function manifestPath(): string {
|
||||||
|
return join(resolve(config.iconCacheDir), 'manifest.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readManifest(): Promise<Manifest | null> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<Set<string>> {
|
||||||
|
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<string>();
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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 `<item>.png`. Returns 404 when no texture was ' +
|
||||||
|
'extracted for that `<mod>:<item>` 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' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user