568 lines
23 KiB
JavaScript
568 lines
23 KiB
JavaScript
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(`<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>IWT2 PvP Sim GUI</title>
|
|
<style>
|
|
:root { color-scheme: dark; font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #111419; color: #eef2f6; }
|
|
body { margin: 0; min-height: 100vh; background: #111419; }
|
|
main { display: grid; grid-template-columns: 340px minmax(0, 1fr); min-height: 100vh; }
|
|
aside { border-right: 1px solid #2a3039; padding: 18px; background: #171b22; overflow: auto; }
|
|
section { padding: 18px; min-width: 0; display: flex; flex-direction: column; gap: 12px; }
|
|
h1 { font-size: 18px; margin: 0 0 16px; }
|
|
h2 { font-size: 13px; margin: 18px 0 8px; color: #b7c2d0; text-transform: uppercase; letter-spacing: .05em; }
|
|
label { display: flex; align-items: center; gap: 8px; margin: 7px 0; font-size: 13px; }
|
|
input[type="text"], input[type="number"] { width: 100%; box-sizing: border-box; background: #0f1217; color: #eef2f6; border: 1px solid #333b47; border-radius: 6px; padding: 8px 9px; }
|
|
input[type="checkbox"] { accent-color: #61dafb; }
|
|
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
|
.bosses { max-height: 300px; overflow: auto; border: 1px solid #2a3039; border-radius: 6px; padding: 8px; background: #111419; }
|
|
.buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 16px; }
|
|
button { border: 1px solid #3a4656; color: #eef2f6; background: #202733; border-radius: 6px; padding: 9px 10px; cursor: pointer; }
|
|
button.primary { background: #245a7a; border-color: #327aa3; }
|
|
button:disabled { opacity: .55; cursor: not-allowed; }
|
|
pre { margin: 0; padding: 12px; overflow: auto; background: #0b0e12; border: 1px solid #2a3039; border-radius: 6px; min-height: 180px; white-space: pre; }
|
|
#dashboard { display: grid; gap: 12px; }
|
|
.empty { border: 1px dashed #3a4656; border-radius: 6px; color: #96a3b5; padding: 22px; text-align: center; }
|
|
.cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
|
|
.metric { background: #171b22; border: 1px solid #2a3039; border-radius: 6px; padding: 12px; }
|
|
.metric span { color: #96a3b5; display: block; font-size: 12px; margin-bottom: 6px; }
|
|
.metric strong { display: block; font-size: 24px; }
|
|
.panel { background: #171b22; border: 1px solid #2a3039; border-radius: 6px; overflow: hidden; }
|
|
.panel h2 { margin: 0; padding: 12px; border-bottom: 1px solid #2a3039; }
|
|
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
th, td { border-bottom: 1px solid #242a33; padding: 9px 10px; text-align: left; vertical-align: top; }
|
|
th { background: #111419; color: #b7c2d0; font-weight: 600; position: sticky; top: 0; }
|
|
tr:last-child td { border-bottom: 0; }
|
|
.number { text-align: right; font-variant-numeric: tabular-nums; }
|
|
.pill { display: inline-block; border: 1px solid #3a4656; border-radius: 999px; color: #d9e3ee; background: #202733; padding: 2px 7px; margin: 0 4px 4px 0; font-size: 12px; }
|
|
.risk-high { color: #ff8d7a; }
|
|
.risk-mid { color: #ffd166; }
|
|
.risk-low { color: #7fe3a1; }
|
|
.bars { display: grid; gap: 9px; padding: 12px; }
|
|
.bar-row { display: grid; grid-template-columns: minmax(170px, 260px) 1fr 90px; gap: 10px; align-items: center; font-size: 13px; }
|
|
.bar-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #d9e3ee; }
|
|
.bar-track { height: 12px; background: #0f1217; border: 1px solid #2a3039; border-radius: 999px; overflow: hidden; }
|
|
.bar-fill { height: 100%; background: linear-gradient(90deg, #61dafb, #7fe3a1); border-radius: inherit; }
|
|
.bar-value { color: #b7c2d0; font-variant-numeric: tabular-nums; text-align: right; }
|
|
details { border: 1px solid #2a3039; border-radius: 6px; overflow: hidden; }
|
|
summary { cursor: pointer; padding: 10px 12px; background: #171b22; color: #b7c2d0; }
|
|
#raw { min-height: 220px; border-radius: 0; border-left: 0; border-right: 0; border-bottom: 0; }
|
|
.status { color: #b7c2d0; font-size: 13px; }
|
|
@media (max-width: 1100px) { .cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
|
@media (max-width: 820px) { main { grid-template-columns: 1fr; } aside { border-right: 0; border-bottom: 1px solid #2a3039; } .cards { grid-template-columns: 1fr; } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<aside>
|
|
<h1>IWT2 PvP Sim</h1>
|
|
<h2>Scalars</h2>
|
|
<div class="grid">
|
|
<label>Gear <input id="gearLevel" type="number" value="5" min="0"></label>
|
|
<label>Boss count <input id="bossCount" type="number" value="2" min="1"></label>
|
|
<label>Boss HP % <input id="bossHpPercent" type="number" value="100" min="1"></label>
|
|
<label>Boss dmg % <input id="bossDamagePercent" type="number" value="100" min="1"></label>
|
|
<label>Stages <input id="stages" type="text" value="1,2"></label>
|
|
<label>Seconds <input id="seconds" type="number" value="180" min="1"></label>
|
|
<label>Workers <input id="workers" type="number" value="8" min="1"></label>
|
|
<label>Repeats <input id="repeats" type="number" value="1" min="1"></label>
|
|
<label>Top <input id="top" type="number" value="24" min="1"></label>
|
|
</div>
|
|
|
|
<h2>Classes</h2>
|
|
<label><input id="allClasses" type="checkbox" checked> All classes</label>
|
|
<div id="classes"></div>
|
|
|
|
<h2>Bosses</h2>
|
|
<label><input id="allBosses" type="checkbox" checked> All bosses</label>
|
|
<div id="bosses" class="bosses"></div>
|
|
|
|
<h2>Required Bosses</h2>
|
|
<div id="requiredBosses" class="bosses"></div>
|
|
|
|
<div class="buttons">
|
|
<button id="run" class="primary">Run</button>
|
|
<button id="stop" disabled>Stop</button>
|
|
<button id="copy">Copy Command</button>
|
|
<button id="save">Save JSON</button>
|
|
</div>
|
|
</aside>
|
|
<section>
|
|
<div id="status" class="status">Idle</div>
|
|
<div id="dashboard"><div class="empty">Run a simulation to see dashboard results.</div></div>
|
|
<details open>
|
|
<summary>Raw output</summary>
|
|
<pre id="raw"></pre>
|
|
</details>
|
|
</section>
|
|
</main>
|
|
<script>
|
|
const state = { lastJson: null, running: false, config: null };
|
|
const ids = ["gearLevel","bossCount","bossHpPercent","bossDamagePercent","stages","seconds","workers","repeats","top"];
|
|
const el = Object.fromEntries(ids.map(id => [id, document.getElementById(id)]));
|
|
const status = document.getElementById("status");
|
|
const raw = document.getElementById("raw");
|
|
const dashboard = document.getElementById("dashboard");
|
|
const run = document.getElementById("run");
|
|
const stop = document.getElementById("stop");
|
|
|
|
fetch("/config").then(r => r.json()).then(config => {
|
|
state.config = config;
|
|
renderChecks("classes", config.classes, true);
|
|
renderChecks("bosses", config.bosses, true);
|
|
renderChecks("requiredBosses", config.bosses, false);
|
|
wireAll("allClasses", "classes");
|
|
wireAll("allBosses", "bosses");
|
|
});
|
|
|
|
function renderChecks(containerId, values, checked) {
|
|
const container = document.getElementById(containerId);
|
|
container.innerHTML = values.map(value => '<label><input type="checkbox" value="' + value + '"' + (checked ? ' checked' : '') + '> ' + value + '</label>').join("");
|
|
}
|
|
|
|
function wireAll(masterId, containerId) {
|
|
const master = document.getElementById(masterId);
|
|
const container = document.getElementById(containerId);
|
|
master.addEventListener("change", () => {
|
|
container.querySelectorAll("input").forEach(input => input.checked = master.checked);
|
|
});
|
|
container.addEventListener("change", () => {
|
|
master.checked = [...container.querySelectorAll("input")].every(input => input.checked);
|
|
});
|
|
}
|
|
|
|
function selected(containerId, masterId) {
|
|
if (document.getElementById(masterId).checked) return ["all"];
|
|
const values = [...document.getElementById(containerId).querySelectorAll("input:checked")].map(input => input.value);
|
|
return values.length ? values : ["all"];
|
|
}
|
|
|
|
function selectedRequiredBosses() {
|
|
const values = [...document.getElementById("requiredBosses").querySelectorAll("input:checked")].map(input => input.value);
|
|
return values.length ? values : ["none"];
|
|
}
|
|
|
|
function payload() {
|
|
const requiredBosses = selectedRequiredBosses();
|
|
let bosses = selected("bosses", "allBosses");
|
|
if (!bosses.includes("all") && !requiredBosses.includes("none")) {
|
|
bosses = [...new Set([...bosses, ...requiredBosses])];
|
|
}
|
|
return {
|
|
bossCount: el.bossCount.value,
|
|
bossDamagePercent: el.bossDamagePercent.value,
|
|
bossHpPercent: el.bossHpPercent.value,
|
|
bosses,
|
|
classes: selected("classes", "allClasses"),
|
|
gearLevel: el.gearLevel.value,
|
|
repeats: el.repeats.value,
|
|
requiredBosses,
|
|
seconds: el.seconds.value,
|
|
stages: el.stages.value,
|
|
top: el.top.value,
|
|
workers: el.workers.value,
|
|
};
|
|
}
|
|
|
|
function commandFor(options) {
|
|
return ["node", "scripts/iwt2-pvp-roguelike-boss-sim.mjs",
|
|
"--classes", options.classes.join(","),
|
|
"--gear-level", options.gearLevel,
|
|
"--boss-count", options.bossCount,
|
|
"--bosses", options.bosses.join(","),
|
|
"--boss-hp-percent", options.bossHpPercent,
|
|
"--boss-damage-percent", options.bossDamagePercent,
|
|
"--stages", options.stages,
|
|
"--seconds", options.seconds,
|
|
"--workers", options.workers,
|
|
"--repeats", options.repeats,
|
|
"--required-bosses", options.requiredBosses.join(","),
|
|
"--top", options.top,
|
|
].join(" ");
|
|
}
|
|
|
|
run.addEventListener("click", async () => {
|
|
const options = payload();
|
|
raw.textContent = "$ " + commandFor(options) + "\\n\\n";
|
|
dashboard.innerHTML = '<div class="empty">Simulation running...</div>';
|
|
state.lastJson = null;
|
|
state.running = true;
|
|
run.disabled = true;
|
|
stop.disabled = false;
|
|
status.textContent = "Running...";
|
|
const response = await fetch("/run", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(options) });
|
|
if (!response.ok || !response.body) {
|
|
status.textContent = "Failed to start";
|
|
run.disabled = false;
|
|
stop.disabled = true;
|
|
return;
|
|
}
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const parts = buffer.split("\\n\\n");
|
|
buffer = parts.pop() ?? "";
|
|
for (const part of parts) handleEvent(part);
|
|
}
|
|
});
|
|
|
|
function handleEvent(block) {
|
|
const lines = block.split("\\n");
|
|
const event = lines.find(line => line.startsWith("event:"))?.slice(6).trim();
|
|
const data = JSON.parse(lines.find(line => line.startsWith("data:"))?.slice(5) ?? "{}");
|
|
if (event === "chunk") {
|
|
raw.textContent += data.text;
|
|
raw.scrollTop = raw.scrollHeight;
|
|
} else if (event === "status") {
|
|
status.textContent = data.status;
|
|
} else if (event === "done") {
|
|
state.running = false;
|
|
run.disabled = false;
|
|
stop.disabled = true;
|
|
status.textContent = "Finished: exit " + data.code;
|
|
state.lastJson = data.json;
|
|
dashboard.innerHTML = data.json ? renderDashboard(JSON.parse(data.json)) : '<div class="empty">No JSON result found.</div>';
|
|
} else if (event === "error") {
|
|
status.textContent = data.error;
|
|
}
|
|
}
|
|
|
|
function renderDashboard(data) {
|
|
const combos = data.hardestCombos ?? [];
|
|
const stageCombos = data.hardestStageCombos ?? [];
|
|
const singleBosses = data.weakestSingleBosses ?? [];
|
|
const totalTrials = data.config?.orderedTrials ?? 0;
|
|
const totalFailures = combos.reduce((total, combo) => total + (combo.failures ?? 0), 0);
|
|
const worst = combos[0];
|
|
return [
|
|
'<div class="cards">',
|
|
metric('Trials', totalTrials),
|
|
metric('Worst fail rate', worst ? percent(failRate(worst)) : 'n/a'),
|
|
metric('Worst combo', worst ? escapeHtml(worst.key) : 'n/a'),
|
|
metric('Gear / repeats', 'Gear +' + escapeHtml(data.config?.gearLevel ?? '?') + ' / ' + escapeHtml(data.config?.repeats ?? '?') + 'x'),
|
|
'</div>',
|
|
totalFailures === 0 ? '<div class="empty risk-low">No failures found in returned hardest-combo rows. Review deaths and fight time for near-fails.</div>' : '',
|
|
panel('Top Damage By Boss Mechanic', mechanicBars(data.topMechanics ?? [])),
|
|
panel('Hardest Combos', comboTable(combos, true)),
|
|
panel('Hardest By Stage', comboTable(stageCombos, false)),
|
|
panel('Bosses Associated With Most Risk', comboTable(singleBosses, true)),
|
|
].join('');
|
|
}
|
|
|
|
function metric(label, value) {
|
|
return '<div class="metric"><span>' + escapeHtml(label) + '</span><strong>' + value + '</strong></div>';
|
|
}
|
|
|
|
function panel(title, content) {
|
|
return '<div class="panel"><h2>' + escapeHtml(title) + '</h2>' + content + '</div>';
|
|
}
|
|
|
|
function comboTable(rows, showDamage) {
|
|
if (!rows.length) return '<div class="empty">No rows.</div>';
|
|
return '<table><thead><tr>' +
|
|
'<th>Combo</th><th class="number">Fail</th><th class="number">Win</th><th class="number">Deaths</th><th class="number">Time</th><th class="number">Boss HP Left</th><th>Top Mechanic</th><th>Top Source</th>' +
|
|
'</tr></thead><tbody>' +
|
|
rows.map(row => '<tr>' +
|
|
'<td><strong>' + escapeHtml(row.key) + '</strong><br>' + outcomePills(row.outcomes) + '</td>' +
|
|
'<td class="number ' + riskClass(failRate(row)) + '">' + escapeHtml(row.failures ?? 0) + '/' + escapeHtml(row.trials ?? 0) + '</td>' +
|
|
'<td class="number">' + percent(row.winRate ?? 0) + '</td>' +
|
|
'<td class="number">' + fixed(row.avgDeaths) + '</td>' +
|
|
'<td class="number">' + fixed(row.avgSeconds) + 's</td>' +
|
|
'<td class="number">' + percent(row.avgRemainingBossHealth ?? 0) + '</td>' +
|
|
'<td>' + damagePills(row.topMechanics) + '</td>' +
|
|
'<td>' + (showDamage ? damagePills(row.topDamageSources) : damagePills(row.topDamageSources)) + '</td>' +
|
|
'</tr>').join('') +
|
|
'</tbody></table>';
|
|
}
|
|
|
|
function mechanicBars(sources) {
|
|
if (!sources.length) return '<div class="empty">No mechanic damage recorded.</div>';
|
|
const max = Math.max(...sources.map(source => Number(source.value) || 0), 1);
|
|
return '<div class="bars">' + sources.slice(0, 12).map(source => {
|
|
const value = Number(source.value) || 0;
|
|
const width = Math.max(2, (value / max) * 100);
|
|
return '<div class="bar-row">' +
|
|
'<div class="bar-label" title="' + escapeHtml(source.sourceId) + '">' + escapeHtml(source.sourceId) + '</div>' +
|
|
'<div class="bar-track"><div class="bar-fill" style="width:' + width + '%"></div></div>' +
|
|
'<div class="bar-value">' + fixed(value) + '</div>' +
|
|
'</div>';
|
|
}).join('') + '</div>';
|
|
}
|
|
|
|
function outcomePills(outcomes) {
|
|
if (!outcomes) return '';
|
|
return Object.entries(outcomes)
|
|
.map(([name, count]) => '<span class="pill">' + escapeHtml(name) + ': ' + escapeHtml(count) + '</span>')
|
|
.join('');
|
|
}
|
|
|
|
function damagePills(sources) {
|
|
if (!sources || sources.length === 0) return '<span class="pill">none</span>';
|
|
return sources.slice(0, 3)
|
|
.map(source => '<span class="pill">' + escapeHtml(source.sourceId) + ': ' + fixed(source.value) + '</span>')
|
|
.join('');
|
|
}
|
|
|
|
function failRate(row) {
|
|
return row.trials ? (row.failures ?? 0) / row.trials : 0;
|
|
}
|
|
|
|
function riskClass(rate) {
|
|
if (rate >= 0.5) return 'risk-high';
|
|
if (rate > 0) return 'risk-mid';
|
|
return 'risk-low';
|
|
}
|
|
|
|
function percent(value) {
|
|
return fixed(value * 100) + '%';
|
|
}
|
|
|
|
function fixed(value) {
|
|
const number = Number(value ?? 0);
|
|
return Number.isInteger(number) ? String(number) : number.toFixed(3).replace(/0+$/, '').replace(/\\.$/, '');
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value)
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
}
|
|
|
|
stop.addEventListener("click", () => {
|
|
fetch("/stop", { method: "POST" });
|
|
status.textContent = "Stopping...";
|
|
});
|
|
|
|
document.getElementById("copy").addEventListener("click", () => {
|
|
navigator.clipboard.writeText(commandFor(payload()));
|
|
status.textContent = "Command copied";
|
|
});
|
|
|
|
document.getElementById("save").addEventListener("click", () => {
|
|
if (!state.lastJson) return;
|
|
const blob = new Blob([state.lastJson], { type: "application/json" });
|
|
const link = document.createElement("a");
|
|
link.href = URL.createObjectURL(blob);
|
|
link.download = "iwt2-pvp-roguelike-sim.json";
|
|
link.click();
|
|
URL.revokeObjectURL(link.href);
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>`)
|
|
}
|
|
|
|
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 ''
|
|
}
|
|
}
|