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
5ce255fa6f
157 changed files with 92600 additions and 0 deletions
128
tests/arcs.test.ts
Normal file
128
tests/arcs.test.ts
Normal 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
298
tests/db.test.ts
Normal 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
252
tests/domain.test.ts
Normal 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
100
tests/guides.test.ts
Normal 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
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();
|
||||
});
|
||||
});
|
||||
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");
|
||||
});
|
||||
});
|
||||
29
tests/router.test.ts
Normal file
29
tests/router.test.ts
Normal 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
147
tests/scoring.test.ts
Normal 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
91
tests/sheet.test.ts
Normal 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
249
tests/solver.test.ts
Normal 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
94
tests/stats.test.ts
Normal 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
188
tests/team.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue