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
88
src/solver/portfolio.ts
Normal file
88
src/solver/portfolio.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue