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

136
tools/prune-gamedata.ts Normal file
View file

@ -0,0 +1,136 @@
/**
* Prunes the mirrored everness data down to what the app actually needs.
*
* Input data-src/{espers,arcs,arc_effects}.json (committed, not shipped)
* Output src/generated/gamedata.json (committed, bundled)
*
* Everything dropped here is presentation the app never renders: voice lines,
* fashion, profile text, and the ability descriptions. What stays is the stat
* curves, the console grid, the console trait, and the ids that join to a
* capture.
*/
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const read = (name: string) =>
JSON.parse(readFileSync(join(root, "data-src", name), "utf8"));
interface StatCurve {
id_stats: string;
name: string;
bShowPercent: boolean;
values: number[];
}
interface TraitStat {
id_stats: string;
name: string;
bShowPercent: boolean;
value: number;
}
export interface Esper {
id: number;
/** The `GA_<key>_*` name the capture marks character blocks with. */
abilityKey: string;
name: string;
element: string | null;
rarity: number;
icon: string;
/** Per-level curves, indexed by level - 1. */
stats: StatCurve[];
/** 7x7, -1 blocked and 0 free. */
slots: number[][];
/**
* The module type the console trait keys off - 2 or 3, NOT the grid type.
* Prydwen's "Console Grid Type" means this same number.
*/
ownerGridCount: number;
/** Never assume Type III / CRIT DMG: seven different stats appear here. */
trait: TraitStat[];
/** Levels at which a breakthrough is taken. */
breakthroughLevels: number[];
}
export interface Arc {
id: string;
name: string;
icon: string;
quality: number;
desc: string;
stats: unknown;
values: unknown;
}
const espersRaw = read("espers.json") as any[];
const arcsRaw = read("arcs.json") as Record<string, any> | any[];
const arcEffects = read("arc_effects.json") as Record<string, unknown>;
function abilityKey(esper: any): string {
const id: string | undefined = esper?.melee?.[0]?.id;
const match = /^GA_(.+?)_Melee$/.exec(id ?? "");
if (!match) throw new Error(`no GA_<key>_Melee id on esper ${esper?.name}`);
return match[1]!;
}
const espers: Esper[] = espersRaw.map((esper) => ({
id: esper.id,
abilityKey: abilityKey(esper),
name: esper.name,
element: esper.element?.[0]?.element_id ?? null,
rarity: esper.rarity,
icon: esper.icon,
stats: (esper.stats ?? []).map((stat: StatCurve) => ({
id_stats: stat.id_stats,
name: stat.name,
bShowPercent: stat.bShowPercent,
values: stat.values,
})),
slots: esper.equip_slots.slots,
ownerGridCount: esper.equip_slots.OwnerGridCount,
trait: (esper.equip_slots.stats ?? []).map((stat: TraitStat) => ({
id_stats: stat.id_stats,
name: stat.name,
bShowPercent: stat.bShowPercent,
value: stat.value,
})),
breakthroughLevels: (esper.breakthrough ?? []).map((step: { level: string }) =>
Number(step.level),
),
}));
const arcEntries = Array.isArray(arcsRaw) ? arcsRaw : Object.values(arcsRaw);
const arcs: Arc[] = arcEntries.map((arc: any) => ({
id: arc.id,
name: arc.name,
icon: arc.icon,
quality: arc.quality,
desc: arc.desc,
stats: arc.stats,
values: arc.effect?.values ?? arc.effect ?? null,
}));
const gamedata = {
format: "nte-gamedata",
format_version: 1,
generated: new Date().toISOString().slice(0, 10),
espers,
arcs,
arcEffects,
};
const outDir = join(root, "src", "generated");
mkdirSync(outDir, { recursive: true });
const json = JSON.stringify(gamedata);
writeFileSync(join(outDir, "gamedata.json"), json + "\n");
const sourceBytes = ["espers.json", "arcs.json"].reduce(
(total, name) => total + readFileSync(join(root, "data-src", name)).byteLength,
0,
);
console.log(
`gamedata.json ${espers.length} espers, ${arcs.length} arcs ` +
`${(sourceBytes / 1024).toFixed(0)} KB -> ${(json.length / 1024).toFixed(0)} KB`,
);