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
171
tests/import.test.ts
Normal file
171
tests/import.test.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { emptyCharacter } from "../src/db/schema.ts";
|
||||
import { mergeCharacters, parseGearExport } from "../src/db/import.ts";
|
||||
|
||||
const item = (over: Record<string, unknown> = {}) => ({
|
||||
instance: "a1",
|
||||
kind: "module",
|
||||
item_id: "cell3_style6_1_Orange",
|
||||
shape: "cell3_style6",
|
||||
level: 20,
|
||||
rarity: "orange",
|
||||
main_stats: [{ stat: "HPMaxAdd", value: 840 }],
|
||||
substats: [{ stat: "CritBase", value: 0.06 }],
|
||||
owner_group: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
const gearExport = (over: Record<string, unknown> = {}) => ({
|
||||
format: "nte-gear-export",
|
||||
format_version: 1,
|
||||
scan: { cartridges: 0, modules: 1, characters: 0, characters_with_loadouts: 0, warnings: [] },
|
||||
exporter: { name: "nte-history-exporter", version: "0.4.0" },
|
||||
user_uid: "100200300",
|
||||
characters: [],
|
||||
items: [item()],
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("gear export import", () => {
|
||||
it("accepts a well-formed export", () => {
|
||||
const result = parseGearExport(gearExport());
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.report.parsed).toBe(1);
|
||||
expect(result.report.rejected).toBe(0);
|
||||
expect(result.rows!.items[0]!.cells).toBe(3);
|
||||
expect(result.rows!.userUid).toBe("100200300");
|
||||
expect(result.rows!.exporterVersion).toBe("0.4.0");
|
||||
});
|
||||
|
||||
it("refuses a file that is not a gear export", () => {
|
||||
expect(parseGearExport({ format: "nte-achievement-export" }).ok).toBe(false);
|
||||
expect(parseGearExport(gearExport({ format_version: 2 })).ok).toBe(false);
|
||||
expect(parseGearExport("not json at all").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("writes nothing at all when one record is bad", () => {
|
||||
// Fail closed: a half-imported account is worse than a refused import.
|
||||
const result = parseGearExport(
|
||||
gearExport({
|
||||
scan: { cartridges: 0, modules: 2 },
|
||||
items: [item(), item({ instance: "a2", level: 99 })],
|
||||
}),
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.rows).toBeUndefined();
|
||||
expect(result.report.rejected).toBe(1);
|
||||
expect(result.report.problems.map((p) => p.code)).toContain("bad_level");
|
||||
});
|
||||
|
||||
it("reports a diff rather than a silent count", () => {
|
||||
const result = parseGearExport(
|
||||
gearExport({ scan: { cartridges: 0, modules: 3 }, items: [item()] }),
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.report.expected).toBe(3);
|
||||
expect(result.report.parsed).toBe(1);
|
||||
expect(result.report.problems.map((p) => p.code)).toContain("count_mismatch");
|
||||
});
|
||||
|
||||
it("rejects a duplicate instance instead of overwriting it", () => {
|
||||
// `instance` is the primary key, so a duplicate would silently vanish.
|
||||
const result = parseGearExport(
|
||||
gearExport({ scan: { cartridges: 0, modules: 2 }, items: [item(), item()] }),
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.report.problems.map((p) => p.code)).toContain("duplicate_instance");
|
||||
});
|
||||
|
||||
it("rejects a stat the model has no slot for", () => {
|
||||
const result = parseGearExport(
|
||||
gearExport({ items: [item({ substats: [{ stat: "MadeUpStat", value: 1 }] })] }),
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.report.problems.map((p) => p.code)).toContain("unknown_stat");
|
||||
});
|
||||
|
||||
it("allows a null main-stat value but not a null substat", () => {
|
||||
// The game never transmits a main stat's displayed value.
|
||||
expect(
|
||||
parseGearExport(gearExport({ items: [item({ main_stats: [{ stat: "HPMaxAdd", value: null }] })] }))
|
||||
.ok,
|
||||
).toBe(true);
|
||||
expect(
|
||||
parseGearExport(gearExport({ items: [item({ substats: [{ stat: "CritBase", value: null }] })] }))
|
||||
.ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a module whose shape is unknown", () => {
|
||||
const result = parseGearExport(gearExport({ items: [item({ shape: "cell9_style9" })] }));
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.report.problems.map((p) => p.code)).toContain("unknown_shape");
|
||||
});
|
||||
|
||||
it("resolves a cartridge's set from its packet id", () => {
|
||||
const result = parseGearExport(
|
||||
gearExport({
|
||||
scan: { cartridges: 1, modules: 0 },
|
||||
items: [
|
||||
item({
|
||||
instance: "c1",
|
||||
kind: "cartridge",
|
||||
item_id: "Psyche_orange",
|
||||
shape: undefined,
|
||||
main_stats: [{ stat: "CritDamageBase", value: 0.6 }],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
// Psyche, not Blood: the id the game actually uses.
|
||||
expect(result.rows!.items[0]!.set).toBe("Devil's Blood: Curse");
|
||||
});
|
||||
|
||||
it("turns an owner group into an equipment row without naming a character", () => {
|
||||
const result = parseGearExport(gearExport({ items: [item({ owner_group: "0000000d0d" })] }));
|
||||
expect(result.ok).toBe(true);
|
||||
const [row] = result.rows!.equipment;
|
||||
expect(row!.ownerGroup).toBe("0000000d0d");
|
||||
// The name attached to a group is a guess until the player makes it.
|
||||
expect(row!.characterId).toBeNull();
|
||||
expect(row!.origin).toBe("imported");
|
||||
});
|
||||
|
||||
it("leaves unequipped items out of the equipment store", () => {
|
||||
const result = parseGearExport(gearExport());
|
||||
expect(result.rows!.equipment).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("character merge", () => {
|
||||
it("refreshes level and ascension but keeps the player's configuration", () => {
|
||||
const stored = {
|
||||
...emptyCharacter("Zankou"),
|
||||
level: 60,
|
||||
breakthroughs: 3,
|
||||
arcId: "fork_vine",
|
||||
arcRefinement: 2,
|
||||
useCustom: true,
|
||||
customTargets: [{ stat: "CritBase", target: 0.7, weight: 3 }],
|
||||
};
|
||||
const [merged] = mergeCharacters(
|
||||
[stored],
|
||||
[{ ...emptyCharacter("Zankou"), level: 70, breakthroughs: 5 }],
|
||||
);
|
||||
expect(merged!.level).toBe(70);
|
||||
expect(merged!.breakthroughs).toBe(5);
|
||||
expect(merged!.arcId).toBe("fork_vine");
|
||||
expect(merged!.arcRefinement).toBe(2);
|
||||
expect(merged!.useCustom).toBe(true);
|
||||
expect(merged!.customTargets).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps a character the capture did not mention", () => {
|
||||
const stored = { ...emptyCharacter("Oneiroi"), arcId: "fork_dustbin" };
|
||||
const merged = mergeCharacters([stored], [emptyCharacter("Zankou")]);
|
||||
expect(merged.map((row) => row.characterId).sort()).toEqual(["Oneiroi", "Zankou"]);
|
||||
expect(merged.find((row) => row.characterId === "Oneiroi")!.arcId).toBe("fork_dustbin");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue