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
91
tools/bench-solve.ts
Normal file
91
tools/bench-solve.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* Times a real solve against a real export. Not a test - the fixture policy
|
||||
* keeps real captures out of the repo, so this takes the export path as an
|
||||
* argument and is run by hand.
|
||||
*
|
||||
* node --experimental-strip-types tools/bench-solve.ts path/to/gear.json
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
import { buildPool, type ExportedItem } from "../src/domain/items.ts";
|
||||
import { compile, explain } from "../src/domain/scoring.ts";
|
||||
import { consoleTrait } from "../src/domain/stats.ts";
|
||||
import { loadTilings, type RawTilings } from "../src/domain/tilings.ts";
|
||||
import { boardFromSlots, boardKey } from "../src/domain/board.ts";
|
||||
import { emptyVector } from "../src/domain/statvec.ts";
|
||||
import type { SetBonusTable } from "../src/domain/setbonus.ts";
|
||||
import { solveSingle } from "../src/solver/single.ts";
|
||||
|
||||
const exportPath = process.argv[2]!;
|
||||
const gear = JSON.parse(readFileSync(exportPath, "utf8"));
|
||||
const gamedata = JSON.parse(readFileSync(join(root, "src/generated/gamedata.json"), "utf8"));
|
||||
const setBonuses = JSON.parse(readFileSync(join(root, "data-src/set-bonuses.json"), "utf8")) as SetBonusTable;
|
||||
const tables = loadTilings(JSON.parse(readFileSync(join(root, "src/generated/tilings.json"), "utf8")) as RawTilings);
|
||||
|
||||
const pool = buildPool(gear.items as ExportedItem[]);
|
||||
console.log(
|
||||
`pool: ${pool.items.length} items, ${pool.modulesByShape.size} shapes, ` +
|
||||
`${pool.cartridgesBySet.size} sets owned, unknown shapes: ${pool.unknownShapes.length}`,
|
||||
);
|
||||
|
||||
const zankou = gamedata.espers.find((e: { name: string }) => e.name === "Zankou");
|
||||
const board = boardFromSlots(zankou.slots);
|
||||
const table = tables.find((t) => t.cells.join(",") === boardKey(board))!;
|
||||
|
||||
const scoring = compile({
|
||||
targets: [
|
||||
{ stat: "CritBase", target: 0.7, weight: 3 },
|
||||
{ stat: "CritDamageBase", target: 2.0, weight: 3 },
|
||||
{ stat: "AtkUp", target: 0.5, weight: 2 },
|
||||
{ stat: "UnbalIntensityBase", target: 280, weight: 1 },
|
||||
],
|
||||
mainStatRank: { CritDamageBase: 1, AtkUp: 0.5 },
|
||||
});
|
||||
|
||||
for (const set of ["Crimson: Twin Butterflies", "Shadow Creed"] as const) {
|
||||
const tilings = table.bySet.get(set) ?? [];
|
||||
const started = Date.now();
|
||||
const run = solveSingle({
|
||||
pool,
|
||||
tilings,
|
||||
base: emptyVector(),
|
||||
trait: consoleTrait(zankou),
|
||||
setBonuses,
|
||||
scoring,
|
||||
});
|
||||
let step = run.next();
|
||||
let yields = 0;
|
||||
while (!step.done) {
|
||||
yields += 1;
|
||||
step = run.next();
|
||||
}
|
||||
const result = step.value;
|
||||
const elapsed = Date.now() - started;
|
||||
const best = result.best;
|
||||
console.log(
|
||||
`\n${set}: ${tilings.length} packings, examined ${result.examined}, ` +
|
||||
`${yields} yields, ${elapsed} ms`,
|
||||
);
|
||||
if (!best) {
|
||||
console.log(" no build");
|
||||
continue;
|
||||
}
|
||||
console.log(
|
||||
` score ${best.score.toFixed(5)} proven=${best.proven} ` +
|
||||
`pieces=${best.modules.length} unknownTiers=[${best.unknownTiers}]`,
|
||||
);
|
||||
console.log(` cartridge ${pool.items[best.cartridge]!.itemId} main ${pool.items[best.cartridge]!.mainStat}`);
|
||||
console.log(` modules ${best.modules.map((i) => pool.items[i]!.shape).join(" ")}`);
|
||||
const report = explain(best.vector, scoring);
|
||||
for (const stat of report.stats) {
|
||||
console.log(
|
||||
` ${stat.stat.padEnd(20)} ${stat.value.toFixed(3).padStart(9)} / ${String(stat.target).padStart(6)}` +
|
||||
` ${(stat.attainment * 100).toFixed(1)}%`,
|
||||
);
|
||||
}
|
||||
console.log(` distinct modules used: ${new Set(best.modules).size}/${best.modules.length}`);
|
||||
}
|
||||
116
tools/bench-team.ts
Normal file
116
tools/bench-team.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/**
|
||||
* Times a four-character team solve against a real export.
|
||||
*
|
||||
* node --experimental-strip-types tools/bench-team.ts path/to/gear.json
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { boardFromSlots, boardKey } from "../src/domain/board.ts";
|
||||
import { buildPool, type ExportedItem } from "../src/domain/items.ts";
|
||||
import { compile } from "../src/domain/scoring.ts";
|
||||
import type { SetBonusTable } from "../src/domain/setbonus.ts";
|
||||
import { consoleTrait } from "../src/domain/stats.ts";
|
||||
import { emptyVector } from "../src/domain/statvec.ts";
|
||||
import { loadTilings, type RawTilings } from "../src/domain/tilings.ts";
|
||||
import { diversify, type Portfolio } from "../src/solver/portfolio.ts";
|
||||
import { solveSingle } from "../src/solver/single.ts";
|
||||
import { solveTeam } from "../src/solver/team.ts";
|
||||
import type { Build } from "../src/solver/protocol.ts";
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const gear = JSON.parse(readFileSync(process.argv[2]!, "utf8"));
|
||||
const gamedata = JSON.parse(readFileSync(join(root, "src/generated/gamedata.json"), "utf8"));
|
||||
const setBonuses = JSON.parse(
|
||||
readFileSync(join(root, "data-src/set-bonuses.json"), "utf8"),
|
||||
) as SetBonusTable;
|
||||
const tables = loadTilings(
|
||||
JSON.parse(readFileSync(join(root, "src/generated/tilings.json"), "utf8")) as RawTilings,
|
||||
);
|
||||
|
||||
const pool = buildPool(gear.items as ExportedItem[]);
|
||||
const scoring = compile({
|
||||
targets: [
|
||||
{ stat: "CritBase", target: 0.7, weight: 3 },
|
||||
{ stat: "CritDamageBase", target: 2.0, weight: 3 },
|
||||
{ stat: "AtkUp", target: 0.5, weight: 2 },
|
||||
{ stat: "UnbalIntensityBase", target: 280, weight: 1 },
|
||||
],
|
||||
mainStatRank: { CritDamageBase: 1, AtkUp: 0.5 },
|
||||
});
|
||||
|
||||
const team = ["Zankou", "Haniel", "Adler", "Nanally"];
|
||||
const espers = team.map((name) =>
|
||||
gamedata.espers.find((esper: { name: string }) => esper.name === name),
|
||||
);
|
||||
|
||||
/** Every packing the character's board allows, across every set it owns. */
|
||||
function tilingsFor(esper: { slots: number[][] }) {
|
||||
const key = boardKey(boardFromSlots(esper.slots));
|
||||
const table = tables.find((entry) => entry.cells.join(",") === key)!;
|
||||
return [...table.bySet.values()].flat();
|
||||
}
|
||||
|
||||
function solveOne(index: number, excluded: ReadonlySet<number>): Build[] {
|
||||
const run = solveSingle({
|
||||
pool,
|
||||
tilings: tilingsFor(espers[index]),
|
||||
base: emptyVector(),
|
||||
trait: consoleTrait(espers[index]),
|
||||
setBonuses,
|
||||
scoring,
|
||||
excluded,
|
||||
beamWidth: 60,
|
||||
candidateWidth: 12,
|
||||
});
|
||||
let step = run.next();
|
||||
while (!step.done) step = run.next();
|
||||
return step.value.portfolio;
|
||||
}
|
||||
|
||||
console.log(`pool ${pool.items.length} items, team ${team.join(", ")}\n`);
|
||||
|
||||
const phase0 = Date.now();
|
||||
const portfolios: Portfolio[] = espers.map((esper, index) => {
|
||||
const started = Date.now();
|
||||
const builds = solveOne(index, new Set());
|
||||
const entries = diversify(builds, pool.items.length, 300);
|
||||
console.log(
|
||||
` ${team[index]!.padEnd(9)} ${tilingsFor(esper).length} packings, ` +
|
||||
`${builds.length} builds -> ${entries.length} kept, ` +
|
||||
`best ${entries[0]?.build.score.toFixed(4) ?? "none"}, ${Date.now() - started} ms`,
|
||||
);
|
||||
return { key: team[index]!, entries, unbuildable: entries.length === 0 };
|
||||
});
|
||||
console.log(`phase 0 total ${Date.now() - phase0} ms\n`);
|
||||
|
||||
const phase12 = Date.now();
|
||||
const run = solveTeam({
|
||||
portfolios,
|
||||
poolSize: pool.items.length,
|
||||
dragOrder: team,
|
||||
resolve: (key, excluded) => solveOne(team.indexOf(key), excluded).slice(0, 40),
|
||||
rounds: 6,
|
||||
});
|
||||
let step = run.next();
|
||||
let yields = 0;
|
||||
while (!step.done) {
|
||||
yields += 1;
|
||||
console.log(
|
||||
` round ${step.value.round}: [${step.value.sorted.map((s) => s.toFixed(4)).join(", ")}]`,
|
||||
);
|
||||
step = run.next();
|
||||
}
|
||||
const result = step.value;
|
||||
console.log(`\ninfeasible=${result.infeasible}`);
|
||||
console.log(`phases 1+2 ${Date.now() - phase12} ms, ${yields} yields, ${result.rounds} rounds`);
|
||||
console.log(`final sorted [${result.sorted.map((s) => s.toFixed(4)).join(", ")}]`);
|
||||
for (const entry of result.assignment) {
|
||||
console.log(
|
||||
` ${entry.key.padEnd(9)} ${entry.build.score.toFixed(4)} ` +
|
||||
`${pool.items[entry.build.cartridge]!.itemId} + ${entry.build.modules.length} modules`,
|
||||
);
|
||||
}
|
||||
const used = result.assignment.flatMap((e) => [e.build.cartridge, ...e.build.modules]);
|
||||
console.log(`items used ${used.length}, distinct ${new Set(used).size}`);
|
||||
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)`,
|
||||
);
|
||||
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`,
|
||||
);
|
||||
115
tools/precompute-tilings.ts
Normal file
115
tools/precompute-tilings.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* Precomputes every (board, set, filler) packing, so exact cover never runs at
|
||||
* runtime.
|
||||
*
|
||||
* Positions do not affect score, so one canonical placement per shape multiset
|
||||
* is all the app ever needs - for rendering. What the solver actually consumes
|
||||
* is the list of multisets that tile at all.
|
||||
*
|
||||
* Output src/generated/tilings.json (committed, bundled).
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { boardFromSlots, boardKey, fillers, tile, type Board } from "../src/domain/board.ts";
|
||||
import { REQUIRED_PIECES, SET_NAMES } from "../src/domain/cartridges.ts";
|
||||
import { ICON_ORDER, type ShapeId } from "../src/domain/shapes.ts";
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const gamedata = JSON.parse(
|
||||
readFileSync(join(root, "src", "generated", "gamedata.json"), "utf8"),
|
||||
);
|
||||
|
||||
// Characters sharing a grid share a tiling table; there are only a few distinct
|
||||
// grids across the whole roster.
|
||||
const boards = new Map<string, { board: Board; espers: string[] }>();
|
||||
for (const esper of gamedata.espers) {
|
||||
const board = boardFromSlots(esper.slots);
|
||||
const key = boardKey(board);
|
||||
const entry = boards.get(key);
|
||||
if (entry) entry.espers.push(esper.abilityKey);
|
||||
else boards.set(key, { board, espers: [esper.abilityKey] });
|
||||
}
|
||||
|
||||
const boardList = [...boards.values()];
|
||||
const freeCells = new Set(boardList.map((entry) => entry.board.cells.length));
|
||||
if (freeCells.size !== 1) {
|
||||
throw new Error(`boards disagree on free cell count: ${[...freeCells].join(", ")}`);
|
||||
}
|
||||
const cellCount = [...freeCells][0]!;
|
||||
|
||||
const REQUIRED_CELLS = 12;
|
||||
const fillerSets = fillers(cellCount - REQUIRED_CELLS);
|
||||
|
||||
/**
|
||||
* Entries are encoded rather than spelled out: 1,297 packings of 20 cells is
|
||||
* 350 KB as plain arrays and 50 KB like this, and the app decodes one entry
|
||||
* only when it draws a board.
|
||||
*
|
||||
* [setIndex, pieces, placement]
|
||||
* pieces one base36 digit per piece - an index into ICON_ORDER
|
||||
* placement one base36 digit per board cell, in `cells` order - an index
|
||||
* into `pieces`
|
||||
*/
|
||||
type Entry = [set: number, pieces: string, placement: string];
|
||||
|
||||
const base36 = (value: number): string => {
|
||||
if (value >= 36) throw new Error(`index ${value} does not fit one base36 digit`);
|
||||
return value.toString(36);
|
||||
};
|
||||
|
||||
const tables: Array<{ cells: number[]; espers: string[]; entries: Entry[] }> = [];
|
||||
let attempted = 0;
|
||||
let tiled = 0;
|
||||
const pieceCounts = new Map<number, number>();
|
||||
|
||||
for (const { board, espers } of boardList) {
|
||||
const entries: Entry[] = [];
|
||||
const cellIndex = new Map(board.cells.map((cell, index) => [cell, index]));
|
||||
for (const [setIndex, set] of SET_NAMES.entries()) {
|
||||
const required = REQUIRED_PIECES[set] as readonly ShapeId[];
|
||||
if (required.reduce((sum, shape) => sum + Number(shape.slice(4, 5)), 0) !== REQUIRED_CELLS) {
|
||||
throw new Error(`${set} does not total ${REQUIRED_CELLS} cells`);
|
||||
}
|
||||
for (const filler of fillerSets) {
|
||||
attempted += 1;
|
||||
const multiset = [...required, ...filler];
|
||||
const packed = tile(board, multiset);
|
||||
if (!packed) continue;
|
||||
tiled += 1;
|
||||
pieceCounts.set(multiset.length, (pieceCounts.get(multiset.length) ?? 0) + 1);
|
||||
|
||||
const pieces = packed.map((piece) => base36(ICON_ORDER.indexOf(piece.shape))).join("");
|
||||
const placement = new Array<string>(board.cells.length);
|
||||
packed.forEach((piece, index) => {
|
||||
for (const cell of piece.cells) placement[cellIndex.get(cell)!] = base36(index);
|
||||
});
|
||||
if (placement.some((slot) => slot === undefined)) {
|
||||
throw new Error(`packing left a cell uncovered for ${set}`);
|
||||
}
|
||||
entries.push([setIndex, pieces, placement.join("")]);
|
||||
}
|
||||
}
|
||||
tables.push({ cells: [...board.cells], espers, entries });
|
||||
}
|
||||
|
||||
const out = {
|
||||
format: "nte-tilings",
|
||||
format_version: 1,
|
||||
gridSize: 7,
|
||||
cellCount,
|
||||
shapes: ICON_ORDER,
|
||||
sets: SET_NAMES,
|
||||
boards: tables,
|
||||
};
|
||||
const json = JSON.stringify(out);
|
||||
writeFileSync(join(root, "src", "generated", "tilings.json"), json + "\n");
|
||||
|
||||
const byPieces = [...pieceCounts.entries()].sort((a, b) => a[0] - b[0]);
|
||||
console.log(
|
||||
`tilings.json ${boardList.length} boards x ${SET_NAMES.length} sets x ` +
|
||||
`${fillerSets.length} fillers = ${attempted} combinations, ${tiled} tile ` +
|
||||
`(${byPieces.map(([n, count]) => `${n} pieces: ${count}`).join(", ")}) ` +
|
||||
`${(json.length / 1024).toFixed(0)} KB`,
|
||||
);
|
||||
136
tools/prune-gamedata.ts
Normal file
136
tools/prune-gamedata.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
/**
|
||||
* Prunes the mirrored everness data down to what the app actually needs.
|
||||
*
|
||||
* Input data-src/{espers,arcs,arc_effects}.json (committed, not shipped)
|
||||
* Output src/generated/gamedata.json (committed, bundled)
|
||||
*
|
||||
* Everything dropped here is presentation the app never renders: voice lines,
|
||||
* fashion, profile text, and the ability descriptions. What stays is the stat
|
||||
* curves, the console grid, the console trait, and the ids that join to a
|
||||
* capture.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const read = (name: string) =>
|
||||
JSON.parse(readFileSync(join(root, "data-src", name), "utf8"));
|
||||
|
||||
interface StatCurve {
|
||||
id_stats: string;
|
||||
name: string;
|
||||
bShowPercent: boolean;
|
||||
values: number[];
|
||||
}
|
||||
|
||||
interface TraitStat {
|
||||
id_stats: string;
|
||||
name: string;
|
||||
bShowPercent: boolean;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface Esper {
|
||||
id: number;
|
||||
/** The `GA_<key>_*` name the capture marks character blocks with. */
|
||||
abilityKey: string;
|
||||
name: string;
|
||||
element: string | null;
|
||||
rarity: number;
|
||||
icon: string;
|
||||
/** Per-level curves, indexed by level - 1. */
|
||||
stats: StatCurve[];
|
||||
/** 7x7, -1 blocked and 0 free. */
|
||||
slots: number[][];
|
||||
/**
|
||||
* The module type the console trait keys off - 2 or 3, NOT the grid type.
|
||||
* Prydwen's "Console Grid Type" means this same number.
|
||||
*/
|
||||
ownerGridCount: number;
|
||||
/** Never assume Type III / CRIT DMG: seven different stats appear here. */
|
||||
trait: TraitStat[];
|
||||
/** Levels at which a breakthrough is taken. */
|
||||
breakthroughLevels: number[];
|
||||
}
|
||||
|
||||
export interface Arc {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
quality: number;
|
||||
desc: string;
|
||||
stats: unknown;
|
||||
values: unknown;
|
||||
}
|
||||
|
||||
const espersRaw = read("espers.json") as any[];
|
||||
const arcsRaw = read("arcs.json") as Record<string, any> | any[];
|
||||
const arcEffects = read("arc_effects.json") as Record<string, unknown>;
|
||||
|
||||
function abilityKey(esper: any): string {
|
||||
const id: string | undefined = esper?.melee?.[0]?.id;
|
||||
const match = /^GA_(.+?)_Melee$/.exec(id ?? "");
|
||||
if (!match) throw new Error(`no GA_<key>_Melee id on esper ${esper?.name}`);
|
||||
return match[1]!;
|
||||
}
|
||||
|
||||
const espers: Esper[] = espersRaw.map((esper) => ({
|
||||
id: esper.id,
|
||||
abilityKey: abilityKey(esper),
|
||||
name: esper.name,
|
||||
element: esper.element?.[0]?.element_id ?? null,
|
||||
rarity: esper.rarity,
|
||||
icon: esper.icon,
|
||||
stats: (esper.stats ?? []).map((stat: StatCurve) => ({
|
||||
id_stats: stat.id_stats,
|
||||
name: stat.name,
|
||||
bShowPercent: stat.bShowPercent,
|
||||
values: stat.values,
|
||||
})),
|
||||
slots: esper.equip_slots.slots,
|
||||
ownerGridCount: esper.equip_slots.OwnerGridCount,
|
||||
trait: (esper.equip_slots.stats ?? []).map((stat: TraitStat) => ({
|
||||
id_stats: stat.id_stats,
|
||||
name: stat.name,
|
||||
bShowPercent: stat.bShowPercent,
|
||||
value: stat.value,
|
||||
})),
|
||||
breakthroughLevels: (esper.breakthrough ?? []).map((step: { level: string }) =>
|
||||
Number(step.level),
|
||||
),
|
||||
}));
|
||||
|
||||
const arcEntries = Array.isArray(arcsRaw) ? arcsRaw : Object.values(arcsRaw);
|
||||
const arcs: Arc[] = arcEntries.map((arc: any) => ({
|
||||
id: arc.id,
|
||||
name: arc.name,
|
||||
icon: arc.icon,
|
||||
quality: arc.quality,
|
||||
desc: arc.desc,
|
||||
stats: arc.stats,
|
||||
values: arc.effect?.values ?? arc.effect ?? null,
|
||||
}));
|
||||
|
||||
const gamedata = {
|
||||
format: "nte-gamedata",
|
||||
format_version: 1,
|
||||
generated: new Date().toISOString().slice(0, 10),
|
||||
espers,
|
||||
arcs,
|
||||
arcEffects,
|
||||
};
|
||||
|
||||
const outDir = join(root, "src", "generated");
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const json = JSON.stringify(gamedata);
|
||||
writeFileSync(join(outDir, "gamedata.json"), json + "\n");
|
||||
|
||||
const sourceBytes = ["espers.json", "arcs.json"].reduce(
|
||||
(total, name) => total + readFileSync(join(root, "data-src", name)).byteLength,
|
||||
0,
|
||||
);
|
||||
console.log(
|
||||
`gamedata.json ${espers.length} espers, ${arcs.length} arcs ` +
|
||||
`${(sourceBytes / 1024).toFixed(0)} KB -> ${(json.length / 1024).toFixed(0)} KB`,
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue