nte-optimizer/tests/arcs.test.ts
goober 5ce255fa6f 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>
2026-08-21 11:38:17 +03:00

128 lines
4.6 KiB
TypeScript

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);
});
});