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>
128 lines
4.6 KiB
TypeScript
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);
|
|
});
|
|
});
|