nte-optimizer/vite.single.config.ts
goober 141bb2c751 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

98 lines
3.3 KiB
TypeScript

/**
* The `file://` build: one HTML file, everything inlined.
*
* Chrome gives a `file://` document an opaque origin, which costs it IndexedDB,
* workers, `fetch` and module scripts. So this build is a classic IIFE with the
* game data inlined rather than dynamically imported, and the worker aliased out
* entirely. The app degrades behind `PersistenceAdapter` and `SolverHost` and
* never sees the difference.
*/
import { readFileSync, writeFileSync, rmSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig, type Plugin } from "vite";
import react from "@vitejs/plugin-react";
const fromRoot = (relative: string): string =>
fileURLToPath(new URL(relative, import.meta.url));
const OUT_DIR = "dist/single";
const TARGET = "dist/nte.html";
/** Fold the emitted script and stylesheet into one document. */
function inlineEverything(): Plugin {
return {
name: "inline-everything",
writeBundle() {
const files = readdirSync(OUT_DIR, { recursive: true }) as string[];
const scripts = files.filter((name) => name.endsWith(".js"));
const styles = files.filter((name) => name.endsWith(".css"));
if (scripts.length !== 1) {
// More than one chunk means something escaped `inlineDynamicImports`,
// and a file:// page cannot fetch the second one.
throw new Error(`expected one script, got ${scripts.length}: ${scripts.join(", ")}`);
}
const script = readFileSync(join(OUT_DIR, scripts[0]!), "utf8");
const css = styles.map((name) => readFileSync(join(OUT_DIR, name), "utf8")).join("\n");
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>NTE gear optimizer</title>
<style>${css}</style>
</head>
<body>
<div id="root"></div>
<script>${script}</script>
</body>
</html>
`;
writeFileSync(TARGET, html);
rmSync(OUT_DIR, { recursive: true, force: true });
const kb = (html.length / 1024).toFixed(0);
// eslint-disable-next-line no-console
console.log(`${TARGET} ${kb} KB, one file, no external requests`);
},
};
}
export default defineConfig({
base: "./",
plugins: [react(), inlineEverything()],
resolve: {
// Matched against the whole import specifier and replaced with an absolute
// path: a regex that rewrites only part of a relative specifier resolves
// against the importer's directory and lands nowhere.
alias: [
{
find: /^\.\/worker-host\.ts$/,
replacement: fromRoot("src/solver/worker-host.single.ts"),
},
{
// No artwork travels with the single file, so the manifest is emptied
// rather than left to request images that are not there.
find: /^\.\.\/generated\/icons\.json$/,
replacement: fromRoot("src/generated/icons.empty.json"),
},
],
},
build: {
outDir: OUT_DIR,
emptyOutDir: true,
target: "es2022",
cssCodeSplit: false,
assetsInlineLimit: Number.MAX_SAFE_INTEGER,
rollupOptions: {
output: {
format: "iife",
inlineDynamicImports: true,
entryFileNames: "app.js",
assetFileNames: "app.[ext]",
},
},
},
});