nte-optimizer/tests/team.test.ts
goober 5ce255fa6f 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>
2026-08-21 11:38:17 +03:00

188 lines
7.4 KiB
TypeScript

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);
});
});