nte-optimizer/src/ui/DataPanel.tsx
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

196 lines
6 KiB
TypeScript

/**
* Import and export.
*
* Two separate things, labelled separately because they are not the same and
* confusing them loses work:
*
* - **Import gear capture** replaces what you own, from the game.
* - **Import database** replaces your whole workspace, from a file this app
* wrote - including everything you configured by hand.
*
* A capture import is validated first and shown as a diff before it is applied,
* because it is deliberate and irreversible. `Restore pre-import state` is the
* way back, and is deliberately not called undo: it is one snapshot, not a
* history.
*/
import { useRef, useState } from "react";
import { fileName } from "../db/dbfile.ts";
import type { ImportResult } from "../db/import.ts";
import { useAppState, useStore } from "./useStore.ts";
async function readJson(file: File): Promise<unknown> {
return JSON.parse(await file.text());
}
/**
* Hand the browser a file.
*
* A blob URL and a click, revoked afterwards - the same call works in the
* hosted build and is the only save path the `file://` build has at all, since
* it cannot reach IndexedDB.
*/
function download(name: string, contents: string): void {
const url = URL.createObjectURL(new Blob([contents], { type: "application/json" }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = name;
anchor.click();
URL.revokeObjectURL(url);
}
export function DataPanel({ gamedataVersion }: { gamedataVersion: string | null }) {
const store = useStore();
const state = useAppState();
const [pending, setPending] = useState<ImportResult | null>(null);
const [problems, setProblems] = useState<string[]>([]);
const [note, setNote] = useState<string | null>(null);
const gearInput = useRef<HTMLInputElement>(null);
const dbInput = useRef<HTMLInputElement>(null);
const reviewGear = async (file: File) => {
setProblems([]);
setNote(null);
try {
setPending(store.reviewImport(await readJson(file)));
} catch (error) {
setProblems([error instanceof Error ? error.message : String(error)]);
}
};
const apply = async () => {
if (!pending?.ok) return;
await store.applyReviewedImport(pending);
setPending(null);
setNote("Gear imported.");
};
return (
<section className="data">
<h2>Data</h2>
<div className="actions">
<button onClick={() => gearInput.current?.click()}>Import gear capture</button>
<input
ref={gearInput}
type="file"
accept="application/json,.json"
hidden
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void reviewGear(file);
event.target.value = "";
}}
/>
<button
onClick={() =>
download(fileName(), JSON.stringify(store.exportFile(gamedataVersion)))
}
>
Export database
</button>
<button onClick={() => dbInput.current?.click()}>Import database</button>
<input
ref={dbInput}
type="file"
accept="application/json,.json"
hidden
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
void (async () => {
setNote(null);
try {
const found = await store.importFile(await readJson(file));
setProblems(found);
if (found.length === 0) setNote("Database restored.");
} catch (error) {
setProblems([error instanceof Error ? error.message : String(error)]);
}
})();
}
event.target.value = "";
}}
/>
{state.data.snapshot && (
<button
onClick={() => {
void store.restorePreImport().then((done) => {
setNote(done ? "Restored the state from before the last import." : null);
});
}}
>
Restore pre-import state
</button>
)}
</div>
{note && <p className="dim">{note}</p>}
{problems.length > 0 && (
<ul className="problem">
{problems.map((problem) => (
<li key={problem}>{problem}</li>
))}
</ul>
)}
{pending && <ImportReview result={pending} onApply={apply} onCancel={() => setPending(null)} />}
</section>
);
}
function ImportReview({
result,
onApply,
onCancel,
}: {
result: ImportResult;
onApply: () => Promise<void>;
onCancel: () => void;
}) {
const { report } = result;
return (
<div className="review">
<p>
<strong>
{report.expected ?? "?"} expected, {report.parsed} parsed, {report.rejected} rejected
</strong>
</p>
{result.ok ? (
<>
<p className="warning">
Importing replaces every item and every equipped loadout. Anything you equipped in
the app but have not applied in game will be lost. Your Arcs, targets, priorities and
group names are kept.
</p>
<div className="actions">
<button onClick={() => void onApply()}>Import</button>
<button onClick={onCancel}>Cancel</button>
</div>
</>
) : (
<>
{/* Fail closed: nothing at all is written when anything is wrong. */}
<p className="problem">Nothing was imported.</p>
<ul className="problem">
{report.problems.slice(0, 20).map((problem, index) => (
<li key={`${problem.code}-${index}`}>
{problem.code}: {problem.detail}
{problem.subject ? ` (${problem.subject})` : ""}
</li>
))}
</ul>
{report.problems.length > 20 && (
<p className="dim">…and {report.problems.length - 20} more.</p>
)}
<div className="actions">
<button onClick={onCancel}>Close</button>
</div>
</>
)}
</div>
);
}