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
94
tests/stats.test.ts
Normal file
94
tests/stats.test.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
BASE_CRIT_DAMAGE,
|
||||
BASE_CRIT_RATE,
|
||||
MULTIPLIER,
|
||||
baseStat,
|
||||
consoleTrait,
|
||||
hasMultiplier,
|
||||
totalAtk,
|
||||
traitContribution,
|
||||
} from "../src/domain/stats.ts";
|
||||
|
||||
const gamedata = JSON.parse(readFileSync("src/generated/gamedata.json", "utf8"));
|
||||
const esperByName = (name: string) =>
|
||||
gamedata.espers.find((esper: { name: string }) => esper.name === name);
|
||||
const curve = (esper: { stats: { id_stats: string; values: number[] }[] }, id: string) =>
|
||||
esper.stats.find((stat) => stat.id_stats === id)!.values;
|
||||
|
||||
describe("base stats", () => {
|
||||
it("reproduces the measured level-50 HP for two different espers", () => {
|
||||
// Both must come out exact: the five-decimal multiplier floors Adler one
|
||||
// point low, which is what forced the band.
|
||||
// The multiplier is a property of the level, not the character: these two
|
||||
// agree to five decimals while their flat deltas differ by 120.
|
||||
expect(baseStat(curve(esperByName("Haniel"), "HPMaxBase"), 50, "HP")).toBe(8272);
|
||||
expect(baseStat(curve(esperByName("Adler"), "HPMaxBase"), 50, "HP")).toBe(8638);
|
||||
});
|
||||
|
||||
it("reproduces Zankou's measured level-70 sheet", () => {
|
||||
const zankou = esperByName("Zankou");
|
||||
expect(baseStat(curve(zankou, "HPMaxBase"), 70, "HP")).toBe(13466);
|
||||
expect(baseStat(curve(zankou, "DefBase"), 70, "DEF")).toBe(789);
|
||||
});
|
||||
|
||||
it("keeps ATK on a lower multiplier than HP and DEF", () => {
|
||||
// The level-50 bands do not overlap, so the split is real, not rounding.
|
||||
const atk = MULTIPLIER[50]!.ATK as { min: number; max: number };
|
||||
const hp = MULTIPLIER[50]!.HP as { min: number; max: number };
|
||||
expect(atk.max).toBeLessThan(hp.min);
|
||||
});
|
||||
|
||||
it("reports level 80 as unmeasured rather than guessing", () => {
|
||||
expect(hasMultiplier(80)).toBe(false);
|
||||
expect(baseStat(curve(esperByName("Zankou"), "HPMaxBase"), 80, "HP")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ATK formula", () => {
|
||||
it("scales the Arc's ATK with ATK% but not flat gear ATK", () => {
|
||||
expect(Math.floor(totalAtk(577, 570, 420, 0.125))).toBe(1710);
|
||||
});
|
||||
});
|
||||
|
||||
describe("console trait", () => {
|
||||
it("is read from the data, not hardcoded", () => {
|
||||
const zankou = consoleTrait(esperByName("Zankou"))!;
|
||||
expect(zankou).toEqual({
|
||||
stat: "CritDamageBase",
|
||||
name: "CRIT DMG",
|
||||
per: 0.16,
|
||||
moduleCells: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("is not always CRIT DMG and not always Type III", () => {
|
||||
const traits = gamedata.espers.map((esper: never) => consoleTrait(esper)!);
|
||||
const typeTwo = traits.filter((trait: { moduleCells: number }) => trait.moduleCells === 2);
|
||||
expect(typeTwo).toHaveLength(3);
|
||||
expect(new Set(traits.map((trait: { stat: string }) => trait.stat)).size).toBe(8);
|
||||
});
|
||||
|
||||
it("counts only modules of the trait's own cell count", () => {
|
||||
const zankou = consoleTrait(esperByName("Zankou"))!;
|
||||
// Four Type III modules is the reading Zankou's sheet was solved against:
|
||||
// 0.50 base + 0.60 cartridge main + 0.26 substats + 4 x 0.16 = 2.00.
|
||||
const contribution = traitContribution(zankou, [
|
||||
"cell3_style1",
|
||||
"cell3_style2",
|
||||
"cell3_style3",
|
||||
"cell3_style4",
|
||||
"cell2_style1",
|
||||
"cell4_style1",
|
||||
])!;
|
||||
expect(contribution.value).toBeCloseTo(0.64, 10);
|
||||
expect(BASE_CRIT_DAMAGE + 0.6 + 0.26 + contribution.value).toBeCloseTo(2.0, 10);
|
||||
});
|
||||
|
||||
it("starts every character at 5% CRIT Rate and 50% CRIT DMG", () => {
|
||||
expect(BASE_CRIT_RATE).toBe(0.05);
|
||||
expect(BASE_CRIT_DAMAGE).toBe(0.5);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue