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>
98 lines
3.3 KiB
TypeScript
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]",
|
|
},
|
|
},
|
|
},
|
|
});
|