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
91952b1745
157 changed files with 92600 additions and 0 deletions
91
tools/check-guides.ts
Normal file
91
tools/check-guides.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* Validates `data-src/guides.json`.
|
||||
*
|
||||
* There is deliberately **no scraper**. Prydwen's character pages are
|
||||
* client-rendered, so a plain fetch returns navigation chrome, and their Gatsby
|
||||
* data endpoint answers `410 Gone`. Their robots.txt does allow content pages
|
||||
* with a ten second crawl-delay, so a headless render would be within policy -
|
||||
* but it is not something an unattended build step should be doing, and the plan
|
||||
* always called for this file to be human-reviewed and committed.
|
||||
*
|
||||
* So the tool checks what a person typed instead: that every stat id has a slot,
|
||||
* that targets look like the units the model expects, and that keys join to real
|
||||
* characters.
|
||||
*
|
||||
* node --experimental-strip-types tools/check-guides.ts
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import type { GuideTable } from "../src/domain/guides.ts";
|
||||
import { isKnownStat } from "../src/domain/statvec.ts";
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const guides = JSON.parse(
|
||||
readFileSync(join(root, "data-src/guides.json"), "utf8"),
|
||||
) as GuideTable;
|
||||
const gamedata = JSON.parse(readFileSync(join(root, "src/generated/gamedata.json"), "utf8"));
|
||||
const keys = new Set<string>(
|
||||
gamedata.espers.map((esper: { abilityKey: string }) => esper.abilityKey),
|
||||
);
|
||||
|
||||
const problems: string[] = [];
|
||||
|
||||
if (guides.format !== "nte-guides") problems.push(`wrong format: ${guides.format}`);
|
||||
|
||||
for (const guide of guides.characters ?? []) {
|
||||
const where = `${guide.key}`;
|
||||
if (!keys.has(guide.key)) {
|
||||
// Seven codenames have no everness record, so this is a warning about a
|
||||
// typo, not proof the character does not exist.
|
||||
problems.push(`${where}: not an ability key in gamedata (typo, or a codename with no record)`);
|
||||
}
|
||||
if (!guide.variants?.length) problems.push(`${where}: no variants`);
|
||||
|
||||
for (const variant of guide.variants ?? []) {
|
||||
const scope = `${where}/${variant.name}`;
|
||||
if (!variant.name) problems.push(`${scope}: variant has no name`);
|
||||
|
||||
for (const stat of variant.priority ?? []) {
|
||||
if (!isKnownStat(stat)) problems.push(`${scope}: priority stat has no slot: ${stat}`);
|
||||
}
|
||||
if (new Set(variant.priority ?? []).size !== (variant.priority ?? []).length) {
|
||||
problems.push(`${scope}: a stat appears twice in the priority list`);
|
||||
}
|
||||
|
||||
for (const target of variant.targets ?? []) {
|
||||
if (!isKnownStat(target.stat)) {
|
||||
problems.push(`${scope}: target stat has no slot: ${target.stat}`);
|
||||
continue;
|
||||
}
|
||||
if (!Number.isFinite(target.target) || target.target <= 0) {
|
||||
problems.push(`${scope}: ${target.stat} has a non-positive target`);
|
||||
}
|
||||
// Percentages are fractions here, never "70". A target above 5 on a
|
||||
// percentage stat is almost certainly a units mistake.
|
||||
const isPercent =
|
||||
target.stat.endsWith("Up") ||
|
||||
target.stat === "CritBase" ||
|
||||
target.stat === "CritDamageBase" ||
|
||||
target.stat.startsWith("DamageUp");
|
||||
if (isPercent && target.target > 5) {
|
||||
problems.push(
|
||||
`${scope}: ${target.stat} target ${target.target} looks like a percent, not a fraction`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const count = guides.characters?.length ?? 0;
|
||||
if (problems.length > 0) {
|
||||
console.error(`guides.json: ${problems.length} problem(s)`);
|
||||
for (const problem of problems) console.error(` ${problem}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
count === 0
|
||||
? "guides.json is valid and empty - the app will say no published guide is available"
|
||||
: `guides.json is valid: ${count} character(s)`,
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue