Android build v1.1.34
This commit is contained in:
@@ -0,0 +1,567 @@
|
||||
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 ''
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user