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:
commit
141bb2c751
157 changed files with 92605 additions and 0 deletions
128
tests/arcs.test.ts
Normal file
128
tests/arcs.test.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { arcContribution, arcControls, parseArcValue } from "../src/domain/arcs.ts";
|
||||
import { describe as describeVector, slotOf } from "../src/domain/statvec.ts";
|
||||
import type { GameData } from "../src/state/gamedata.ts";
|
||||
|
||||
const gamedata = JSON.parse(readFileSync("src/generated/gamedata.json", "utf8")) as GameData;
|
||||
const arcById = (id: string) => gamedata.arcs.find((arc) => arc.id === id)!;
|
||||
const effectsFor = (id: string) => gamedata.arcEffects[id];
|
||||
|
||||
describe("arc values", () => {
|
||||
it("reads a percent string as a fraction", () => {
|
||||
expect(parseArcValue("12%", true)).toBeCloseTo(0.12, 10);
|
||||
expect(parseArcValue("12%", false)).toBeCloseTo(0.12, 10);
|
||||
expect(parseArcValue(20, false)).toBe(20);
|
||||
expect(parseArcValue(20, true)).toBeCloseTo(0.2, 10);
|
||||
expect(parseArcValue(" ", false)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("arc contribution", () => {
|
||||
it("carries the arc's own stat line at the given level", () => {
|
||||
const arc = arcById("fork_vine");
|
||||
const low = arcContribution(arc, effectsFor("fork_vine"), {
|
||||
arcId: arc.id,
|
||||
level: 1,
|
||||
refinement: 1,
|
||||
});
|
||||
const high = arcContribution(arc, effectsFor("fork_vine"), {
|
||||
arcId: arc.id,
|
||||
level: 80,
|
||||
refinement: 1,
|
||||
});
|
||||
const atk = slotOf("AtkBase");
|
||||
expect(low.vector[atk]).toBe(21);
|
||||
expect(high.vector[atk]!).toBeGreaterThan(low.vector[atk]!);
|
||||
});
|
||||
|
||||
it("keeps the arc's flat ATK out of gear's flat ATK slot", () => {
|
||||
// They share a unit but not a formula: the Arc's ATK scales with ATK%.
|
||||
const arc = arcById("fork_vine");
|
||||
const result = arcContribution(arc, effectsFor("fork_vine"), {
|
||||
arcId: arc.id,
|
||||
level: 80,
|
||||
refinement: 1,
|
||||
});
|
||||
expect(result.vector[slotOf("AtkAdd")]).toBe(0);
|
||||
expect(result.vector[slotOf("AtkBase")]).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("leaves a toggle off by default and reports it as omitted", () => {
|
||||
// fork_vine's own stat line is already HPMaxUp, so the toggle stacks onto
|
||||
// the same slot: the assertion has to be on the delta, not the total.
|
||||
const arc = arcById("fork_vine");
|
||||
const slot = slotOf("HPMaxUp");
|
||||
const off = arcContribution(arc, effectsFor("fork_vine"), {
|
||||
arcId: arc.id,
|
||||
level: 80,
|
||||
refinement: 5,
|
||||
});
|
||||
expect(off.omitted.some((entry) => entry.why === "toggled off")).toBe(true);
|
||||
|
||||
const on = arcContribution(arc, effectsFor("fork_vine"), {
|
||||
arcId: arc.id,
|
||||
level: 80,
|
||||
refinement: 5,
|
||||
toggles: { 0: true },
|
||||
});
|
||||
// Refinement 5 of "12%..20%" is 20%.
|
||||
expect(on.vector[slot]! - off.vector[slot]!).toBeCloseTo(0.2, 6);
|
||||
});
|
||||
|
||||
it("scales a refinement with the chosen level of it", () => {
|
||||
const arc = arcById("fork_vine");
|
||||
const slot = slotOf("HPMaxUp");
|
||||
const base = arcContribution(arc, effectsFor("fork_vine"), {
|
||||
arcId: arc.id,
|
||||
level: 80,
|
||||
refinement: 1,
|
||||
});
|
||||
const first = arcContribution(arc, effectsFor("fork_vine"), {
|
||||
arcId: arc.id,
|
||||
level: 80,
|
||||
refinement: 1,
|
||||
toggles: { 0: true },
|
||||
});
|
||||
expect(first.vector[slot]! - base.vector[slot]!).toBeCloseTo(0.12, 6);
|
||||
});
|
||||
|
||||
it("never scores a duration or an unmodellable effect", () => {
|
||||
// fork_appliance's only effect is named ability damage, not a sheet stat.
|
||||
const arc = arcById("fork_appliance");
|
||||
const result = arcContribution(arc, effectsFor("fork_appliance"), {
|
||||
arcId: arc.id,
|
||||
level: 80,
|
||||
refinement: 5,
|
||||
toggles: { 0: true },
|
||||
});
|
||||
expect(result.omitted.length).toBeGreaterThan(0);
|
||||
// Only the arc's own stat line contributed.
|
||||
const nonZero = Object.keys(describeVector(result.vector));
|
||||
expect(nonZero.every((stat) => stat === "AtkBase" || stat === "AtkUp")).toBe(true);
|
||||
});
|
||||
|
||||
it("has a slot for every stat any arc can contribute", () => {
|
||||
const unknown = new Set<string>();
|
||||
for (const arc of gamedata.arcs) {
|
||||
const result = arcContribution(arc, effectsFor(arc.id), {
|
||||
arcId: arc.id,
|
||||
level: 80,
|
||||
refinement: 5,
|
||||
toggles: Object.fromEntries(
|
||||
Array.from({ length: 8 }, (_, index) => [index, true] as const),
|
||||
),
|
||||
});
|
||||
for (const stat of result.unknownStats) unknown.add(stat);
|
||||
}
|
||||
expect([...unknown]).toEqual([]);
|
||||
});
|
||||
|
||||
it("offers a control only for effects that need one", () => {
|
||||
const controls = arcControls(arcById("fork_vine"), effectsFor("fork_vine"));
|
||||
expect(controls.map((control) => control.mode)).toEqual(["toggle"]);
|
||||
// The cooldown placeholder is not a control.
|
||||
expect(controls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue