nte-optimizer/src/solver/portfolio.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

88 lines
3 KiB
TypeScript

/**
* Phase 0 of the team solve: a diverse portfolio per character.
*
* Every portfolio is generated against the **entire pool, conflicts ignored**.
* That is what makes the team phase anti-greedy structurally rather than
* heuristically: no character is solved after another has already taken items,
* so generation order confers no advantage at all.
*
* What is kept is not the best build repeated with cosmetic variation but a
* spread: builds that reuse items already covered are penalised, so the leximin
* phase has genuinely different options to trade between.
*/
import type { Build } from "./protocol.ts";
import { bitsetOf, type Bitset } from "./bitset.ts";
export interface PortfolioEntry {
build: Build;
/** The build's items - cartridge and modules - as a bitset over the pool. */
items: Bitset;
}
export interface Portfolio {
/** Whatever identifies the character to the caller. */
key: string;
entries: PortfolioEntry[];
/** True when the character has no valid full-set build at all. */
unbuildable: boolean;
}
export const DEFAULT_DIVERSITY = 0.15;
export function itemsOf(build: Build, poolSize: number): Bitset {
return bitsetOf(poolSize, [build.cartridge, ...build.modules]);
}
/**
* Trim a solve's builds to a spread of `size`.
*
* Greedy by `score - diversity * (mean usage of this build's items so far)`.
*
* The penalty counts **how many times** each item has already been kept, not
* merely whether it has been. An earlier version measured overlap against the
* union of everything kept, and that saturates: once the union is large every
* remaining build overlaps it completely, the penalty stops discriminating, and
* the portfolio collapses onto a few dozen items. Four characters then have no
* conflict-free selection between them at all - the team phase returns nothing.
*/
export function diversify(
builds: readonly Build[],
poolSize: number,
size: number,
diversity = DEFAULT_DIVERSITY,
): PortfolioEntry[] {
const remaining = builds
.map((build) => ({
entry: { build, items: itemsOf(build, poolSize) } as PortfolioEntry,
pieces: [build.cartridge, ...build.modules],
}))
.sort((a, b) => b.entry.build.score - a.entry.build.score);
const usage = new Uint16Array(poolSize);
const kept: PortfolioEntry[] = [];
const taken = new Uint8Array(remaining.length);
while (kept.length < size) {
let bestIndex = -1;
let bestValue = -Infinity;
for (let i = 0; i < remaining.length; i += 1) {
if (taken[i]) continue;
const candidate = remaining[i]!;
let used = 0;
for (const item of candidate.pieces) used += usage[item]!;
const value =
candidate.entry.build.score - (diversity * used) / candidate.pieces.length;
if (value > bestValue) {
bestValue = value;
bestIndex = i;
}
}
if (bestIndex < 0) break;
const chosen = remaining[bestIndex]!;
taken[bestIndex] = 1;
kept.push(chosen.entry);
for (const item of chosen.pieces) usage[item] = usage[item]! + 1;
}
return kept;
}