Local Only
Action Asset Admin
Uploads write directly into public/action-assets. Next web or mobile build uses them automatically.
In-Game Preview
Dungeon Cards
Combat Preview
import { createReadStream, existsSync, statSync } from 'node:fs' import { mkdir, writeFile } from 'node:fs/promises' import { createServer } from 'node:http' import { extname, join, normalize } from 'node:path' import { fileURLToPath } from 'node:url' const __dirname = fileURLToPath(new URL('.', import.meta.url)) const rootDir = normalize(join(__dirname, '..')) const publicDir = join(rootDir, 'public') const assetDir = join(publicDir, 'action-assets') const host = process.env.HOST ?? '127.0.0.1' const port = Number(process.env.PORT ?? 4175) const assetSlots = [ { id: 'dungeon-bulldrome', category: 'Dungeon Icons', label: 'Bulldrome Hunting Grounds', path: 'dungeons/bulldrome.svg', preview: 'dungeon', }, { id: 'dungeon-yian-kut-ku', category: 'Dungeon Icons', label: 'Yian Kut-Ku Roost', path: 'dungeons/yian-kut-ku.svg', preview: 'dungeon', }, { id: 'dungeon-cyber-dragon', category: 'Dungeon Icons', label: 'Cyber Dragon Core', path: 'dungeons/cyber-dragon.svg', preview: 'dungeon', }, { id: 'enemy-cyber-dragon', category: 'Boss and Mob Sprites', label: 'Cyber Dragon', path: 'enemies/cyber-dragon.svg', preview: 'enemy', }, { id: 'enemy-bulldrome', category: 'Boss and Mob Sprites', label: 'Bulldrome', path: 'enemies/bulldrome.svg', preview: 'enemy', }, { id: 'enemy-bullfango', category: 'Boss and Mob Sprites', label: 'Bullfango', path: 'enemies/bullfango.svg', preview: 'enemy', }, { id: 'enemy-yian-kut-ku', category: 'Boss and Mob Sprites', label: 'Yian Kut-Ku', path: 'enemies/yian-kut-ku.svg', preview: 'enemy', }, { id: 'enemy-bird', category: 'Boss and Mob Sprites', label: 'Bird', path: 'enemies/bird.svg', preview: 'enemy', }, ] const mimeTypes = { '.css': 'text/css; charset=utf-8', '.html': 'text/html; charset=utf-8', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.png': 'image/png', '.svg': 'image/svg+xml; charset=utf-8', '.webp': 'image/webp', } const server = createServer(async (request, response) => { try { const url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`) if (request.method === 'GET' && url.pathname === '/') { sendHtml(response, adminHtml()) return } if (request.method === 'GET' && url.pathname === '/api/assets') { sendJson(response, 200, { slots: assetSlots.map(slotPayload) }) return } if (request.method === 'POST' && url.pathname.startsWith('/api/assets/')) { const slotId = decodeURIComponent(url.pathname.replace('/api/assets/', '')) const slot = assetSlots.find((item) => item.id === slotId) if (!slot) throw statusError(404, 'Unknown asset slot.') const body = await readJson(request, 16 * 1024 * 1024) const svg = normalizeUploadToSvg(body) const targetPath = join(assetDir, slot.path) await mkdir(join(targetPath, '..'), { recursive: true }) await writeFile(targetPath, svg) sendJson(response, 200, { slot: slotPayload(slot) }) return } if (request.method === 'GET' && url.pathname.startsWith('/action-assets/')) { await servePublicAsset(response, url.pathname) return } sendJson(response, 404, { error: 'not_found' }) } catch (error) { const status = Number(error?.status) || 500 if (status >= 500) console.error(error) sendJson(response, status, { error: error instanceof Error ? error.message : 'Unable to process request.', }) } }) server.listen(port, host, () => { console.log(`Action asset admin listening on http://${host}:${port}`) }) function slotPayload(slot) { const filePath = join(assetDir, slot.path) const stat = existsSync(filePath) ? statSync(filePath) : null return { ...slot, size: stat?.size ?? 0, updatedAt: stat?.mtime.toISOString() ?? null, url: `/action-assets/${slot.path}`, } } function normalizeUploadToSvg(body) { const dataUrl = String(body?.dataUrl ?? '') const width = clampDimension(Number(body?.width), 256) const height = clampDimension(Number(body?.height), 256) const originalName = escapeXml(String(body?.name ?? 'uploaded-image').slice(0, 120)) const match = dataUrl.match(/^data:(image\/(?:png|jpeg|webp|svg\+xml));base64,([A-Za-z0-9+/=]+)$/) if (!match) throw statusError(400, 'Upload must be PNG, JPG, WebP, or SVG.') const decodedBytes = Buffer.byteLength(match[2], 'base64') if (decodedBytes > 10 * 1024 * 1024) throw statusError(400, 'Upload must be 10 MB or smaller.') return [ `', '', ].join('\n') } function clampDimension(value, fallback) { if (!Number.isFinite(value) || value < 1) return fallback return Math.min(4096, Math.round(value)) } async function readJson(request, maxSize) { const chunks = [] let size = 0 for await (const chunk of request) { size += chunk.length if (size > maxSize) throw statusError(400, 'Request body is too large.') chunks.push(chunk) } return JSON.parse(Buffer.concat(chunks).toString('utf8')) } async function servePublicAsset(response, pathname) { const safePath = normalize(pathname).replace(/^(\.\.[/\\])+/, '') const filePath = join(publicDir, safePath) if (!filePath.startsWith(publicDir) || !existsSync(filePath) || !statSync(filePath).isFile()) { sendJson(response, 404, { error: 'not_found' }) return } response.writeHead(200, { 'Cache-Control': 'no-store', 'Content-Type': mimeTypes[extname(filePath)] ?? 'application/octet-stream', }) createReadStream(filePath).pipe(response) } function sendHtml(response, html) { response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) response.end(html) } function sendJson(response, status, payload) { response.writeHead(status, { 'Cache-Control': 'no-store', 'Content-Type': 'application/json; charset=utf-8', }) response.end(JSON.stringify(payload)) } function statusError(status, message) { const error = new Error(message) error.status = status return error } function escapeXml(value) { return value .replace(/&/g, '&') .replace(/"/g, '"') .replace(//g, '>') } function adminHtml() { return `
Local Only
Uploads write directly into public/action-assets. Next web or mobile build uses them automatically.
In-Game Preview
Combat Preview