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
141bb2c751
157 changed files with 92605 additions and 0 deletions
98
vite.single.config.ts
Normal file
98
vite.single.config.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* 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]",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue