feat: NTE gear optimizer web app

Client-side optimizer for Neverness to Everness console builds. Imports a
gear export from nte-history-exporter, keeps it in IndexedDB, and solves for
the best module placement per character or across a team.

- domain/: pure ports of the Python research scripts (shapes, cartridges,
  board tiling, stats, scoring, set bonuses, Arc effects, sheet prediction).
  No DOM, no storage imports, so they stay worker-safe and Node-testable.
- solver/: single-character solve with branch and bound, plus a three-phase
  team solve (portfolio, leximin, column generation) over disjoint item sets.
  Runs in a Worker when one can be constructed, inline otherwise.
- db/: three storage tiers selected by use rather than feature detection,
  fail-closed import validation, and undo that restores both sides of a steal.
- ui/: React 19 views for items, characters and teams; the board is drawn as
  the deliverable rather than a score.
- Two build targets: a hosted bundle and a single self-contained nte.html
  with no external requests.

Values the model cannot measure are reported as unavailable rather than
guessed: cartridge set bonuses, the level 80 base-stat multiplier, and the
distinction between "optimal for this packing" and globally optimal.

145 tests, tsc clean, both build targets clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goober 2026-08-21 11:38:17 +03:00
commit 5ce255fa6f
157 changed files with 92600 additions and 0 deletions

91
tools/bench-solve.ts Normal file
View file

@ -0,0 +1,91 @@
/**
* Times a real solve against a real export. Not a test - the fixture policy
* keeps real captures out of the repo, so this takes the export path as an
* argument and is run by hand.
*
* node --experimental-strip-types tools/bench-solve.ts path/to/gear.json
*/
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
import { buildPool, type ExportedItem } from "../src/domain/items.ts";
import { compile, explain } from "../src/domain/scoring.ts";
import { consoleTrait } from "../src/domain/stats.ts";
import { loadTilings, type RawTilings } from "../src/domain/tilings.ts";
import { boardFromSlots, boardKey } from "../src/domain/board.ts";
import { emptyVector } from "../src/domain/statvec.ts";
import type { SetBonusTable } from "../src/domain/setbonus.ts";
import { solveSingle } from "../src/solver/single.ts";
const exportPath = process.argv[2]!;
const gear = JSON.parse(readFileSync(exportPath, "utf8"));
const gamedata = JSON.parse(readFileSync(join(root, "src/generated/gamedata.json"), "utf8"));
const setBonuses = JSON.parse(readFileSync(join(root, "data-src/set-bonuses.json"), "utf8")) as SetBonusTable;
const tables = loadTilings(JSON.parse(readFileSync(join(root, "src/generated/tilings.json"), "utf8")) as RawTilings);
const pool = buildPool(gear.items as ExportedItem[]);
console.log(
`pool: ${pool.items.length} items, ${pool.modulesByShape.size} shapes, ` +
`${pool.cartridgesBySet.size} sets owned, unknown shapes: ${pool.unknownShapes.length}`,
);
const zankou = gamedata.espers.find((e: { name: string }) => e.name === "Zankou");
const board = boardFromSlots(zankou.slots);
const table = tables.find((t) => t.cells.join(",") === boardKey(board))!;
const scoring = compile({
targets: [
{ stat: "CritBase", target: 0.7, weight: 3 },
{ stat: "CritDamageBase", target: 2.0, weight: 3 },
{ stat: "AtkUp", target: 0.5, weight: 2 },
{ stat: "UnbalIntensityBase", target: 280, weight: 1 },
],
mainStatRank: { CritDamageBase: 1, AtkUp: 0.5 },
});
for (const set of ["Crimson: Twin Butterflies", "Shadow Creed"] as const) {
const tilings = table.bySet.get(set) ?? [];
const started = Date.now();
const run = solveSingle({
pool,
tilings,
base: emptyVector(),
trait: consoleTrait(zankou),
setBonuses,
scoring,
});
let step = run.next();
let yields = 0;
while (!step.done) {
yields += 1;
step = run.next();
}
const result = step.value;
const elapsed = Date.now() - started;
const best = result.best;
console.log(
`\n${set}: ${tilings.length} packings, examined ${result.examined}, ` +
`${yields} yields, ${elapsed} ms`,
);
if (!best) {
console.log(" no build");
continue;
}
console.log(
` score ${best.score.toFixed(5)} proven=${best.proven} ` +
`pieces=${best.modules.length} unknownTiers=[${best.unknownTiers}]`,
);
console.log(` cartridge ${pool.items[best.cartridge]!.itemId} main ${pool.items[best.cartridge]!.mainStat}`);
console.log(` modules ${best.modules.map((i) => pool.items[i]!.shape).join(" ")}`);
const report = explain(best.vector, scoring);
for (const stat of report.stats) {
console.log(
` ${stat.stat.padEnd(20)} ${stat.value.toFixed(3).padStart(9)} / ${String(stat.target).padStart(6)}` +
` ${(stat.attainment * 100).toFixed(1)}%`,
);
}
console.log(` distinct modules used: ${new Set(best.modules).size}/${best.modules.length}`);
}