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
80
tools/fetch-icons.ts
Normal file
80
tools/fetch-icons.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* Downloads character and Arc artwork once, at build time.
|
||||
*
|
||||
* everness is never hotlinked at runtime: the app would break the moment their
|
||||
* paths changed or their CORS policy tightened, and it would be rude besides.
|
||||
* Files land in `public/img/` named by a content hash, so they can be served
|
||||
* `immutable` and a changed icon gets a new name for free.
|
||||
*
|
||||
* The single-file `file://` build does not carry these - inlining 72 images
|
||||
* would multiply its size for decoration. Cards fall back to the character's
|
||||
* name there, which is the information that actually matters.
|
||||
*
|
||||
* node --experimental-strip-types tools/fetch-icons.ts
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const outDir = join(root, "public", "img");
|
||||
const BASE = "https://everness.info/images";
|
||||
|
||||
interface Entry {
|
||||
key: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const gamedata = JSON.parse(readFileSync(join(root, "src/generated/gamedata.json"), "utf8"));
|
||||
|
||||
const entries: Entry[] = [
|
||||
...gamedata.espers.map((esper: { abilityKey: string; icon: string }) => ({
|
||||
key: `esper:${esper.abilityKey}`,
|
||||
path: esper.icon,
|
||||
})),
|
||||
...gamedata.arcs.map((arc: { id: string; icon: string }) => ({
|
||||
key: `arc:${arc.id}`,
|
||||
path: arc.icon,
|
||||
})),
|
||||
].filter((entry) => typeof entry.path === "string" && entry.path.length > 0);
|
||||
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
// Rebuilt wholesale, so a renamed icon cannot leave an orphan behind.
|
||||
for (const name of readdirSync(outDir)) rmSync(join(outDir, name));
|
||||
|
||||
const manifest: Record<string, string> = {};
|
||||
let bytes = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
const url = `${BASE}${entry.path}.webp`;
|
||||
try {
|
||||
const response = await fetch(url, { headers: { "user-agent": "nte-optimizer build" } });
|
||||
if (!response.ok) {
|
||||
failed += 1;
|
||||
console.warn(` ${response.status} ${url}`);
|
||||
continue;
|
||||
}
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
const hash = createHash("sha256").update(buffer).digest("hex").slice(0, 8);
|
||||
const slug = entry.path.split("/").pop() ?? "icon";
|
||||
const name = `${slug}-${hash}.webp`;
|
||||
writeFileSync(join(outDir, name), buffer);
|
||||
manifest[entry.key] = name;
|
||||
bytes += buffer.byteLength;
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
console.warn(` failed ${url}: ${error instanceof Error ? error.message : error}`);
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
join(root, "src", "generated", "icons.json"),
|
||||
`${JSON.stringify({ format: "nte-icons", format_version: 1, files: manifest }, null, 1)}\n`,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`icons ${Object.keys(manifest).length} of ${entries.length} fetched` +
|
||||
`${failed > 0 ? `, ${failed} failed` : ""} ${(bytes / 1024).toFixed(0)} KB`,
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue