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:
goober 2026-08-21 11:38:17 +03:00
commit 5ce255fa6f
157 changed files with 92600 additions and 0 deletions

128
tests/arcs.test.ts Normal file
View file

@ -0,0 +1,128 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { arcContribution, arcControls, parseArcValue } from "../src/domain/arcs.ts";
import { describe as describeVector, slotOf } from "../src/domain/statvec.ts";
import type { GameData } from "../src/state/gamedata.ts";
const gamedata = JSON.parse(readFileSync("src/generated/gamedata.json", "utf8")) as GameData;
const arcById = (id: string) => gamedata.arcs.find((arc) => arc.id === id)!;
const effectsFor = (id: string) => gamedata.arcEffects[id];
describe("arc values", () => {
it("reads a percent string as a fraction", () => {
expect(parseArcValue("12%", true)).toBeCloseTo(0.12, 10);
expect(parseArcValue("12%", false)).toBeCloseTo(0.12, 10);
expect(parseArcValue(20, false)).toBe(20);
expect(parseArcValue(20, true)).toBeCloseTo(0.2, 10);
expect(parseArcValue(" ", false)).toBe(0);
});
});
describe("arc contribution", () => {
it("carries the arc's own stat line at the given level", () => {
const arc = arcById("fork_vine");
const low = arcContribution(arc, effectsFor("fork_vine"), {
arcId: arc.id,
level: 1,
refinement: 1,
});
const high = arcContribution(arc, effectsFor("fork_vine"), {
arcId: arc.id,
level: 80,
refinement: 1,
});
const atk = slotOf("AtkBase");
expect(low.vector[atk]).toBe(21);
expect(high.vector[atk]!).toBeGreaterThan(low.vector[atk]!);
});
it("keeps the arc's flat ATK out of gear's flat ATK slot", () => {
// They share a unit but not a formula: the Arc's ATK scales with ATK%.
const arc = arcById("fork_vine");
const result = arcContribution(arc, effectsFor("fork_vine"), {
arcId: arc.id,
level: 80,
refinement: 1,
});
expect(result.vector[slotOf("AtkAdd")]).toBe(0);
expect(result.vector[slotOf("AtkBase")]).toBeGreaterThan(0);
});
it("leaves a toggle off by default and reports it as omitted", () => {
// fork_vine's own stat line is already HPMaxUp, so the toggle stacks onto
// the same slot: the assertion has to be on the delta, not the total.
const arc = arcById("fork_vine");
const slot = slotOf("HPMaxUp");
const off = arcContribution(arc, effectsFor("fork_vine"), {
arcId: arc.id,
level: 80,
refinement: 5,
});
expect(off.omitted.some((entry) => entry.why === "toggled off")).toBe(true);
const on = arcContribution(arc, effectsFor("fork_vine"), {
arcId: arc.id,
level: 80,
refinement: 5,
toggles: { 0: true },
});
// Refinement 5 of "12%..20%" is 20%.
expect(on.vector[slot]! - off.vector[slot]!).toBeCloseTo(0.2, 6);
});
it("scales a refinement with the chosen level of it", () => {
const arc = arcById("fork_vine");
const slot = slotOf("HPMaxUp");
const base = arcContribution(arc, effectsFor("fork_vine"), {
arcId: arc.id,
level: 80,
refinement: 1,
});
const first = arcContribution(arc, effectsFor("fork_vine"), {
arcId: arc.id,
level: 80,
refinement: 1,
toggles: { 0: true },
});
expect(first.vector[slot]! - base.vector[slot]!).toBeCloseTo(0.12, 6);
});
it("never scores a duration or an unmodellable effect", () => {
// fork_appliance's only effect is named ability damage, not a sheet stat.
const arc = arcById("fork_appliance");
const result = arcContribution(arc, effectsFor("fork_appliance"), {
arcId: arc.id,
level: 80,
refinement: 5,
toggles: { 0: true },
});
expect(result.omitted.length).toBeGreaterThan(0);
// Only the arc's own stat line contributed.
const nonZero = Object.keys(describeVector(result.vector));
expect(nonZero.every((stat) => stat === "AtkBase" || stat === "AtkUp")).toBe(true);
});
it("has a slot for every stat any arc can contribute", () => {
const unknown = new Set<string>();
for (const arc of gamedata.arcs) {
const result = arcContribution(arc, effectsFor(arc.id), {
arcId: arc.id,
level: 80,
refinement: 5,
toggles: Object.fromEntries(
Array.from({ length: 8 }, (_, index) => [index, true] as const),
),
});
for (const stat of result.unknownStats) unknown.add(stat);
}
expect([...unknown]).toEqual([]);
});
it("offers a control only for effects that need one", () => {
const controls = arcControls(arcById("fork_vine"), effectsFor("fork_vine"));
expect(controls.map((control) => control.mode)).toEqual(["toggle"]);
// The cooldown placeholder is not a control.
expect(controls).toHaveLength(1);
});
});

298
tests/db.test.ts Normal file
View file

