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>
249 lines
7.4 KiB
TypeScript
249 lines
7.4 KiB
TypeScript
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);
|
|
});
|
|
});
|