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
197
tests/host.test.ts
Normal file
197
tests/host.test.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue