nte-optimizer/tests/host.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

197 lines
6.6 KiB
TypeScript

import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { ICON_ORDER } from "../src/domain/shapes.ts";
import type { SetBonusTable } from "../src/domain/setbonus.ts";
import { consoleTrait } from "../src/domain/stats.ts";
import { SLOT_COUNT } from "../src/domain/statvec.ts";
import type { RawTilings } from "../src/domain/tilings.ts";
import type { ExportedItem } from "../src/domain/items.ts";
import { runJob, type JobProgress, type SolveJob } from "../src/solver/job.ts";
import { CancelledError, createInlineHost, createSolverHost } from "../src/solver/host.ts";
const gamedata = JSON.parse(readFileSync("src/generated/gamedata.json", "utf8"));
const tilings = JSON.parse(readFileSync("src/generated/tilings.json", "utf8")) as RawTilings;
const setBonuses = JSON.parse(readFileSync("data-src/set-bonuses.json", "utf8")) as SetBonusTable;
/**
* A synthetic account: plenty of every shape, and cartridges of one set. Real
* game data for the boards and packings, invented inventory - the fixture policy
* keeps real captures out of the repo.
*/
function account(): ExportedItem[] {
const items: ExportedItem[] = [];
let n = 0;
for (const shape of ICON_ORDER) {
for (let i = 0; i < 8; i += 1) {
n += 1;
items.push({
instance: `m${n}`,
kind: "module",
item_id: `${shape}_1_Orange`,
shape,
level: 20,
rarity: "orange",
main_stats: [],
substats: [
{ stat: "CritBase", value: 0.01 * (i + 1) },
{ stat: "CritDamageBase", value: 0.02 * (i + 1) },
],
owner_group: null,
});
}
}
for (let i = 0; i < 6; i += 1) {
n += 1;
items.push({
instance: `c${n}`,
kind: "cartridge",
item_id: "Attack_orange",
set: "Shadow Creed",
level: 20,
rarity: "orange",
main_stats: [{ stat: "CritDamageBase", value: 0.6 }],
substats: [{ stat: "CritBase", value: 0.01 * (i + 1) }],
owner_group: null,
});
}
return items;
}
function job(keys: string[]): SolveJob {
return {
items: account(),
setBonuses,
tilings,
characters: keys.map((name) => {
const esper = gamedata.espers.find((e: { name: string }) => e.name === name);
return {
key: name,
slots: esper.slots,
trait: consoleTrait(esper),
base: new Array(SLOT_COUNT).fill(0),
scoring: {
targets: [
{ stat: "CritBase" as const, target: 0.7, weight: 3 },
{ stat: "CritDamageBase" as const, target: 2.0, weight: 3 },
],
},
sets: ["Shadow Creed" as const],
};
}),
options: { rounds: 1 },
};
}
describe("runJob", () => {
it("solves one character end to end from a serialisable job", () => {
const run = runJob(job(["Zankou"]));
const progress: JobProgress[] = [];
let step = run.next();
while (!step.done) {
progress.push(step.value);
step = run.next();
}
const result = step.value;
expect(result.infeasible).toBe(false);
expect(result.assignment).toHaveLength(1);
expect(result.unbuildable).toEqual([]);
expect(result.unknownShapes).toEqual([]);
expect(progress.some((p) => p.phase === "portfolio")).toBe(true);
expect(progress.some((p) => p.phase === "team")).toBe(true);
});
it("gives two characters disjoint items", () => {
const run = runJob(job(["Zankou", "Haniel"]));
let step = run.next();
while (!step.done) step = run.next();
const used = step.value.assignment.flatMap((entry) => [
entry.build.cartridge,
...entry.build.modules,
]);
expect(step.value.assignment).toHaveLength(2);
expect(new Set(used).size).toBe(used.length);
});
it("honours a character's excluded instances", () => {
// R2 turned off: whatever another character wears is off limits, and the
// solve must actually avoid it rather than merely be told about it.
const plain = runJob(job(["Zankou"]));
let step = plain.next();
while (!step.done) step = plain.next();
const chosen = step.value.assignment[0]!.build;
const pool = job(["Zankou"]).items;
const used = [chosen.cartridge, ...chosen.modules].map((index) => pool[index]!.instance);
expect(used.length).toBeGreaterThan(0);
const restricted = job(["Zankou"]);
restricted.characters[0]!.excludedInstances = used;
const run = runJob(restricted);
let next = run.next();
while (!next.done) next = run.next();
const after = next.value.assignment[0]!.build;
const afterInstances = [after.cartridge, ...after.modules].map(
(index) => restricted.items[index]!.instance,
);
for (const instance of used) expect(afterInstances).not.toContain(instance);
});
it("carries a job with no packings through as unbuildable", () => {
const empty = job(["Zankou"]);
empty.characters[0]!.sets = ["Kingdom's Guard"]; // owned by nobody here
const run = runJob(empty);
let step = run.next();
while (!step.done) step = run.next();
expect(step.value.unbuildable).toEqual(["Zankou"]);
expect(step.value.assignment).toEqual([]);
});
it("reports item ids it has no shape for instead of dropping them", () => {
const odd = job(["Zankou"]);
odd.items.push({
instance: "x1",
kind: "module",
item_id: "cell9_style9_1_Orange",
shape: "cell9_style9",
level: 0,
rarity: "orange",
main_stats: [],
substats: [],
owner_group: null,
});
const run = runJob(odd);
let step = run.next();
while (!step.done) step = run.next();
expect(step.value.unknownShapes).toEqual(["cell9_style9_1_Orange"]);
});
});
describe("solver host", () => {
it("falls back to inline where there is no Worker", () => {
// Node has none, which is also the file:// build's situation.
expect(createSolverHost().kind).toBe("inline");
});
it("resolves with the same answer the generator gives", async () => {
const host = createInlineHost();
const direct = runJob(job(["Zankou"]));
let step = direct.next();
while (!step.done) step = direct.next();
const seen: JobProgress[] = [];
const handle = host.solve(job(["Zankou"]), (progress) => seen.push(progress));
const result = await handle.promise;
expect(result.sorted).toEqual(step.value.sorted);
expect(seen.length).toBeGreaterThan(0);
host.dispose();
});
it("cancels cooperatively", async () => {
const host = createInlineHost();
const handle = host.solve(job(["Zankou", "Haniel"]));
handle.cancel();
await expect(handle.promise).rejects.toBeInstanceOf(CancelledError);
host.dispose();
});
});