@ -0,0 +1,298 @@
import { describe, expect, it } from "vitest";
import { createLocalStorageAdapter, STATE_KEY, type StorageLike } from "../src/db/adapters/local-storage.ts";
import { createMemoryAdapter } from "../src/db/adapters/memory.ts";
import { emptyState, type StoredState } from "../src/db/adapters/types.ts";
import { exportDatabase, importDatabase } from "../src/db/dbfile.ts";
import { parseGearExport } from "../src/db/import.ts";
import { emptyCharacter, type EquipmentRow } from "../src/db/schema.ts";
import {
applyImport,
coveredCells,
editItem,
equip,
equipmentOf,
heldByOthers,
isComplete,
nameOwnerGroup,
resolveCharacter,
todoInGame,
undoLast,
unnamedGroups,
} from "../src/db/store.ts";
const row = (over: Partial<EquipmentRow> = {}): EquipmentRow => ({
instance: "a1",
ownerGroup: null,
characterId: "Zankou",
cells: [1, 2, 3],
origin: "imported",
batchId: "b1",
at: 0,
...over,
});
function stateWith(equipment: EquipmentRow[], over: Partial<StoredState> = {}): StoredState {
const state = emptyState();
state.db.equipment = equipment;
return { ...state, ...over };
}
class FakeStorage implements StorageLike {
data = new Map<string, string>();
quota = Infinity;
getItem(key: string) {
return this.data.get(key) ?? null;
}
setItem(key: string, value: string) {
if (value.length > this.quota) throw new Error("QuotaExceededError");
this.data.set(key, value);
}
removeItem(key: string) {
this.data.delete(key);
}
}
describe("adapters", () => {
it("memory round-trips and does not alias the caller's state", async () => {
const adapter = createMemoryAdapter();
const state = stateWith([row()]);
await adapter.replaceAll(state);
state.db.equipment[0]!.instance = "mutated";
const read = await adapter.read();
expect(read.db.equipment[0]!.instance).toBe("a1");
expect(adapter.durable).toBe(false);
});
it("localStorage round-trips and survives a corrupt value", async () => {
const storage = new FakeStorage();
const adapter = createLocalStorageAdapter(storage);
await adapter.replaceAll(stateWith([row()]));
expect((await adapter.read()).db.equipment).toHaveLength(1);
storage.data.set(STATE_KEY, "{ not json");
expect((await adapter.read()).db.equipment).toEqual([]);
});
it("drops the snapshot before the database when the quota is hit", async () => {
const storage = new FakeStorage();
const adapter = createLocalStorageAdapter(storage);
const state = stateWith([row()]);
state.snapshot = { items: [], equipment: [], characters: [], ownerNames: [], meta: [] };
await adapter.replaceAll(state);
expect(storage.data.size).toBe(2);
storage.quota = 200;
await expect(adapter.replaceAll(state)).rejects.toThrow();
// The database write is what matters; the snapshot is the first thing cut.
expect(storage.getItem("nte-optimizer:snapshot")).toBeNull();
});
it("writes only equipment on the frequent path", async () => {
const adapter = createMemoryAdapter();
const state = stateWith([row()]);
state.db.items = [
{ instance: "a1", kind: "module", itemId: "x", shape: null, cells: null, set: null, level: 0, rarity: "orange", mainStats: [], substats: [] },
];
await adapter.replaceAll(state);
await adapter.writeEquipment([row({ instance: "a2" })], []);
const read = await adapter.read();
expect(read.db.equipment.map((r) => r.instance)).toEqual(["a2"]);
expect(read.db.items).toHaveLength(1);
});
});
describe("store", () => {
it("resolves a character through the owner group, never off the row", () => {
const state = nameOwnerGroup(stateWith([row({ characterId: null, ownerGroup: "g1" })]), "g1", "Sagiri");
const [equipped] = state.db.equipment;
expect(equipped!.characterId).toBeNull();
expect(resolveCharacter(equipped!, state.db.ownerNames)).toBe("Sagiri");
expect(equipmentOf(state.db, "Sagiri")).toHaveLength(1);
});
it("lists groups nobody has named", () => {
const state = stateWith([
row({ characterId: null, ownerGroup: "g1" }),
row({ instance: "a2", characterId: null, ownerGroup: "g2" }),
]);
expect(unnamedGroups(state.db)).toEqual(["g1", "g2"]);
expect(unnamedGroups(nameOwnerGroup(state, "g1", "Zankou").db)).toEqual(["g2"]);
});
it("calls a build incomplete when it no longer covers twenty cells", () => {
const full = [row({ cells: new Array(20).fill(0) })];
expect(coveredCells(full)).toBe(20);
expect(isComplete(full)).toBe(true);
expect(isComplete([row({ cells: [1, 2] })])).toBe(false);
});
it("replaces a character's whole loadout in one action", () => {
const state = stateWith([row({ instance: "old1" }), row({ instance: "old2" })]);
const { state: next } = equip(state, "Zankou", ["new1", "new2"]);
expect(equipmentOf(next.db, "Zankou").map((r) => r.instance).sort()).toEqual(["new1", "new2"]);
expect(next.undo).toHaveLength(1);
expect(next.undo[0]!.before.map((r) => r.instance).sort()).toEqual(["old1", "old2"]);
});
it("names who was left with a hole when it takes their item", () => {
const state = stateWith([
row({ instance: "shared", characterId: "Haniel" }),
row({ instance: "other", characterId: "Haniel" }),
]);
const { state: next, displaced } = equip(state, "Zankou", ["shared"]);
expect(displaced).toEqual([{ characterId: "Haniel", instances: ["shared"] }]);
// Haniel keeps everything else, and is now incomplete rather than wiped.
expect(equipmentOf(next.db, "Haniel").map((r) => r.instance)).toEqual(["other"]);
});
it("restores both sides of a steal with one undo", () => {
const state = stateWith([
row({ instance: "shared", characterId: "Haniel" }),
row({ instance: "mine", characterId: "Zankou" }),
]);
const { state: next } = equip(state, "Zankou", ["shared"]);
const back = undoLast(next);
expect(equipmentOf(back.db, "Haniel").map((r) => r.instance)).toEqual(["shared"]);
expect(equipmentOf(back.db, "Zankou").map((r) => r.instance)).toEqual(["mine"]);
expect(back.undo).toHaveLength(0);
});
it("marks app-assigned rows as things still to do in game", () => {
const state = stateWith([row({ instance: "worn" })]);
const { state: next } = equip(state, "Zankou", ["planned"]);
const todo = todoInGame(next.db);
expect(todo.get("Zankou")!.map((r) => r.instance)).toEqual(["planned"]);
expect(todoInGame(state.db).size).toBe(0);
});
});
describe("import application", () => {
const gearExport = {
format: "nte-gear-export",
format_version: 1,
scan: { cartridges: 0, modules: 1 },
exporter: { version: "0.4.0" },
characters: [{ key: "Zankou", level: 70, breakthroughs: 5 }],
items: [
{
instance: "a1",
kind: "module",
item_id: "cell3_style6_1_Orange",
shape: "cell3_style6",
level: 20,
rarity: "orange",
main_stats: [],
substats: [{ stat: "CritBase", value: 0.06 }],
owner_group: "g1",
},
],
};
it("snapshots, replaces gear, and leaves owner names alone", () => {
let state = stateWith([row({ instance: "stale" })]);
state = nameOwnerGroup(state, "g1", "Zankou");
state.db.characters = [{ ...emptyCharacter("Zankou"), arcId: "fork_vine", level: 60 }];
state.undo = [{ batchId: "b", before: [], after: [], at: 0 }];
const next = applyImport(state, parseGearExport(gearExport));
expect(next.db.items).toHaveLength(1);
expect(next.db.equipment.map((r) => r.instance)).toEqual(["a1"]);
expect(next.db.ownerNames).toHaveLength(1);
expect(next.db.characters[0]!.arcId).toBe("fork_vine");
expect(next.db.characters[0]!.level).toBe(70);
// Undo refers to items that may no longer exist.
expect(next.undo).toEqual([]);
expect(next.snapshot!.equipment.map((r) => r.instance)).toEqual(["stale"]);
});
it("refuses to apply a failed import", () => {
const bad = parseGearExport({ format: "nope" });
expect(() => applyImport(emptyState(), bad)).toThrow();
});
});
describe("database file", () => {
it("round-trips the whole workspace", () => {
const state = nameOwnerGroup(stateWith([row()]), "g1", "Zankou");
const file = exportDatabase(state, "2026-08-21");
const back = importDatabase(JSON.parse(JSON.stringify(file)));
expect(back.ok).toBe(true);
expect(back.migrated).toBe(false);
expect(back.state!.db.ownerNames).toHaveLength(1);
expect(back.state!.db.equipment).toHaveLength(1);
});
it("refuses a file from a newer build rather than dropping fields", () => {
const file = exportDatabase(emptyState(), null);
const newer = { ...file, schema_version: 99 };
const result = importDatabase(newer);
expect(result.ok).toBe(false);
expect(result.problems[0]).toContain("newer build");
});
it("refuses anything that is not a database file", () => {
expect(importDatabase({ format: "nte-gear-export" }).ok).toBe(false);
expect(importDatabase(null).ok).toBe(false);
});
});
describe("use equipped items", () => {
it("locks away only what a resolvable other character is wearing", () => {
let state = stateWith([
row({ instance: "mine", characterId: "Zankou" }),
row({ instance: "theirs", characterId: "Haniel" }),
row({ instance: "unnamed", characterId: null, ownerGroup: "g9" }),
]);
// Zankou keeps their own; Haniel's is off limits.
expect(heldByOthers(state.db, ["Zankou"])).toEqual(["theirs"]);
// Both team members' items are available to the team.
expect(heldByOthers(state.db, ["Zankou", "Haniel"])).toEqual([]);
// An unresolved group belongs to nobody the app can reason about, so it
// stays available rather than hiding most of the pool.
state = nameOwnerGroup(state, "g9", "Adler");
expect(heldByOthers(state.db, ["Zankou"]).sort()).toEqual(["theirs", "unnamed"]);
});
});
describe("editing an item by hand", () => {
it("corrects a value and leaves the rest alone", () => {
const state = emptyState();
state.db.items = [
{
instance: "a1",
kind: "module",
itemId: "cell3_style6_1_Orange",
shape: "cell3_style6",
cells: 3,
set: null,
level: 0,
rarity: "orange",
mainStats: [],
substats: [{ stat: "CritBase", value: 0.03 }],
},
];
const next = editItem(state, "a1", {
level: 20,
substats: [{ stat: "CritBase", value: 0.06 }],
});
expect(next.db.items[0]!.level).toBe(20);
expect(next.db.items[0]!.substats[0]!.value).toBe(0.06);
expect(next.db.items[0]!.shape).toBe("cell3_style6");
// The original is untouched: state is replaced, never mutated.
expect(state.db.items[0]!.level).toBe(0);
});
it("cannot be used to change an item's identity", () => {
const state = emptyState();
state.db.items = [
{
instance: "a1", kind: "module", itemId: "x", shape: null, cells: null,
set: null, level: 0, rarity: "orange", mainStats: [], substats: [],
},
];
const next = editItem(state, "a1", { level: 5 } as never);
expect(next.db.items[0]!.instance).toBe("a1");
});
});

252
tests/domain.test.ts Normal file
View file

@ -0,0 +1,252 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { boardFromSlots, fillers, placements, tile } from "../src/domain/board.ts";
import { REQUIRED_PIECES, SET_IDS, SET_NAMES, activeTiers } from "../src/domain/cartridges.ts";
import { incompleteSets, setBonus, type SetBonusTable } from "../src/domain/setbonus.ts";
import { ICON_ORDER, SHAPES, renderShape, sizeOf } from "../src/domain/shapes.ts";
import { pieceColour, renderBoard } from "../src/domain/render.ts";
import { cellsOfPiece, loadTilings, multisetOf, type RawTilings } from "../src/domain/tilings.ts";
const gamedata = JSON.parse(readFileSync("src/generated/gamedata.json", "utf8"));
const tilings = loadTilings(
JSON.parse(readFileSync("src/generated/tilings.json", "utf8")) as RawTilings,
);
describe("shapes", () => {
it("has exactly the twelve obtainable shapes", () => {
expect(Object.keys(SHAPES).sort()).toEqual([...ICON_ORDER].sort());
expect(ICON_ORDER).toHaveLength(12);
});
it("names each shape after its own cell count", () => {
for (const shape of ICON_ORDER) {
expect(sizeOf(shape)).toBe(Number(shape.slice(4, 5)));
}
});
it("keeps the S and Z tetrominoes distinct", () => {
// Rotating style5 gives 10/11/01, which is not style6: these are mirror
// images, not one piece in two orientations.
expect(renderShape("cell4_style5")).toEqual(["011", "110"]);
expect(renderShape("cell4_style6")).toEqual(["01", "11", "10"]);
});
it("anchors every shape at the top-left corner", () => {
for (const shape of ICON_ORDER) {
const cells = SHAPES[shape];
expect(Math.min(...cells.map(([r]) => r))).toBe(0);
expect(Math.min(...cells.map(([, c]) => c))).toBe(0);
}
});
});
describe("cartridge sets", () => {
it("requires four distinct shapes totalling twelve cells", () => {
for (const set of SET_NAMES) {
const required = REQUIRED_PIECES[set];
expect(new Set(required).size).toBe(4);
expect(required.reduce((sum, shape) => sum + sizeOf(shape), 0)).toBe(12);
}
});
it("counts distinct shapes, not copies, toward a tier", () => {
const [first, second] = REQUIRED_PIECES["Shadow Creed"];
expect(activeTiers("Shadow Creed", [first!, first!, first!])).toEqual([]);
expect(activeTiers("Shadow Creed", [first!, second!])).toEqual([2]);
expect(activeTiers("Shadow Creed", REQUIRED_PIECES["Shadow Creed"])).toEqual([2, 4]);
});
});
describe("boards", () => {
const boards = gamedata.espers.map((esper: { slots: number[][] }) =>
boardFromSlots(esper.slots),
);
it("gives every character twenty free cells", () => {
for (const board of boards) expect(board.cells).toHaveLength(20);
});
it("collapses the roster onto four distinct grids", () => {
expect(new Set(boards.map((b: { cells: number[] }) => b.cells.join(","))).size).toBe(4);
});
it("never places a shape off the right-hand edge", () => {
// A naive translation wraps a wide piece onto the next row; every cell of
// every placement must stay in its own row.
for (const shape of ICON_ORDER) {
for (const spot of placements(shape, boards[0]!)) {
const cols = spot.map((cell) => cell % 7);
expect(Math.max(...cols) - Math.min(...cols)).toBeLessThan(4);
}
}
});
it("finds sixty-nine filler multisets for the eight spare cells", () => {
const found = fillers(8);
expect(found).toHaveLength(69);
for (const combo of found) {
expect(combo.reduce((sum, shape) => sum + sizeOf(shape), 0)).toBe(8);
}
});
it("refuses a multiset that cannot cover the board", () => {
expect(tile(boards[0]!, ["cell2_style1", "cell2_style1"])).toBeNull();
});
});
describe("precomputed tilings", () => {
it("reproduces the Python search exactly", () => {
const total = tilings.reduce(
(sum, board) => sum + [...board.bySet.values()].reduce((n, list) => n + list.length, 0),
0,
);
expect(tilings).toHaveLength(4);
expect(total).toBe(1297);
});
it("builds are six, seven or eight pieces - never always seven", () => {
const counts = new Map<number, number>();
for (const board of tilings) {
for (const list of board.bySet.values()) {
for (const tiling of list) {
counts.set(tiling.pieces.length, (counts.get(tiling.pieces.length) ?? 0) + 1);
}
}
}
expect([...counts.entries()].sort((a, b) => a[0] - b[0])).toEqual([
[6, 30],
[7, 1102],
[8, 165],
]);
});
it("decodes back into a genuine exact cover", () => {
for (const board of tilings) {
for (const list of board.bySet.values()) {
for (const tiling of list) {
const covered = new Set<number>();
tiling.pieces.forEach((shape, index) => {
const cells = cellsOfPiece(tiling, index);
expect(cells).toHaveLength(sizeOf(shape));
for (const cell of cells) {
expect(covered.has(cell)).toBe(false);
covered.add(cell);
}
});
expect([...covered].sort((a, b) => a - b)).toEqual(board.cells);
}
}
}
});
it("every packing carries all four of its set's required shapes", () => {
for (const board of tilings) {
for (const [set, list] of board.bySet) {
for (const tiling of list) {
const present = multisetOf(tiling);
for (const shape of REQUIRED_PIECES[set]) {
expect(present.get(shape) ?? 0).toBeGreaterThan(0);
}
expect(activeTiers(set, tiling.pieces)).toEqual([2, 4]);
}
}
}
});
it("leaves some (board, set) pairs unbuildable", () => {
// Shadow Creed on one board admits only three fillers; the UI needs a
// defined "no valid full-set build" state because of exactly this.
const smallest = Math.min(
...tilings.flatMap((board) => [...board.bySet.values()].map((list) => list.length)),
);
expect(smallest).toBeLessThan(10);
});
});
describe("set bonuses", () => {
const table = JSON.parse(
readFileSync("data-src/set-bonuses.json", "utf8"),
) as SetBonusTable;
it("covers all twelve sets", () => {
expect(Object.keys(table.sets).sort()).toEqual([...SET_NAMES].sort());
});
it("names the three unowned sets by their real ids", () => {
// Confirmed from everness's cartridge boxes. Blood/Night/Kingdom was a
// guess and was wrong on all three.
expect(SET_IDS["Psyche_orange"]).toBe("Devil's Blood: Curse");
expect(SET_IDS["Shield_orange"]).toBe("Kingdom's Guard");
expect(SET_IDS["Heal_orange"]).toBe("Thea's Night Tavern");
expect(Object.keys(SET_IDS)).toHaveLength(12);
});
it("contributes nothing rather than a guess while values are unmeasured", () => {
const required = REQUIRED_PIECES["Shadow Creed"];
const result = setBonus(table, "Shadow Creed", required);
expect(result.tiers).toEqual([2, 4]);
expect(result.unknownTiers).toEqual([2, 4]);
expect([...result.vector].every((value) => value === 0)).toBe(true);
});
it("reports no active tier when the board misses the shapes", () => {
const result = setBonus(table, "Shadow Creed", ["cell3_style1"]);
expect(result.tiers).toEqual([]);
expect(result.unknownTiers).toEqual([]);
});
it("flags every set as incomplete until the values are filled in", () => {
expect(incompleteSets(table)).toHaveLength(12);
});
});
describe("board rendering", () => {
it("borders only the sides facing a different piece", () => {
// A horizontal domino: the join between the two cells carries no border.
const render = renderBoard([0, 1], [0, 0]);
const [left, right] = render.cells;
expect(left!.right).toBe(false);
expect(right!.left).toBe(false);
expect(left!.left).toBe(true);
expect(left!.top).toBe(true);
expect(right!.right).toBe(true);
});
it("borders the join between two different pieces", () => {
const render = renderBoard([0, 1], [0, 1]);
expect(render.cells[0]!.right).toBe(true);
expect(render.cells[1]!.left).toBe(true);
});
it("treats a blocked neighbour as an edge", () => {
// Cell 2 is not on the board, so cell 1's right side is an outer edge.
const render = renderBoard([0, 1], [0, 0]);
expect(render.cells[1]!.right).toBe(true);
});
it("does not wrap a border round the end of a row", () => {
// Cells 6 and 7 are adjacent in index but sit in different rows.
const render = renderBoard([6, 7], [0, 0]);
expect(render.cells[0]!.right).toBe(true);
expect(render.cells[1]!.left).toBe(true);
});
it("renders a real packing as one block per piece", () => {
const board = tilings[0]!;
const [tiling] = [...board.bySet.values()][0]!;
const render = renderBoard(tiling!.cells, tiling!.placement);
expect(render.cells).toHaveLength(20);
// Every cell belongs to a piece, and every piece is contiguous enough that
// its internal joins are unbordered.
const internal = render.cells.filter((c) => !c.top || !c.right || !c.bottom || !c.left);
expect(internal.length).toBe(20);
expect(new Set(render.cells.map((c) => c.piece)).size).toBe(tiling!.pieces.length);
});
it("gives every piece of a build a distinct colour", () => {
const colours = new Set([0, 1, 2, 3, 4, 5, 6, 7].map((i) => pieceColour(i, 8)));
expect(colours.size).toBe(8);
expect(pieceColour(-1, 8)).toBe("transparent");
});
});

