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>
88 lines
3 KiB
TypeScript
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;
|
|
}
|