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:
goober 2026-08-21 11:38:17 +03:00
commit 91952b1745
157 changed files with 92600 additions and 0 deletions

111
src/ui/CharactersTab.tsx Normal file
View file

@ -0,0 +1,111 @@
/**
* Characters, and one character's optimizer view.
*
* The list is every character the capture saw. Seven codenames have no everness
* record at all (`Chiichan073`, `Female051`, `Jin`, `Kuhara`, `Mint019`,
* `Mitsuki`, `Oneiroi`), so the card has to work without artwork, a grid or stat
* curves rather than assuming they exist.
*/
import { esperFor, type LoadedData } from "../state/gamedata.ts";
import { CharacterView } from "./CharacterView.tsx";
import { Icon } from "./Icon.tsx";
import { equipmentOf } from "../db/store.ts";
import { hashFor, type Route } from "./router.ts";
import { useAppState, useStore } from "./useStore.ts";
export function CharactersTab({
data,
route,
unnamedGroups,
}: {
data: LoadedData;
route: Route;
unnamedGroups: string[];
}) {
const state = useAppState();
const db = state.data.db;
if (route.id) return <CharacterView data={data} characterId={route.id} />;
return (
<section>
{unnamedGroups.length > 0 && (
<UnidentifiedGroups groups={unnamedGroups} data={data} />
)}
<div className="cards">
{db.characters.map((character) => {
const esper = esperFor(data.gamedata, character.characterId);
return (
<a
key={character.characterId}
className="card"
href={hashFor("characters", character.characterId)}
>
<span className="card-head">
<Icon entry={`esper:${character.characterId}`} alt={esper?.name ?? character.characterId} />
<strong>{esper?.name ?? character.characterId}</strong>
</span>
<span className="dim">
{esper ? esper.element : "no game data"} · level {character.level ?? "?"} ·{" "}
{character.breakthroughs ?? "?"} breakthroughs
</span>
<span className="dim">
{equipmentOf(db, character.characterId).length} pieces equipped
</span>
</a>
);
})}
</div>
</section>
);
}
/**
* The owner groups nobody has named.
*
* Seven of thirteen cannot be matched from the capture at all - three sets of
* characters share both their module shapes and their cartridge set - so the
* player names them once, keyed on values that are stable across exports.
*/
function UnidentifiedGroups({ groups, data }: { groups: string[]; data: LoadedData }) {
const store = useStore();
const state = useAppState();
const db = state.data.db;
return (
<div className="unidentified">
<h2>Unidentified groups</h2>
<p className="dim">
The capture says these items are worn together, but not by whom. Name each group once
and it sticks.
</p>
{groups.map((group) => {
const worn = db.equipment.filter((row) => row.ownerGroup === group);
const items = worn
.map((row) => db.items.find((item) => item.instance === row.instance))
.filter((item) => item !== undefined);
return (
<div key={group} className="group">
<code>{group.slice(0, 12)}</code>
<span className="dim">
{items.map((item) => item.shape ?? item.set).join(", ")}
</span>
<select
defaultValue=""
onChange={(event) => {
if (event.target.value) void store.nameGroup(group, event.target.value);
}}
>
<option value="">name this group…</option>
{data.gamedata.espers.map((esper) => (
<option key={esper.abilityKey} value={esper.abilityKey}>
{esper.name}
</option>
))}
</select>
</div>
);
})}
</div>
);
}