100
tests/guides.test.ts Normal file
View file

@ -0,0 +1,100 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import {
TOP_WEIGHT,
guideFor,
targetsFromGuide,
variantFor,
weightForRank,
type GuideTable,
} from "../src/domain/guides.ts";
const table = JSON.parse(readFileSync("data-src/guides.json", "utf8")) as GuideTable;
const variant = {
name: "Main DPS",
targets: [
{ stat: "CritBase", target: 0.7 },
{ stat: "CritDamageBase", target: 2.0 },
{ stat: "AtkUp", target: 0.5 },
{ stat: "HPMaxUp", target: 0.3 },
],
priority: ["CritDamageBase", "CritBase", "AtkUp"],
};
describe("rank to weight", () => {
it("puts the top rank highest and keeps the last one meaningful", () => {
expect(weightForRank(0, 3)).toBe(TOP_WEIGHT);
expect(weightForRank(2, 3)).toBe(1);
// The bottom of the ranking is where a shortfall should land, not a stat to
// ignore entirely.
expect(weightForRank(2, 3)).toBeGreaterThan(0);
});
it("descends monotonically", () => {
const weights = [0, 1, 2, 3, 4].map((rank) => weightForRank(rank, 5));
for (let i = 1; i < weights.length; i += 1) {
expect(weights[i]!).toBeLessThan(weights[i - 1]!);
}
});
it("handles a single ranked stat", () => {
expect(weightForRank(0, 1)).toBe(TOP_WEIGHT);
});
});
describe("guide to targets", () => {
it("weights targets by their place in the ranking", () => {
const targets = targetsFromGuide(variant);
const byStat = new Map(targets.map((entry) => [entry.stat, entry]));
expect(byStat.get("CritDamageBase")!.weight).toBe(TOP_WEIGHT);
expect(byStat.get("CritDamageBase")!.weight).toBeGreaterThan(
byStat.get("CritBase")!.weight,
);
expect(byStat.get("CritBase")!.weight).toBeGreaterThan(byStat.get("AtkUp")!.weight);
});
it("keeps a target that the ranking does not mention", () => {
const targets = targetsFromGuide(variant);
const hp = targets.find((entry) => entry.stat === "HPMaxUp")!;
expect(hp.weight).toBe(1);
expect(hp.target).toBe(0.3);
});
it("drops a stat the model has no slot for", () => {
const targets = targetsFromGuide({
name: "x",
targets: [{ stat: "NotAStat", target: 1 }],
priority: ["NotAStat"],
});
expect(targets).toEqual([]);
});
});
describe("guide lookup", () => {
it("returns null for a character with no published guide", () => {
expect(guideFor(table, "Zankou")).toBeNull();
expect(variantFor(null, null)).toBeNull();
});
it("falls back to the first variant when the named one is gone", () => {
const guide = {
key: "Zankou",
source: "x",
updated: "2026-08-21",
variants: [variant, { ...variant, name: "SubDPS" }],
};
expect(variantFor(guide, "SubDPS")!.name).toBe("SubDPS");
// A variant that was renamed upstream must not blank the character out.
expect(variantFor(guide, "Removed")!.name).toBe("Main DPS");
expect(variantFor(guide, null)!.name).toBe("Main DPS");
});
it("ships an empty table rather than invented guidance", () => {
// Prydwen's data could not be obtained; a plausible-looking guess would be
// worse than none, because the player could not tell it apart from real.
expect(table.characters).toEqual([]);
expect(table.format).toBe("nte-guides");
});
});

197
tests/host.test.ts Normal file
View 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();
});
});

171
tests/import.test.ts Normal file
View 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");
});
});

29
tests/router.test.ts Normal file
View file

@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { hashFor, parseHash, TABS } from "../src/ui/router.ts";
describe("hash routing", () => {
it("round-trips every tab", () => {
for (const tab of TABS) {
expect(parseHash(hashFor(tab))).toEqual({ tab, id: null });
}
});
it("carries a character id, encoded", () => {
const hash = hashFor("characters", "Chiichan073");
expect(parseHash(hash)).toEqual({ tab: "characters", id: "Chiichan073" });
// A name with a slash must not read as a third path segment.
expect(parseHash(hashFor("characters", "a/b"))).toEqual({ tab: "characters", id: "a/b" });
});
it("falls back to characters for anything unrecognised", () => {
expect(parseHash("").tab).toBe("characters");
expect(parseHash("#/nonsense").tab).toBe("characters");
expect(parseHash("#").tab).toBe("characters");
});
it("accepts a hash with or without the leading slash", () => {
expect(parseHash("#team").tab).toBe("team");
expect(parseHash("#/team").tab).toBe("team");
});
});

147
tests/scoring.test.ts Normal file
View file

@ -0,0 +1,147 @@
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);
});
});

91
tests/sheet.test.ts Normal file
View file

@ -0,0 +1,91 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { compareSheet, predictSheet } from "../src/domain/sheet.ts";
import { emptyVector, slotOf } from "../src/domain/statvec.ts";
const gamedata = JSON.parse(readFileSync("src/generated/gamedata.json", "utf8"));
const zankou = gamedata.espers.find((esper: { name: string }) => esper.name === "Zankou");
const line = (lines: ReturnType<typeof predictSheet>, key: string) =>
lines.find((entry) => entry.key === key)!;
describe("predicted sheet", () => {
it("reproduces the measured off-team CRIT numbers", () => {
// Zankou off-team: 0.05 base + 0.24 Arc main + 0.30 gear = 0.59.
const total = emptyVector();
total[slotOf("CritBase")] = 0.54;
total[slotOf("CritDamageBase")] = 1.5;
const lines = predictSheet(zankou, 70, total);
expect(line(lines, "crit").predicted).toBeCloseTo(0.59, 6);
expect(line(lines, "critDamage").predicted).toBeCloseTo(2.0, 6);
});
it("reproduces her measured level-70 base HP with no gear", () => {
const lines = predictSheet(zankou, 70, emptyVector());
expect(line(lines, "hp").predicted).toBe(13466);
expect(line(lines, "def").predicted).toBe(789);
});
it("scales the Arc's flat ATK with ATK% but not gear's", () => {
const total = emptyVector();
total[slotOf("AtkBase")] = 570;
total[slotOf("AtkUp")] = 0.125;
total[slotOf("AtkAdd")] = 420;
// (577 + 570) * 1.125 + 420 = 1710.375
expect(Math.floor(line(predictSheet(zankou, 70, total), "atk").predicted!)).toBe(1710);
});
it("refuses to guess where the model has never been measured", () => {
// Level 80 is the level builds actually use, and the multiplier for it has
// never been read. Saying so beats inventing a number.
const lines = predictSheet(zankou, 80, emptyVector());
expect(line(lines, "hp").predicted).toBeNull();
expect(line(lines, "hp").unavailable).toContain("not measured at level 80");
// Stats that do not depend on the multiplier still work.
expect(line(lines, "crit").predicted).toBeCloseTo(0.05, 6);
});
it("says so when the character's level is unknown", () => {
const lines = predictSheet(zankou, null, emptyVector());
expect(line(lines, "atk").unavailable).toBe("character level unknown");
});
it("starts cycle intensity and charge efficiency at their bases", () => {
const lines = predictSheet(zankou, 70, emptyVector());
expect(line(lines, "cycle").predicted).toBe(100);
expect(line(lines, "charge").predicted).toBe(1);
});
});
describe("drift against a real sheet", () => {
const lines = predictSheet(zankou, 70, emptyVector());
it("reports no drift when the reading matches", () => {
const compared = compareSheet(lines, { hp: 13466 });
const hp = compared.find((entry) => entry.key === "hp")!;
expect(hp.delta).toBe(0);
expect(hp.drifted).toBe(false);
});
it("flags a gap larger than rounding", () => {
const compared = compareSheet(lines, { hp: 13000 });
expect(compared.find((entry) => entry.key === "hp")!.drifted).toBe(true);
});
it("tolerates a one-point difference from a truncated display", () => {
const compared = compareSheet(lines, { hp: 13465 });
expect(compared.find((entry) => entry.key === "hp")!.drifted).toBe(false);
});
it("compares a percentage on its own scale", () => {
// 1.0 of tolerance would make every percentage look correct.
const compared = compareSheet(lines, { crit: 0.2 });
expect(compared.find((entry) => entry.key === "crit")!.drifted).toBe(true);
});
it("leaves a line alone when nothing was measured for it", () => {
const compared = compareSheet(lines, {});
expect(compared.every((entry) => entry.actual === null && !entry.drifted)).toBe(true);
});
});

249
tests/solver.test.ts Normal file
View file

