nte-optimizer/tests/stats.test.ts
goober 91952b1745 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

94 lines
3.5 KiB
TypeScript

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