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
91952b1745
157 changed files with 92600 additions and 0 deletions
111
src/ui/CharactersTab.tsx
Normal file
111
src/ui/CharactersTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue