import http from 'node:http' import { spawn } from 'node:child_process' import { fileURLToPath } from 'node:url' import { dirname, resolve } from 'node:path' const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) const ROOT = resolve(SCRIPT_DIR, '..') const SIM_SCRIPT = resolve(SCRIPT_DIR, 'iwt2-pvp-roguelike-boss-sim.mjs') const PORT = Number.parseInt(process.env.IWT2_SIM_GUI_PORT ?? '8787', 10) const HEALER_CLASSES = ['dawnweaver', 'lifebinder', 'runesage'] const BOSS_IDS = [ 'bulldrome', 'yian-kut-ku', 'great-jaggi', 'khezu', 'rathian', 'barroth', 'tobi-kadachi', 'rimebastion', 'ember-mantis-duelist', 'cinderback-ricochet', 'obsidian-ram-golem', 'stormcoil-wyrm', 'venom-orchid-hydra', 'sandglass-scorpion', 'crystal-bat-matriarch', 'hollowcrown-revenant', ] let activeRun = null const server = http.createServer(async (request, response) => { const url = new URL(request.url ?? '/', `http://${request.headers.host ?? `localhost:${PORT}`}`) if (request.method === 'GET' && url.pathname === '/') { sendHtml(response) return } if (request.method === 'GET' && url.pathname === '/config') { sendJson(response, { bosses: BOSS_IDS, classes: HEALER_CLASSES }) return } if (request.method === 'POST' && url.pathname === '/run') { const body = await readJson(request) startRun(body, response) return } if (request.method === 'POST' && url.pathname === '/stop') { stopRun() sendJson(response, { ok: true }) return } response.writeHead(404) response.end('not found') }) server.on('error', (error) => { if (error.code === 'EADDRINUSE') { console.error(`Port ${PORT} is already in use. Try: IWT2_SIM_GUI_PORT=${PORT + 1} node scripts/iwt2-pvp-sim-gui-server.mjs`) process.exit(1) } throw error }) server.listen(PORT, () => { console.log(`IWT2 PvP sim GUI: http://localhost:${PORT}`) }) function startRun(options, response) { if (activeRun) { response.writeHead(409, { 'content-type': 'application/json' }) response.end(JSON.stringify({ error: 'A simulation is already running.' })) return } const command = [ process.execPath, SIM_SCRIPT, '--classes', listOrAll(options.classes, HEALER_CLASSES), '--gear-level', scalar(options.gearLevel, '5'), '--boss-count', scalar(options.bossCount, '2'), '--bosses', listOrAll(options.bosses, BOSS_IDS), '--boss-hp-percent', scalar(options.bossHpPercent, '100'), '--boss-damage-percent', scalar(options.bossDamagePercent, '100'), '--stages', scalar(options.stages, '1,2'), '--seconds', scalar(options.seconds, '180'), '--workers', scalar(options.workers, '8'), '--repeats', scalar(options.repeats, '1'), '--required-bosses', listOrNone(options.requiredBosses, BOSS_IDS), '--top', scalar(options.top, '24'), ] response.writeHead(200, { 'cache-control': 'no-cache', 'connection': 'keep-alive', 'content-type': 'text/event-stream', }) writeEvent(response, 'status', { command: command.join(' '), status: 'started' }) const child = spawn(command[0], command.slice(1), { cwd: ROOT, env: { ...process.env, VITE_CJS_IGNORE_WARNING: 'true' }, stdio: ['ignore', 'pipe', 'pipe'], }) activeRun = child let output = '' child.stdout.on('data', (chunk) => { const text = chunk.toString() output += text writeEvent(response, 'chunk', { text }) }) child.stderr.on('data', (chunk) => { const text = chunk.toString() output += text writeEvent(response, 'chunk', { text }) }) child.on('close', (code) => { activeRun = null writeEvent(response, 'done', { code, json: extractJson(output), }) response.end() }) child.on('error', (error) => { activeRun = null writeEvent(response, 'error', { error: String(error) }) response.end() }) } function stopRun() { if (!activeRun) return activeRun.kill('SIGTERM') } function sendHtml(response) { response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) response.end(` IWT2 PvP Sim GUI
Idle
Run a simulation to see dashboard results.
Raw output

      
`) } function readJson(request) { return new Promise((resolveRequest, rejectRequest) => { let body = '' request.on('data', (chunk) => { body += chunk.toString() }) request.on('end', () => { try { resolveRequest(body ? JSON.parse(body) : {}) } catch (error) { rejectRequest(error) } }) request.on('error', rejectRequest) }) } function sendJson(response, value) { response.writeHead(200, { 'content-type': 'application/json' }) response.end(JSON.stringify(value)) } function writeEvent(response, event, data) { response.write(`event: ${event}\n`) response.write(`data: ${JSON.stringify(data)}\n\n`) } function listOrAll(value, allowed) { if (!Array.isArray(value) || value.length === 0 || value.includes('all')) return 'all' const selected = value.filter((item) => allowed.includes(item)) return selected.length ? selected.join(',') : 'all' } function listOrNone(value, allowed) { if (!Array.isArray(value) || value.length === 0 || value.includes('none')) return 'none' const selected = value.filter((item) => allowed.includes(item)) return selected.length ? selected.join(',') : 'none' } function scalar(value, fallback) { return value === undefined || value === null || value === '' ? fallback : String(value) } function extractJson(output) { const start = output.indexOf('{') const end = output.lastIndexOf('}') if (start < 0 || end < start) return '' const candidate = output.slice(start, end + 1) try { return JSON.stringify(JSON.parse(candidate), null, 2) } catch { return '' } }