@ -0,0 +1,249 @@
import { describe, expect, it } from "vitest";
import { buildPool, type ExportedItem } from "../src/domain/items.ts";
import { compile } from "../src/domain/scoring.ts";
import type { SetBonusTable } from "../src/domain/setbonus.ts";
import { emptyVector } from "../src/domain/statvec.ts";
import type { Tiling } from "../src/domain/tilings.ts";
import { solveSingle } from "../src/solver/single.ts";
import type { SolveProgress, SolveResult } from "../src/solver/protocol.ts";
const bonuses: SetBonusTable = {
format: "nte-set-bonuses",
format_version: 1,
sets: {
"Shadow Creed": { "2": { unknown: true, stats: [] }, "4": { unknown: true, stats: [] } },
},
};
const scoring = compile({
targets: [
{ stat: "CritBase", target: 0.2, weight: 1 },
{ stat: "CritDamageBase", target: 0.4, weight: 1 },
],
});
let counter = 0;
function module_(shape: string, crit: number, critDamage: number): ExportedItem {
counter += 1;
return {
instance: `m${counter}`,
kind: "module",
item_id: `${shape}_1_Orange`,
shape,
level: 20,
rarity: "orange",
main_stats: [],
substats: [
{ stat: "CritBase", value: crit },
{ stat: "CritDamageBase", value: critDamage },
],
owner_group: null,
};
}
function cartridge(mainStat: string, value: number): ExportedItem {
counter += 1;
return {
instance: `c${counter}`,
kind: "cartridge",
item_id: "Attack_orange",
set: "Shadow Creed",
level: 20,
rarity: "orange",
main_stats: [{ stat: mainStat, value }],
substats: [],
owner_group: null,
};
}
/** A packing needing two of one shape and one of another. */
const tiling = (pieces: string[]): Tiling =>
({ set: "Shadow Creed", pieces, cells: [], placement: [] }) as unknown as Tiling;
function run(request: Parameters<typeof solveSingle>[0]): {
result: SolveResult;
progress: SolveProgress[];
} {
const generator = solveSingle(request);
const progress: SolveProgress[] = [];
let step = generator.next();
while (!step.done) {
progress.push(step.value);
step = generator.next();
}
return { result: step.value, progress };
}
describe("single-character solve", () => {
const items = [
module_("cell2_style1", 0.02, 0.04),
module_("cell2_style1", 0.06, 0.12), // clearly the best of its shape
module_("cell2_style1", 0.03, 0.06),
module_("cell3_style1", 0.01, 0.02),
module_("cell3_style1", 0.05, 0.10), // and of its shape
cartridge("CritDamageBase", 0.12),
];
const pool = buildPool(items);
const base = () => emptyVector();
it("picks the best module of each shape", () => {
const { result } = run({
pool,
tilings: [tiling(["cell2_style1", "cell3_style1"])],
base: base(),
trait: null,
setBonuses: bonuses,
scoring,
});
const best = result.best!;
expect(best.modules.map((index) => pool.items[index]!.instance).sort()).toEqual(["m2", "m5"]);
expect(best.proven).toBe(true);
});
it("never uses the same module twice", () => {
const { result } = run({
pool,
tilings: [tiling(["cell2_style1", "cell2_style1", "cell3_style1"])],
base: base(),
trait: null,
setBonuses: bonuses,
scoring,
});
const best = result.best!;
expect(best.modules).toHaveLength(3);
expect(new Set(best.modules).size).toBe(3);
// Two copies of a shape must take its two best, not its best twice.
expect(best.modules.map((index) => pool.items[index]!.instance).sort()).toEqual([
"m2",
"m3",
"m5",
]);
});
it("returns one module per piece in the packing", () => {
for (const pieces of [
["cell2_style1", "cell3_style1"],
["cell2_style1", "cell2_style1", "cell3_style1"],
]) {
const { result } = run({
pool,
tilings: [tiling(pieces)],
base: base(),
trait: null,
setBonuses: bonuses,
scoring,
});
expect(result.best!.modules).toHaveLength(pieces.length);
}
});
it("respects items held by other characters", () => {
const { result } = run({
pool,
tilings: [tiling(["cell2_style1", "cell3_style1"])],
base: base(),
trait: null,
setBonuses: bonuses,
scoring,
excluded: new Set([1]), // m2, the best cell2
});
const chosen = result.best!.modules.map((index) => pool.items[index]!.instance);
expect(chosen).not.toContain("m2");
expect(chosen.sort()).toEqual(["m3", "m5"]);
});
it("reports a packing as unbuildable when no cartridge of its set is owned", () => {
const moduleOnly = buildPool(items.filter((item) => item.kind === "module"));
const { result } = run({
pool: moduleOnly,
tilings: [tiling(["cell2_style1", "cell3_style1"])],
base: base(),
trait: null,
setBonuses: bonuses,
scoring,
});
expect(result.unbuildable).toBe(true);
expect(result.best).toBeNull();
});
it("skips a packing that needs more copies of a shape than exist", () => {
const { result } = run({
pool,
tilings: [tiling(["cell3_style1", "cell3_style1", "cell3_style1"])],
base: base(),
trait: null,
setBonuses: bonuses,
scoring,
});
expect(result.best).toBeNull();
// The packing was reachable, so this is "no build", not "no packing".
expect(result.unbuildable).toBe(false);
});
it("yields progress with a real denominator and a usable best at every step", () => {
const { result, progress } = run({
pool,
tilings: [
tiling(["cell2_style1", "cell3_style1"]),
tiling(["cell2_style1", "cell2_style1", "cell3_style1"]),
],
base: base(),
trait: null,
setBonuses: bonuses,
scoring,
});
expect(progress.length).toBeGreaterThan(0);
for (const step of progress) {
expect(step.total).toBe(2);
expect(step.done).toBeLessThanOrEqual(step.total);
// Anytime: any yielded best is already a complete, valid build.
if (step.best) expect(step.best.modules.length).toBe(step.best.tiling.pieces.length);
}
expect(result.examined).toBe(2);
});
it("carries the unmeasured set tiers through to the build", () => {
// Shadow Creed needs cell2_style1, cell2_style2, cell4_style1 and
// cell4_style6; two of them present is the (2) bonus.
const withRequired = buildPool([...items, module_("cell2_style2", 0.05, 0.1)]);
const { result } = run({
pool: withRequired,
tilings: [tiling(["cell2_style1", "cell2_style2"])],
base: base(),
trait: null,
setBonuses: bonuses,
scoring,
});
// The tier is active and its value is unknown, which the build has to admit
// rather than quietly scoring it as zero.
expect(result.best!.unknownTiers).toEqual([2]);
});
it("reports no tier at all when the shapes do not qualify", () => {
const { result } = run({
pool,
tilings: [tiling(["cell2_style1", "cell3_style1"])],
base: base(),
trait: null,
setBonuses: bonuses,
scoring,
});
// Only one of Shadow Creed's required shapes is on the board.
expect(result.best!.unknownTiers).toEqual([]);
});
it("counts a character's own base contribution", () => {
const withBase = emptyVector();
withBase[6] = 0.2; // CritBase already at target
const { result } = run({
pool,
tilings: [tiling(["cell2_style1", "cell3_style1"])],
base: withBase,
trait: null,
setBonuses: bonuses,
scoring,
});
expect(result.best!.score).toBeGreaterThan(0.5);
});
});

94
tests/stats.test.ts Normal file
View file

@ -0,0 +1,94 @@
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);
});
});

188
tests/team.test.ts Normal file
View file

@ -0,0 +1,188 @@
import { describe, expect, it } from "vitest";
import { bitsetOf, disjoint, members, overlapCount } from "../src/solver/bitset.ts";
import { diversify, itemsOf, type Portfolio } from "../src/solver/portfolio.ts";
import { compareLeximin, solveTeam } from "../src/solver/team.ts";
import type { Build } from "../src/solver/protocol.ts";
import type { Tiling } from "../src/domain/tilings.ts";
const POOL = 64;
const tiling = { set: "Shadow Creed", pieces: [], cells: [], placement: [] } as unknown as Tiling;
function build(score: number, cartridge: number, modules: number[]): Build {
return {
cartridge,
modules,
tiling,
score,
vector: new Float32Array(0),
proven: true,
unknownTiers: [],
};
}
const portfolio = (key: string, builds: Build[]): Portfolio => ({
key,
entries: builds.map((b) => ({ build: b, items: itemsOf(b, POOL) })),
unbuildable: false,
});
function run(request: Parameters<typeof solveTeam>[0]) {
const generator = solveTeam(request);
const progress = [];
let step = generator.next();
while (!step.done) {
progress.push(step.value);
step = generator.next();
}
return { result: step.value, progress };
}
describe("bitsets", () => {
it("round-trips membership", () => {
const set = bitsetOf(POOL, [0, 5, 33, 63]);
expect(members(set)).toEqual([0, 5, 33, 63]);
});
it("detects sharing across word boundaries", () => {
expect(disjoint(bitsetOf(POOL, [1, 2]), bitsetOf(POOL, [3, 4]))).toBe(true);
expect(disjoint(bitsetOf(POOL, [1, 33]), bitsetOf(POOL, [33]))).toBe(false);
expect(overlapCount(bitsetOf(POOL, [1, 33, 40]), bitsetOf(POOL, [33, 40, 50]))).toBe(2);
});
});
describe("portfolio diversity", () => {
it("prefers a spread over near-duplicates of the best build", () => {
const builds = [
build(1.0, 0, [1, 2, 3]),
build(0.99, 0, [1, 2, 4]), // near-duplicate of the best
build(0.9, 10, [11, 12, 13]), // shares nothing
];
const kept = diversify(builds, POOL, 2);
expect(kept.map((entry) => entry.build.score)).toEqual([1.0, 0.9]);
});
it("keeps everything when the portfolio is smaller than the cap", () => {
const builds = [build(1.0, 0, [1]), build(0.5, 2, [3])];
expect(diversify(builds, POOL, 10)).toHaveLength(2);
});
});
describe("leximin comparison", () => {
it("maximises the worst entry first", () => {
expect(compareLeximin([0.9, 0.5], [0.8, 0.6])).toBeLessThan(0);
expect(compareLeximin([0.7, 0.7], [0.9, 0.5])).toBeGreaterThan(0);
expect(compareLeximin([0.6, 0.8], [0.8, 0.6])).toBe(0);
});
});
describe("team solve", () => {
it("never gives one character an item another is using", () => {
const a = portfolio("A", [build(1.0, 0, [1, 2]), build(0.6, 10, [11, 12])]);
const b = portfolio("B", [build(0.9, 0, [1, 2]), build(0.5, 20, [21, 22])]);
const { result } = run({ portfolios: [a, b], poolSize: POOL });
const used = result.assignment.flatMap((entry) => [
entry.build.cartridge,
...entry.build.modules,
]);
expect(new Set(used).size).toBe(used.length);
});
it("lifts the worst character rather than the total", () => {
// Sum would pick 1.0 + 0.5 = 1.5; leximin picks 0.6 + 0.9 = 1.5 with a
// much better floor.
const a = portfolio("A", [build(1.0, 0, [1, 2]), build(0.6, 10, [11, 12])]);
const b = portfolio("B", [build(0.9, 0, [1, 2]), build(0.5, 20, [21, 22])]);
const { result } = run({ portfolios: [a, b], poolSize: POOL });
expect(Math.min(...result.sorted)).toBeCloseTo(0.6, 6);
});
it("gives generation order no advantage", () => {
const a = portfolio("A", [build(1.0, 0, [1, 2]), build(0.6, 10, [11, 12])]);
const b = portfolio("B", [build(0.9, 0, [1, 2]), build(0.5, 20, [21, 22])]);
const forward = run({ portfolios: [a, b], poolSize: POOL }).result;
const backward = run({ portfolios: [b, a], poolSize: POOL }).result;
expect(forward.sorted).toEqual(backward.sorted);
});
it("reports characters with no valid build instead of dropping them", () => {
const a = portfolio("A", [build(1.0, 0, [1, 2])]);
const none: Portfolio = { key: "B", entries: [], unbuildable: true };
const { result } = run({ portfolios: [a, none], poolSize: POOL });
expect(result.unbuildable).toEqual(["B"]);
expect(result.assignment.map((entry) => entry.key)).toEqual(["A"]);
});
it("yields a complete, conflict-free assignment at every step", () => {
const a = portfolio("A", [build(1.0, 0, [1, 2]), build(0.6, 10, [11, 12])]);
const b = portfolio("B", [build(0.9, 0, [1, 2]), build(0.5, 20, [21, 22])]);
const { progress } = run({ portfolios: [a, b], poolSize: POOL });
expect(progress.length).toBeGreaterThan(0);
for (const step of progress) {
expect(step.assignment).toHaveLength(2);
const used = step.assignment.flatMap((entry) => [
entry.build.cartridge,
...entry.build.modules,
]);
expect(new Set(used).size).toBe(used.length);
}
});
it("uses drag order only to break a tie", () => {
const a = portfolio("A", [build(0.8, 0, [1, 2])]);
const b = portfolio("B", [build(0.8, 10, [11, 12])]);
const { result } = run({ portfolios: [a, b], poolSize: POOL, dragOrder: ["B", "A"] });
expect(result.assignment.map((entry) => entry.key)).toEqual(["B", "A"]);
// The tie-break reorders the report; it does not change the outcome.
expect(result.sorted).toEqual([0.8, 0.8]);
});
it("seeds a team when the portfolios share every item", () => {
// Both characters only ever want the same three items, so no conflict-free
// selection exists in the portfolios at all - the case that made a real
// four-character solve return nothing.
const a = portfolio("A", [build(1.0, 0, [1, 2])]);
const b = portfolio("B", [build(0.9, 0, [1, 2])]);
const withoutSeed = run({ portfolios: [a, b], poolSize: POOL }).result;
expect(withoutSeed.infeasible).toBe(true);
expect(withoutSeed.assignment).toEqual([]);
const seeded = run({
portfolios: [portfolio("A", [build(1.0, 0, [1, 2])]), portfolio("B", [build(0.9, 0, [1, 2])])],
poolSize: POOL,
resolve: (key, excluded) =>
excluded.has(0) ? [build(0.4, 10, [11, 12])] : [build(0.9, 0, [1, 2])],
}).result;
expect(seeded.infeasible).toBe(false);
expect(seeded.assignment).toHaveLength(2);
const used = seeded.assignment.flatMap((e) => [e.build.cartridge, ...e.build.modules]);
expect(new Set(used).size).toBe(used.length);
});
it("reports infeasible rather than an empty team when it cannot seed", () => {
const a = portfolio("A", [build(1.0, 0, [1, 2])]);
const b = portfolio("B", [build(0.9, 0, [1, 2])]);
const { result } = run({ portfolios: [a, b], poolSize: POOL, resolve: () => [] });
expect(result.infeasible).toBe(true);
expect(result.unbuildable).toEqual([]);
});
it("improves the vector by column generation and stops when it cannot", () => {
const a = portfolio("A", [build(1.0, 0, [1, 2])]);
const b = portfolio("B", [build(0.2, 10, [11, 12])]);
let calls = 0;
const { result } = run({
portfolios: [a, b],
poolSize: POOL,
resolve: (key) => {
calls += 1;
// A better, still conflict-free build turns up for the worst-off.
return key === "B" && calls <= 2 ? [build(0.7, 30, [31, 32])] : [];
},
});
expect(Math.min(...result.sorted)).toBeCloseTo(0.7, 6);
expect(result.rounds).toBeGreaterThan(0);
});
});