nte-optimizer/tests/scoring.test.ts
goober 141bb2c751 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

147 lines
5.6 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
DEFAULT_MAIN_STAT_EPSILON,
compile,
explain,
score,
upperBound,
} from "../src/domain/scoring.ts";
import {
SLOT_COUNT,
STAT_SLOTS,
addInto,
describe as describeVector,
elementSlot,
emptyPool,
emptyVector,
poolSlice,
slotOf,
vectorFrom,
} from "../src/domain/statvec.ts";
const config = {
targets: [
{ stat: "CritBase" as const, target: 0.7, weight: 3 },
{ stat: "CritDamageBase" as const, target: 2.0, weight: 3 },
{ stat: "AtkUp" as const, target: 0.5, weight: 1 },
],
};
const withStats = (pairs: Record<string, number>) =>
vectorFrom(Object.entries(pairs).map(([stat, value]) => ({ stat, value })));
describe("stat vector", () => {
it("gives every stat id its own slot and never reorders", () => {
expect(new Set(STAT_SLOTS).size).toBe(SLOT_COUNT);
// Slot order is an on-disk contract; append only.
expect(STAT_SLOTS[0]).toBe("HPMaxAdd");
expect(slotOf("HPMaxAdd")).toBe(0);
expect(slotOf("NotAStat")).toBe(-1);
});
it("skips main stats whose value the game never transmits", () => {
const vector = vectorFrom([
{ stat: "CritBase", value: null },
{ stat: "CritBase", value: 0.06 },
]);
// Float32 keeps ~7 digits; substats carry three, so the storage is exact
// enough and the rounding never reaches the score.
expect(Object.keys(describeVector(vector))).toEqual(["CritBase"]);
expect(vector[slotOf("CritBase")]).toBeCloseTo(0.06, 6);
});
it("resolves a character's own damage-bonus slot", () => {
expect(elementSlot("Incantation")).toBe(slotOf("DamageUpIncantationBase"));
expect(elementSlot(null)).toBe(-1);
});
it("adds through a flat pool without allocating per item", () => {
const pool = emptyPool(3);
poolSlice(pool, 1).set(withStats({ CritBase: 0.1 }));
const total = emptyVector();
addInto(total, poolSlice(pool, 1));
addInto(total, poolSlice(pool, 1));
expect(total[slotOf("CritBase")]).toBeCloseTo(0.2, 6);
});
});
describe("scoring", () => {
const compiled = compile(config);
it("is the weighted mean of per-stat attainment", () => {
// Half of every target, so every attainment is 0.5.
const half = withStats({ CritBase: 0.35, CritDamageBase: 1.0, AtkUp: 0.25 });
expect(score(half, compiled)).toBeCloseTo(0.5, 5);
});
it("saturates at the target rather than rewarding a ceiling", () => {
const met = withStats({ CritBase: 0.7, CritDamageBase: 2.0, AtkUp: 0.5 });
const report = explain(met, compiled);
expect(report.base).toBeCloseTo(1, 5);
expect(report.complete).toBe(true);
for (const stat of report.stats) expect(stat.attainment).toBe(1);
});
it("puts the shortfall on the lowest-weighted stat", () => {
// Missing the weight-1 stat entirely costs less than missing a weight-3 one.
const missingLight = withStats({ CritBase: 0.7, CritDamageBase: 2.0 });
const missingHeavy = withStats({ CritDamageBase: 2.0, AtkUp: 0.5 });
expect(score(missingLight, compiled)).toBeGreaterThan(score(missingHeavy, compiled));
});
it("breaks a tie at 1.0 by overshoot, and only by a little", () => {
const met = withStats({ CritBase: 0.7, CritDamageBase: 2.0, AtkUp: 0.5 });
const over = withStats({ CritBase: 0.9, CritDamageBase: 2.0, AtkUp: 0.5 });
expect(score(over, compiled)).toBeGreaterThan(score(met, compiled));
// Overshoot must not outweigh actually reaching a target.
const shortOnOne = withStats({ CritBase: 3.0, CritDamageBase: 2.0, AtkUp: 0.0 });
expect(score(shortOnOne, compiled)).toBeLessThan(score(met, compiled));
});
it("stops paying for overshoot past the cap", () => {
const double = withStats({ CritBase: 1.4, CritDamageBase: 2.0, AtkUp: 0.5 });
const quadruple = withStats({ CritBase: 2.8, CritDamageBase: 2.0, AtkUp: 0.5 });
expect(score(quadruple, compiled)).toBeCloseTo(score(double, compiled), 6);
});
it("lets the cartridge main stat break ties but never decide a build", () => {
const met = withStats({ CritBase: 0.7, CritDamageBase: 2.0, AtkUp: 0.5 });
const ranked = compile({ ...config, mainStatRank: { CritDamageBase: 1 } });
const withMain = score(met, ranked, slotOf("CritDamageBase"));
expect(withMain - score(met, ranked)).toBeCloseTo(DEFAULT_MAIN_STAT_EPSILON, 8);
// A build one whole weight-1 target short cannot be rescued by the epsilon.
const short = withStats({ CritBase: 0.7, CritDamageBase: 2.0 });
expect(score(short, ranked, slotOf("CritDamageBase"))).toBeLessThan(
score(met, ranked),
);
});
it("ignores stats with no target", () => {
const irrelevant = withStats({ HealUp: 5, MagBase: 900 });
expect(score(irrelevant, compiled)).toBe(0);
});
it("bounds any completion of a partial build", () => {
const partial = withStats({ CritBase: 0.35 });
const best = withStats({ CritBase: 0.35, CritDamageBase: 2.0, AtkUp: 0.5 });
const bound = upperBound(partial, best, compiled);
const finished = emptyVector();
addInto(finished, partial);
addInto(finished, best);
// Admissible: the bound must never sit below what the completion scores.
expect(bound).toBeGreaterThanOrEqual(score(finished, compiled));
const worse = withStats({ CritDamageBase: 1.0 });
const reached = emptyVector();
addInto(reached, partial);
addInto(reached, worse);
expect(bound).toBeGreaterThanOrEqual(score(reached, compiled));
});
it("reports zero rather than dividing by an empty weight sum", () => {
const empty = compile({ targets: [] });
expect(score(emptyVector(), empty)).toBe(0);
expect(explain(emptyVector(), empty).total).toBe(0);
});
});