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
5ce255fa6f
157 changed files with 92600 additions and 0 deletions
161
src/db/adapters/idb.ts
Normal file
161
src/db/adapters/idb.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
/**
|
||||
* The normal tier.
|
||||
*
|
||||
* One database, integer-versioned, with explicit migrations in `upgrade`.
|
||||
*
|
||||
* The rule that governs every write here: **an IndexedDB transaction
|
||||
* auto-commits the moment you await anything that is not an IndexedDB request.**
|
||||
* So each transaction body below is a straight run of `store.clear()` and
|
||||
* `store.put()` calls with a single await on `tx.done` at the end. Nothing else
|
||||
* may creep in - not a `structuredClone`, not a progress callback - or the
|
||||
* transaction closes underneath the remaining writes and the import lands half
|
||||
* applied.
|
||||
*/
|
||||
import { openDB, type IDBPDatabase } from "idb";
|
||||
|
||||
import {
|
||||
DB_NAME,
|
||||
DB_VERSION,
|
||||
ITEM_KIND_INDEX,
|
||||
STORES,
|
||||
UNDO_LIMIT,
|
||||
type EquipmentRow,
|
||||
type UndoRow,
|
||||
} from "../schema.ts";
|
||||
import { emptyState, type PersistenceAdapter, type StoredState } from "./types.ts";
|
||||
|
||||
const SNAPSHOT_KEY = "preImport";
|
||||
|
||||
export async function openDatabase(): Promise<IDBPDatabase> {
|
||||
return openDB(DB_NAME, DB_VERSION, {
|
||||
upgrade(db, oldVersion) {
|
||||
// Migrations are explicit and additive; each `if` is one version step.
|
||||
if (oldVersion < 1) {
|
||||
const items = db.createObjectStore(STORES.items, { keyPath: "instance" });
|
||||
items.createIndex(ITEM_KIND_INDEX, "kind");
|
||||
// Keyed on `instance`: an item is worn in exactly one place, and the
|
||||
// primary key is what enforces it.
|
||||
db.createObjectStore(STORES.equipment, { keyPath: "instance" });
|
||||
db.createObjectStore(STORES.characters, { keyPath: "characterId" });
|
||||
db.createObjectStore(STORES.ownerNames, { keyPath: "ownerGroup" });
|
||||
db.createObjectStore(STORES.undo, { keyPath: "id", autoIncrement: true });
|
||||
db.createObjectStore(STORES.snapshots);
|
||||
db.createObjectStore(STORES.meta, { keyPath: "key" });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createIdbAdapter(db: IDBPDatabase): PersistenceAdapter {
|
||||
return {
|
||||
kind: "idb",
|
||||
durable: true,
|
||||
|
||||
async read(): Promise<StoredState> {
|
||||
const [items, equipment, characters, ownerNames, meta, undo, snapshot] =
|
||||
await Promise.all([
|
||||
db.getAll(STORES.items),
|
||||
db.getAll(STORES.equipment),
|
||||
db.getAll(STORES.characters),
|
||||
db.getAll(STORES.ownerNames),
|
||||
db.getAll(STORES.meta),
|
||||
db.getAll(STORES.undo),
|
||||
db.get(STORES.snapshots, SNAPSHOT_KEY),
|
||||
]);
|
||||
const state = emptyState();
|
||||
state.db = { items, equipment, characters, ownerNames, meta };
|
||||
state.undo = undo as UndoRow[];
|
||||
state.snapshot = (snapshot as StoredState["snapshot"]) ?? null;
|
||||
return state;
|
||||
},
|
||||
|
||||
async replaceAll(next: StoredState): Promise<void> {
|
||||
const tx = db.transaction(
|
||||
[
|
||||
STORES.items,
|
||||
STORES.equipment,
|
||||
STORES.characters,
|
||||
STORES.ownerNames,
|
||||
STORES.undo,
|
||||
STORES.snapshots,
|
||||
STORES.meta,
|
||||
],
|
||||
"readwrite",
|
||||
);
|
||||
// Synchronous from here to `tx.done`.
|
||||
const snapshots = tx.objectStore(STORES.snapshots);
|
||||
if (next.snapshot) void snapshots.put(next.snapshot, SNAPSHOT_KEY);
|
||||
else void snapshots.delete(SNAPSHOT_KEY);
|
||||
|
||||
const items = tx.objectStore(STORES.items);
|
||||
void items.clear();
|
||||
for (const row of next.db.items) void items.put(row);
|
||||
|
||||
const equipment = tx.objectStore(STORES.equipment);
|
||||
void equipment.clear();
|
||||
for (const row of next.db.equipment) void equipment.put(row);
|
||||
|
||||
const characters = tx.objectStore(STORES.characters);
|
||||
void characters.clear();
|
||||
for (const row of next.db.characters) void characters.put(row);
|
||||
|
||||
// Never touched by import - the player named these once, and the values
|
||||
// they key on are stable across exports.
|
||||
const ownerNames = tx.objectStore(STORES.ownerNames);
|
||||
void ownerNames.clear();
|
||||
for (const row of next.db.ownerNames) void ownerNames.put(row);
|
||||
|
||||
const undo = tx.objectStore(STORES.undo);
|
||||
void undo.clear();
|
||||
for (const row of next.undo.slice(-UNDO_LIMIT)) void undo.put(row);
|
||||
|
||||
const meta = tx.objectStore(STORES.meta);
|
||||
void meta.clear();
|
||||
for (const row of next.db.meta) void meta.put(row);
|
||||
|
||||
await tx.done;
|
||||
},
|
||||
|
||||
async writeEquipment(rows: EquipmentRow[], undoRows: UndoRow[]): Promise<void> {
|
||||
const tx = db.transaction([STORES.equipment, STORES.undo], "readwrite");
|
||||
const equipment = tx.objectStore(STORES.equipment);
|
||||
void equipment.clear();
|
||||
for (const row of rows) void equipment.put(row);
|
||||
const undo = tx.objectStore(STORES.undo);
|
||||
void undo.clear();
|
||||
for (const row of undoRows.slice(-UNDO_LIMIT)) void undo.put(row);
|
||||
await tx.done;
|
||||
},
|
||||
|
||||
close() {
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* IndexedDB, then `localStorage`, then memory.
|
||||
*
|
||||
* Each tier is tried by *using* it, not by feature detection: on a `file://`
|
||||
* page `indexedDB` is a defined global that throws on `open`, and `localStorage`
|
||||
* can be present but throw on `setItem`.
|
||||
*/
|
||||
export async function createBestAdapter(): Promise<PersistenceAdapter> {
|
||||
try {
|
||||
return createIdbAdapter(await openDatabase());
|
||||
} catch {
|
||||
// Fall through.
|
||||
}
|
||||
try {
|
||||
const storage = globalThis.localStorage;
|
||||
const probe = "nte-optimizer:probe";
|
||||
storage.setItem(probe, "1");
|
||||
storage.removeItem(probe);
|
||||
const { createLocalStorageAdapter } = await import("./local-storage.ts");
|
||||
return createLocalStorageAdapter(storage);
|
||||
} catch {
|
||||
// Fall through.
|
||||
}
|
||||
const { createMemoryAdapter } = await import("./memory.ts");
|
||||
return createMemoryAdapter();
|
||||
}
|
||||
74
src/db/adapters/local-storage.ts
Normal file
74
src/db/adapters/local-storage.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* The `file://` tier.
|
||||
*
|
||||
* Chrome gives a `file://` document an opaque origin, so IndexedDB is gone but
|
||||
* `localStorage` sometimes survives. It is synchronous and small, so the whole
|
||||
* database is one key and the snapshot is another - a snapshot is about a
|
||||
* megabyte, which is most of the budget, so it is dropped first when the quota
|
||||
* is hit rather than failing the write.
|
||||
*/
|
||||
import { emptyState, type PersistenceAdapter, type StoredState } from "./types.ts";
|
||||
import type { EquipmentRow, UndoRow } from "../schema.ts";
|
||||
|
||||
export const STATE_KEY = "nte-optimizer:state";
|
||||
export const SNAPSHOT_KEY = "nte-optimizer:snapshot";
|
||||
|
||||
export interface StorageLike {
|
||||
getItem: (key: string) => string | null;
|
||||
setItem: (key: string, value: string) => void;
|
||||
removeItem: (key: string) => void;
|
||||
}
|
||||
|
||||
export function createLocalStorageAdapter(storage: StorageLike): PersistenceAdapter {
|
||||
const readState = (): StoredState => {
|
||||
const raw = storage.getItem(STATE_KEY);
|
||||
if (!raw) return emptyState();
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as StoredState;
|
||||
const snapshotRaw = storage.getItem(SNAPSHOT_KEY);
|
||||
parsed.snapshot = snapshotRaw ? (JSON.parse(snapshotRaw) as StoredState["db"]) : null;
|
||||
return parsed;
|
||||
} catch {
|
||||
// A corrupt value is not worth a crash on boot; start clean.
|
||||
return emptyState();
|
||||
}
|
||||
};
|
||||
|
||||
const write = (state: StoredState): void => {
|
||||
const { snapshot, ...rest } = state;
|
||||
try {
|
||||
storage.setItem(STATE_KEY, JSON.stringify({ ...rest, snapshot: null }));
|
||||
} catch (error) {
|
||||
// Out of quota: the snapshot is the largest thing here and the least
|
||||
// essential, so it goes before the database does.
|
||||
storage.removeItem(SNAPSHOT_KEY);
|
||||
storage.setItem(STATE_KEY, JSON.stringify({ ...rest, snapshot: null }));
|
||||
throw error;
|
||||
}
|
||||
if (snapshot) {
|
||||
try {
|
||||
storage.setItem(SNAPSHOT_KEY, JSON.stringify(snapshot));
|
||||
} catch {
|
||||
storage.removeItem(SNAPSHOT_KEY);
|
||||
}
|
||||
} else {
|
||||
storage.removeItem(SNAPSHOT_KEY);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
kind: "localStorage",
|
||||
durable: false,
|
||||
async read() {
|
||||
return readState();
|
||||
},
|
||||
async replaceAll(next) {
|
||||
write(next);
|
||||
},
|
||||
async writeEquipment(equipment: EquipmentRow[], undo: UndoRow[]) {
|
||||
const state = readState();
|
||||
write({ ...state, db: { ...state.db, equipment }, undo });
|
||||
},
|
||||
close() {},
|
||||
};
|
||||
}
|
||||
21
src/db/adapters/memory.ts
Normal file
21
src/db/adapters/memory.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/** Last resort: the state lives only as long as the tab does. */
|
||||
import { emptyState, type PersistenceAdapter, type StoredState } from "./types.ts";
|
||||
import type { EquipmentRow, UndoRow } from "../schema.ts";
|
||||
|
||||
export function createMemoryAdapter(initial?: StoredState): PersistenceAdapter {
|
||||
let state = initial ?? emptyState();
|
||||
return {
|
||||
kind: "memory",
|
||||
durable: false,
|
||||
async read() {
|
||||
return structuredClone(state);
|
||||
},
|
||||
async replaceAll(next) {
|
||||
state = structuredClone(next);
|
||||
},
|
||||
async writeEquipment(equipment: EquipmentRow[], undo: UndoRow[]) {
|
||||
state = structuredClone({ ...state, db: { ...state.db, equipment }, undo });
|
||||
},
|
||||
close() {},
|
||||
};
|
||||
}
|
||||
40
src/db/adapters/types.ts
Normal file
40
src/db/adapters/types.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* Where the database actually lives.
|
||||
*
|
||||
* Three tiers, one interface: IndexedDB normally, `localStorage` when a
|
||||
* `file://` document's opaque origin denies IndexedDB, and memory when even that
|
||||
* is refused. The app never sees through this - it asks for the state and hands
|
||||
* back a new one - so the degraded modes cost nothing above the adapter beyond a
|
||||
* banner telling the player their data is not durable.
|
||||
*/
|
||||
import type { Database, EquipmentRow, UndoRow } from "../schema.ts";
|
||||
|
||||
export interface StoredState {
|
||||
db: Database;
|
||||
undo: UndoRow[];
|
||||
/** The last pre-import dump. One click back from a wrong file. */
|
||||
snapshot: Database | null;
|
||||
}
|
||||
|
||||
export interface PersistenceAdapter {
|
||||
readonly kind: "idb" | "localStorage" | "memory";
|
||||
/** False in the degraded tiers, where the UI must say so. */
|
||||
readonly durable: boolean;
|
||||
read: () => Promise<StoredState>;
|
||||
/** Replace everything atomically: import, or a database restore. */
|
||||
replaceAll: (state: StoredState) => Promise<void>;
|
||||
/**
|
||||
* The frequent path. Equipping changes equipment and undo and nothing else,
|
||||
* so rewriting 817 item rows for it would be pure waste.
|
||||
*/
|
||||
writeEquipment: (equipment: EquipmentRow[], undo: UndoRow[]) => Promise<void>;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export function emptyState(): StoredState {
|
||||
return {
|
||||
db: { items: [], equipment: [], characters: [], ownerNames: [], meta: [] },
|
||||
undo: [],
|
||||
snapshot: null,
|
||||
};
|
||||
}
|
||||
106
src/db/dbfile.ts
Normal file
106
src/db/dbfile.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* Database export and import - the "backend" without a backend.
|
||||
*
|
||||
* Deliberately separate from importing a game capture and labelled that way in
|
||||
* the UI: a capture is the game telling you what you own, this is your whole
|
||||
* workspace including everything you configured by hand. It is backup, moving
|
||||
* between machines, sharing a snapshot - and it is the only persistence story
|
||||
* the `file://` build has, where IndexedDB does not exist.
|
||||
*/
|
||||
import { DB_VERSION } from "./schema.ts";
|
||||
import { emptyState, type StoredState } from "./adapters/types.ts";
|
||||
|
||||
export const DBFILE_FORMAT = "nte-optimizer-db";
|
||||
export const DBFILE_VERSION = 1;
|
||||
|
||||
export interface DbFile {
|
||||
format: string;
|
||||
format_version: number;
|
||||
/** Schema version the rows were written against. */
|
||||
schema_version: number;
|
||||
/** Which generated game data was in play, so a mismatch can be reported. */
|
||||
gamedata_version: string | null;
|
||||
exported_at: number;
|
||||
state: StoredState;
|
||||
}
|
||||
|
||||
export function exportDatabase(
|
||||
state: StoredState,
|
||||
gamedataVersion: string | null,
|
||||
now = Date.now,
|
||||
): DbFile {
|
||||
return {
|
||||
format: DBFILE_FORMAT,
|
||||
format_version: DBFILE_VERSION,
|
||||
schema_version: DB_VERSION,
|
||||
gamedata_version: gamedataVersion,
|
||||
exported_at: now(),
|
||||
state: structuredClone(state),
|
||||
};
|
||||
}
|
||||
|
||||
export function fileName(now = new Date()): string {
|
||||
const date = now.toISOString().slice(0, 10);
|
||||
return `nte-db-${date}.json`;
|
||||
}
|
||||
|
||||
export interface DbFileImport {
|
||||
ok: boolean;
|
||||
problems: string[];
|
||||
state?: StoredState;
|
||||
/** True when the file was written against an older schema. */
|
||||
migrated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a database file back.
|
||||
*
|
||||
* A file from a *newer* schema is refused rather than partially understood: the
|
||||
* rows may carry fields this build would drop on the next write, and silently
|
||||
* discarding the player's configuration is worse than refusing to open it.
|
||||
*/
|
||||
export function importDatabase(raw: unknown): DbFileImport {
|
||||
const problems: string[] = [];
|
||||
if (typeof raw !== "object" || raw === null) {
|
||||
return { ok: false, problems: ["not an object"], migrated: false };
|
||||
}
|
||||
const file = raw as Partial<DbFile>;
|
||||
if (file.format !== DBFILE_FORMAT) {
|
||||
return { ok: false, problems: [`not a database file: ${String(file.format)}`], migrated: false };
|
||||
}
|
||||
if (file.format_version !== DBFILE_VERSION) {
|
||||
return {
|
||||
ok: false,
|
||||
problems: [`unsupported file version ${String(file.format_version)}`],
|
||||
migrated: false,
|
||||
};
|
||||
}
|
||||
const schema = file.schema_version ?? 0;
|
||||
if (schema > DB_VERSION) {
|
||||
return {
|
||||
ok: false,
|
||||
problems: [`written by a newer build (schema ${schema} > ${DB_VERSION})`],
|
||||
migrated: false,
|
||||
};
|
||||
}
|
||||
if (typeof file.state !== "object" || file.state === null) {
|
||||
return { ok: false, problems: ["no state"], migrated: false };
|
||||
}
|
||||
|
||||
const base = emptyState();
|
||||
const state = file.state as Partial<StoredState>;
|
||||
const merged: StoredState = {
|
||||
db: { ...base.db, ...(state.db ?? {}) },
|
||||
undo: state.undo ?? [],
|
||||
snapshot: state.snapshot ?? null,
|
||||
};
|
||||
|
||||
for (const key of ["items", "equipment", "characters", "ownerNames", "meta"] as const) {
|
||||
if (!Array.isArray(merged.db[key])) {
|
||||
problems.push(`${key} is not a list`);
|
||||
}
|
||||
}
|
||||
if (problems.length > 0) return { ok: false, problems, migrated: false };
|
||||
|
||||
return { ok: true, problems, state: merged, migrated: schema < DB_VERSION };
|
||||
}
|
||||
306
src/db/import.ts
Normal file
306
src/db/import.ts
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
/**
|
||||
* Turning a gear export into rows.
|
||||
*
|
||||
* **Fail closed.** Any error at all and nothing is written: a partially imported
|
||||
* account is worse than a refused import, because the player cannot tell which
|
||||
* half is stale. The report is diff-shaped on purpose - "817 expected, 811
|
||||
* parsed, 6 rejected" tells you something is wrong with the capture; a silent
|
||||
* 811 does not.
|
||||
*
|
||||
* This module is pure. It parses, validates and normalises; it never touches
|
||||
* IndexedDB, so it runs in a worker and in a test unchanged.
|
||||
*/
|
||||
import { SET_IDS } from "../domain/cartridges.ts";
|
||||
import { SHAPES } from "../domain/shapes.ts";
|
||||
import { isKnownStat } from "../domain/statvec.ts";
|
||||
import {
|
||||
emptyCharacter,
|
||||
type CharacterRow,
|
||||
type EquipmentRow,
|
||||
type ItemRow,
|
||||
} from "./schema.ts";
|
||||
|
||||
export const EXPORT_FORMAT = "nte-gear-export";
|
||||
export const SUPPORTED_FORMAT_VERSION = 1;
|
||||
|
||||
export interface ImportProblem {
|
||||
code: string;
|
||||
detail: string;
|
||||
/** The item or character it concerns, when there is one. */
|
||||
subject?: string;
|
||||
}
|
||||
|
||||
export interface ImportReport {
|
||||
/** How many item records the export claimed, from its own `scan` block. */
|
||||
expected: number | null;
|
||||
parsed: number;
|
||||
rejected: number;
|
||||
problems: ImportProblem[];
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
ok: boolean;
|
||||
report: ImportReport;
|
||||
/** Only present when `ok` - there is nothing partial to apply. */
|
||||
rows?: {
|
||||
items: ItemRow[];
|
||||
equipment: EquipmentRow[];
|
||||
characters: CharacterRow[];
|
||||
userUid: string | null;
|
||||
serverId: string | null;
|
||||
exporterVersion: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
function statPairs(
|
||||
raw: unknown,
|
||||
problems: ImportProblem[],
|
||||
subject: string,
|
||||
allowNull: boolean,
|
||||
): Array<{ stat: string; value: number | null }> | null {
|
||||
if (!Array.isArray(raw)) {
|
||||
problems.push({ code: "stats_not_a_list", detail: "expected a list", subject });
|
||||
return null;
|
||||
}
|
||||
const out: Array<{ stat: string; value: number | null }> = [];
|
||||
for (const entry of raw) {
|
||||
if (!isObject(entry) || typeof entry["stat"] !== "string") {
|
||||
problems.push({ code: "stat_malformed", detail: "missing stat id", subject });
|
||||
return null;
|
||||
}
|
||||
const stat = entry["stat"];
|
||||
const value = entry["value"];
|
||||
if (!isKnownStat(stat)) {
|
||||
// A stat the model has no slot for cannot be scored, so it cannot be
|
||||
// silently kept either.
|
||||
problems.push({ code: "unknown_stat", detail: stat, subject });
|
||||
return null;
|
||||
}
|
||||
if (value === null || value === undefined) {
|
||||
if (!allowNull) {
|
||||
problems.push({ code: "substat_without_value", detail: stat, subject });
|
||||
return null;
|
||||
}
|
||||
out.push({ stat, value: null });
|
||||
continue;
|
||||
}
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
problems.push({ code: "stat_value_not_a_number", detail: stat, subject });
|
||||
return null;
|
||||
}
|
||||
out.push({ stat, value });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseGearExport(raw: unknown): ImportResult {
|
||||
const problems: ImportProblem[] = [];
|
||||
const fail = (report?: Partial<ImportReport>): ImportResult => ({
|
||||
ok: false,
|
||||
report: { expected: null, parsed: 0, rejected: 0, problems, ...report },
|
||||
});
|
||||
|
||||
if (!isObject(raw)) return fail();
|
||||
if (raw["format"] !== EXPORT_FORMAT) {
|
||||
problems.push({ code: "wrong_format", detail: String(raw["format"]) });
|
||||
return fail();
|
||||
}
|
||||
if (raw["format_version"] !== SUPPORTED_FORMAT_VERSION) {
|
||||
problems.push({
|
||||
code: "unsupported_format_version",
|
||||
detail: String(raw["format_version"]),
|
||||
});
|
||||
return fail();
|
||||
}
|
||||
|
||||
const scan = isObject(raw["scan"]) ? raw["scan"] : null;
|
||||
const expected =
|
||||
scan && typeof scan["cartridges"] === "number" && typeof scan["modules"] === "number"
|
||||
? scan["cartridges"] + scan["modules"]
|
||||
: null;
|
||||
|
||||
const rawItems = raw["items"];
|
||||
if (!Array.isArray(rawItems)) {
|
||||
problems.push({ code: "items_missing", detail: "no items list" });
|
||||
return fail({ expected });
|
||||
}
|
||||
|
||||
const items: ItemRow[] = [];
|
||||
const equipment: EquipmentRow[] = [];
|
||||
const seen = new Set<string>();
|
||||
const at = Date.now();
|
||||
const batchId = `import-${at}`;
|
||||
let rejected = 0;
|
||||
|
||||
for (const entry of rawItems) {
|
||||
if (!isObject(entry)) {
|
||||
rejected += 1;
|
||||
problems.push({ code: "item_not_an_object", detail: "skipped" });
|
||||
continue;
|
||||
}
|
||||
const instance = entry["instance"];
|
||||
const itemId = entry["item_id"];
|
||||
const kind = entry["kind"];
|
||||
const subject = typeof instance === "string" ? instance : "<no instance>";
|
||||
|
||||
if (typeof instance !== "string" || instance.length === 0) {
|
||||
rejected += 1;
|
||||
problems.push({ code: "item_without_instance", detail: String(itemId), subject });
|
||||
continue;
|
||||
}
|
||||
if (seen.has(instance)) {
|
||||
// The primary key would silently overwrite; say so instead.
|
||||
rejected += 1;
|
||||
problems.push({ code: "duplicate_instance", detail: instance, subject });
|
||||
continue;
|
||||
}
|
||||
if (kind !== "module" && kind !== "cartridge") {
|
||||
rejected += 1;
|
||||
problems.push({ code: "unknown_kind", detail: String(kind), subject });
|
||||
continue;
|
||||
}
|
||||
if (typeof itemId !== "string") {
|
||||
rejected += 1;
|
||||
problems.push({ code: "item_without_id", detail: "missing item_id", subject });
|
||||
continue;
|
||||
}
|
||||
|
||||
const shape = typeof entry["shape"] === "string" ? entry["shape"] : null;
|
||||
if (kind === "module" && (shape === null || !(shape in SHAPES))) {
|
||||
rejected += 1;
|
||||
problems.push({ code: "unknown_shape", detail: String(shape), subject });
|
||||
continue;
|
||||
}
|
||||
|
||||
const set =
|
||||
kind === "cartridge"
|
||||
? (SET_IDS[itemId] ?? (typeof entry["set"] === "string" ? entry["set"] : null))
|
||||
: null;
|
||||
if (kind === "cartridge" && set === null) {
|
||||
rejected += 1;
|
||||
problems.push({ code: "unknown_set", detail: itemId, subject });
|
||||
continue;
|
||||
}
|
||||
|
||||
const before = problems.length;
|
||||
const mains = statPairs(entry["main_stats"], problems, subject, true);
|
||||
const subs = statPairs(entry["substats"], problems, subject, false);
|
||||
if (mains === null || subs === null || problems.length !== before) {
|
||||
rejected += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const level = entry["level"];
|
||||
if (typeof level !== "number" || !Number.isInteger(level) || level < 0 || level > 20) {
|
||||
rejected += 1;
|
||||
problems.push({ code: "bad_level", detail: String(level), subject });
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(instance);
|
||||
items.push({
|
||||
instance,
|
||||
kind,
|
||||
itemId,
|
||||
shape,
|
||||
cells: shape ? SHAPES[shape as keyof typeof SHAPES].length : null,
|
||||
set,
|
||||
level,
|
||||
rarity: typeof entry["rarity"] === "string" ? entry["rarity"] : "unknown",
|
||||
mainStats: mains,
|
||||
substats: subs as Array<{ stat: string; value: number }>,
|
||||
});
|
||||
|
||||
const ownerGroup = entry["owner_group"];
|
||||
if (typeof ownerGroup === "string" && ownerGroup.length > 0) {
|
||||
equipment.push({
|
||||
instance,
|
||||
ownerGroup,
|
||||
characterId: null,
|
||||
cells: null,
|
||||
origin: "imported",
|
||||
batchId,
|
||||
at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const characters: CharacterRow[] = [];
|
||||
const rawCharacters = raw["characters"];
|
||||
if (Array.isArray(rawCharacters)) {
|
||||
for (const entry of rawCharacters) {
|
||||
if (!isObject(entry) || typeof entry["key"] !== "string") {
|
||||
problems.push({ code: "character_malformed", detail: "missing key" });
|
||||
continue;
|
||||
}
|
||||
const row = emptyCharacter(entry["key"]);
|
||||
row.level = typeof entry["level"] === "number" ? entry["level"] : null;
|
||||
row.breakthroughs =
|
||||
typeof entry["breakthroughs"] === "number" ? entry["breakthroughs"] : null;
|
||||
characters.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
// The count from the export's own scan block is the check that catches a
|
||||
// truncated capture, which no per-record validation can see.
|
||||
if (expected !== null && items.length + rejected !== expected) {
|
||||
problems.push({
|
||||
code: "count_mismatch",
|
||||
detail: `${expected} expected, ${items.length + rejected} present`,
|
||||
});
|
||||
}
|
||||
|
||||
const report: ImportReport = {
|
||||
expected,
|
||||
parsed: items.length,
|
||||
rejected,
|
||||
problems,
|
||||
};
|
||||
|
||||
if (rejected > 0 || problems.length > 0) return { ok: false, report };
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
report,
|
||||
rows: {
|
||||
items,
|
||||
equipment,
|
||||
characters,
|
||||
userUid: typeof raw["user_uid"] === "string" ? raw["user_uid"] : null,
|
||||
serverId: typeof raw["server_id"] === "string" ? raw["server_id"] : null,
|
||||
exporterVersion: isObject(raw["exporter"])
|
||||
? ((raw["exporter"]["version"] as string | undefined) ?? null)
|
||||
: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge imported character rows onto stored ones.
|
||||
*
|
||||
* Level and ascension come from the game and are refreshed; everything the
|
||||
* player configured - Arc, refinement, effect toggles, build variant, custom
|
||||
* targets - is theirs and survives.
|
||||
*/
|
||||
export function mergeCharacters(
|
||||
stored: readonly CharacterRow[],
|
||||
imported: readonly CharacterRow[],
|
||||
): CharacterRow[] {
|
||||
const byId = new Map(stored.map((row) => [row.characterId, row]));
|
||||
const out: CharacterRow[] = [];
|
||||
for (const row of imported) {
|
||||
const previous = byId.get(row.characterId);
|
||||
byId.delete(row.characterId);
|
||||
out.push(
|
||||
previous
|
||||
? { ...previous, level: row.level, breakthroughs: row.breakthroughs }
|
||||
: row,
|
||||
);
|
||||
}
|
||||
// A character the capture did not mention is still the player's; keep it.
|
||||
for (const remaining of byId.values()) out.push(remaining);
|
||||
return out;
|
||||
}
|
||||
137
src/db/schema.ts
Normal file
137
src/db/schema.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
/**
|
||||
* The local database.
|
||||
*
|
||||
* One IndexedDB database, integer-versioned with explicit migrations. Treated as
|
||||
* a **durable log, not a query engine**: everything is loaded into memory at
|
||||
* boot - well under 5 MB at 817 items - and filtered and sorted in JS. Substat
|
||||
* filters are not indexable in IndexedDB anyway, so one index carries the whole
|
||||
* schema and it stops churning.
|
||||
*/
|
||||
|
||||
export const DB_NAME = "nte-optimizer";
|
||||
export const DB_VERSION = 1;
|
||||
|
||||
export const STORES = {
|
||||
/** Cleared and replaced on import. Keyed by `instance`. */
|
||||
items: "items",
|
||||
/**
|
||||
* Cleared and replaced on import. Keyed by `instance` too - an item is worn in
|
||||
* exactly one place, so the primary key makes double-equipping structurally
|
||||
* impossible rather than merely something to test for.
|
||||
*/
|
||||
equipment: "equipment",
|
||||
/**
|
||||
* Level and ascension are refreshed by import; the Arc, refinement, effect
|
||||
* toggles, build variant, custom targets and priorities are preserved.
|
||||
*/
|
||||
characters: "characters",
|
||||
/** Named once by the player and **never touched by import**. */
|
||||
ownerNames: "ownerNames",
|
||||
/** Bounded ring of equipment changes. Cleared by import. */
|
||||
undo: "undo",
|
||||
/** The last pre-import dump, so a wrong file is a click to undo. */
|
||||
snapshots: "snapshots",
|
||||
meta: "meta",
|
||||
} as const;
|
||||
|
||||
export type StoreName = (typeof STORES)[keyof typeof STORES];
|
||||
|
||||
export const ITEM_KIND_INDEX = "by_kind";
|
||||
|
||||
export interface ItemRow {
|
||||
instance: string;
|
||||
kind: "module" | "cartridge";
|
||||
itemId: string;
|
||||
shape: string | null;
|
||||
cells: number | null;
|
||||
set: string | null;
|
||||
level: number;
|
||||
rarity: string;
|
||||
mainStats: Array<{ stat: string; value: number | null }>;
|
||||
substats: Array<{ stat: string; value: number }>;
|
||||
}
|
||||
|
||||
export type EquipmentOrigin = "imported" | "app";
|
||||
|
||||
export interface EquipmentRow {
|
||||
instance: string;
|
||||
/**
|
||||
* Resolved through `ownerNames` at read time and null when unknown. The
|
||||
* character's name is **never** stored on the row: the owner group is exact,
|
||||
* the name attached to it is a guess until the player makes it.
|
||||
*/
|
||||
ownerGroup: string | null;
|
||||
characterId: string | null;
|
||||
/** Board cells this item covers, or null when the capture did not say. */
|
||||
cells: number[] | null;
|
||||
/** `imported` means the game says so; `app` means you have not done it yet. */
|
||||
origin: EquipmentOrigin;
|
||||
/** Shared by every row written in one equip action, so undo is one entry. */
|
||||
batchId: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
export interface CharacterRow {
|
||||
characterId: string;
|
||||
/** Refreshed by import. */
|
||||
level: number | null;
|
||||
breakthroughs: number | null;
|
||||
/** Preserved across imports. */
|
||||
arcId: string | null;
|
||||
arcRefinement: number;
|
||||
effectToggles: Record<string, boolean | number>;
|
||||
buildVariant: string | null;
|
||||
customTargets: Array<{ stat: string; target: number; weight: number }> | null;
|
||||
useCustom: boolean;
|
||||
/**
|
||||
* What the game actually shows, read off-team and typed in by the player.
|
||||
*
|
||||
* The stat model has measured gaps, so a permanent predicted-vs-actual panel
|
||||
* is how drift gets caught by the player instead of being trusted silently.
|
||||
*/
|
||||
measuredSheet: Record<string, number> | null;
|
||||
}
|
||||
|
||||
export interface OwnerNameRow {
|
||||
ownerGroup: string;
|
||||
characterId: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
export interface UndoRow {
|
||||
id?: number;
|
||||
batchId: string;
|
||||
before: EquipmentRow[];
|
||||
after: EquipmentRow[];
|
||||
at: number;
|
||||
}
|
||||
|
||||
export const UNDO_LIMIT = 50;
|
||||
|
||||
export interface MetaRow {
|
||||
key: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
export interface Database {
|
||||
items: ItemRow[];
|
||||
equipment: EquipmentRow[];
|
||||
characters: CharacterRow[];
|
||||
ownerNames: OwnerNameRow[];
|
||||
meta: MetaRow[];
|
||||
}
|
||||
|
||||
export function emptyCharacter(characterId: string): CharacterRow {
|
||||
return {
|
||||
characterId,
|
||||
level: null,
|
||||
breakthroughs: null,
|
||||
arcId: null,
|
||||
arcRefinement: 0,
|
||||
effectToggles: {},
|
||||
buildVariant: null,
|
||||
customTargets: null,
|
||||
useCustom: false,
|
||||
measuredSheet: null,
|
||||
};
|
||||
}
|
||||
277
src/db/store.ts
Normal file
277
src/db/store.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
/**
|
||||
* The in-memory mirror and the operations that change it.
|
||||
*
|
||||
* IndexedDB is the durable log; this is the query engine. Everything is loaded
|
||||
* at boot - well under 5 MB - and filtered and sorted in JS, because substat
|
||||
* filters are not indexable in IndexedDB anyway.
|
||||
*/
|
||||
import { mergeCharacters, type ImportResult } from "./import.ts";
|
||||
import {
|
||||
UNDO_LIMIT,
|
||||
type Database,
|
||||
type EquipmentRow,
|
||||
type ItemRow,
|
||||
type OwnerNameRow,
|
||||
type UndoRow,
|
||||
} from "./schema.ts";
|
||||
import type { PersistenceAdapter, StoredState } from "./adapters/types.ts";
|
||||
|
||||
/** How many cells a complete board covers. */
|
||||
export const BOARD_CELLS = 20;
|
||||
|
||||
export interface EquipOptions {
|
||||
/** Cells each instance covers, when a packing decided them. */
|
||||
cells?: Record<string, number[]>;
|
||||
now?: () => number;
|
||||
newBatchId?: () => string;
|
||||
}
|
||||
|
||||
export interface EquipOutcome {
|
||||
state: StoredState;
|
||||
/**
|
||||
* Characters left with a hole because this equip took an item they were
|
||||
* wearing. Their build is `incomplete` - covered cells no longer total 20 -
|
||||
* and the confirmation dialog has to name them and the piece before it runs.
|
||||
*/
|
||||
displaced: Array<{ characterId: string; instances: string[] }>;
|
||||
}
|
||||
|
||||
/** The character wearing a row: stored directly, or resolved through the group. */
|
||||
export function resolveCharacter(
|
||||
row: EquipmentRow,
|
||||
ownerNames: readonly OwnerNameRow[],
|
||||
): string | null {
|
||||
if (row.characterId) return row.characterId;
|
||||
if (!row.ownerGroup) return null;
|
||||
return ownerNames.find((name) => name.ownerGroup === row.ownerGroup)?.characterId ?? null;
|
||||
}
|
||||
|
||||
export function equipmentOf(
|
||||
db: Database,
|
||||
characterId: string,
|
||||
): EquipmentRow[] {
|
||||
return db.equipment.filter((row) => resolveCharacter(row, db.ownerNames) === characterId);
|
||||
}
|
||||
|
||||
/** Covered cells, when the rows carry them. A complete build totals 20. */
|
||||
export function coveredCells(rows: readonly EquipmentRow[]): number {
|
||||
let total = 0;
|
||||
for (const row of rows) total += row.cells?.length ?? 0;
|
||||
return total;
|
||||
}
|
||||
|
||||
export function isComplete(rows: readonly EquipmentRow[]): boolean {
|
||||
return coveredCells(rows) === BOARD_CELLS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows the player has not actually applied in game yet.
|
||||
*
|
||||
* `origin` does all the work: after the next import these vanish on their own,
|
||||
* because either you did it or you didn't. There is no reconciliation logic and
|
||||
* there does not need to be.
|
||||
*/
|
||||
export function todoInGame(db: Database): Map<string, EquipmentRow[]> {
|
||||
const out = new Map<string, EquipmentRow[]>();
|
||||
for (const row of db.equipment) {
|
||||
if (row.origin !== "app") continue;
|
||||
const character = resolveCharacter(row, db.ownerNames) ?? "";
|
||||
const bucket = out.get(character);
|
||||
if (bucket) bucket.push(row);
|
||||
else out.set(character, [row]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct one item by hand.
|
||||
*
|
||||
* The decode is good but not infallible, and a player who spots a wrong value
|
||||
* needs to fix it rather than work around it. The edit is stored like any other
|
||||
* row, so the **next import overwrites it** - which is right: the import is the
|
||||
* game speaking, and a correction that outlived the thing it corrected would be
|
||||
* worse than losing it.
|
||||
*/
|
||||
export function editItem(
|
||||
state: StoredState,
|
||||
instance: string,
|
||||
patch: Partial<Omit<ItemRow, "instance">>,
|
||||
): StoredState {
|
||||
const items = state.db.items.map((row) =>
|
||||
row.instance === instance ? { ...row, ...patch, instance } : row,
|
||||
);
|
||||
return { ...state, db: { ...state.db, items } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Instances worn by anyone other than these characters.
|
||||
*
|
||||
* This is R2's toggle turned **off**: no item may be taken from another
|
||||
* character. With the toggle on the solve sees the whole pool, which is why the
|
||||
* team solve has to name whoever is left with a hole.
|
||||
*
|
||||
* An item with no resolvable owner is *not* excluded: the capture says it is
|
||||
* worn, but until the player names that owner group it belongs to nobody the app
|
||||
* can reason about, and locking it away would hide most of the pool.
|
||||
*/
|
||||
export function heldByOthers(
|
||||
db: Database,
|
||||
characterIds: readonly string[],
|
||||
): string[] {
|
||||
const mine = new Set(characterIds);
|
||||
const out: string[] = [];
|
||||
for (const row of db.equipment) {
|
||||
const owner = resolveCharacter(row, db.ownerNames);
|
||||
if (owner !== null && !mine.has(owner)) out.push(row.instance);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an import.
|
||||
*
|
||||
* Items and equipment are replaced outright; the pre-import snapshot is taken
|
||||
* first; character configuration is merged rather than overwritten; owner names
|
||||
* are left completely alone; undo is cleared, because its rows refer to items
|
||||
* that may no longer exist.
|
||||
*/
|
||||
export function applyImport(
|
||||
state: StoredState,
|
||||
result: ImportResult,
|
||||
now = Date.now,
|
||||
): StoredState {
|
||||
if (!result.ok || !result.rows) throw new Error("refusing to apply a failed import");
|
||||
const { items, equipment, characters, userUid, serverId, exporterVersion } = result.rows;
|
||||
|
||||
const meta = state.db.meta.filter(
|
||||
(row) => !["lastImport", "userUid", "serverId", "exporterVersion"].includes(row.key),
|
||||
);
|
||||
meta.push(
|
||||
{ key: "lastImport", value: now() },
|
||||
{ key: "userUid", value: userUid },
|
||||
{ key: "serverId", value: serverId },
|
||||
{ key: "exporterVersion", value: exporterVersion },
|
||||
);
|
||||
|
||||
return {
|
||||
snapshot: structuredClone(state.db),
|
||||
undo: [],
|
||||
db: {
|
||||
items,
|
||||
equipment,
|
||||
characters: mergeCharacters(state.db.characters, characters),
|
||||
ownerNames: state.db.ownerNames,
|
||||
meta,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Equip a set of items on one character, as one atomic change.
|
||||
*
|
||||
* Both sides of the move go into a **single** undo entry: the character's old
|
||||
* rows and any rows taken from someone else. One undo then restores both, which
|
||||
* is the only behaviour that is not surprising.
|
||||
*/
|
||||
export function equip(
|
||||
state: StoredState,
|
||||
characterId: string,
|
||||
instances: readonly string[],
|
||||
options: EquipOptions = {},
|
||||
): EquipOutcome {
|
||||
const now = options.now ?? Date.now;
|
||||
const at = now();
|
||||
const batchId = options.newBatchId?.() ?? `equip-${at}-${characterId}`;
|
||||
const wanted = new Set(instances);
|
||||
|
||||
const before: EquipmentRow[] = [];
|
||||
const displacedBy = new Map<string, string[]>();
|
||||
|
||||
const kept = state.db.equipment.filter((row) => {
|
||||
const owner = resolveCharacter(row, state.db.ownerNames);
|
||||
if (owner === characterId) {
|
||||
before.push(row);
|
||||
return false;
|
||||
}
|
||||
if (wanted.has(row.instance)) {
|
||||
before.push(row);
|
||||
if (owner) {
|
||||
const bucket = displacedBy.get(owner);
|
||||
if (bucket) bucket.push(row.instance);
|
||||
else displacedBy.set(owner, [row.instance]);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const after: EquipmentRow[] = instances.map((instance) => ({
|
||||
instance,
|
||||
ownerGroup: null,
|
||||
characterId,
|
||||
cells: options.cells?.[instance] ?? null,
|
||||
origin: "app",
|
||||
batchId,
|
||||
at,
|
||||
}));
|
||||
|
||||
const undo: UndoRow[] = [...state.undo, { batchId, before, after, at }].slice(-UNDO_LIMIT);
|
||||
|
||||
return {
|
||||
state: { ...state, db: { ...state.db, equipment: [...kept, ...after] }, undo },
|
||||
displaced: [...displacedBy].map(([id, list]) => ({ characterId: id, instances: list })),
|
||||
};
|
||||
}
|
||||
|
||||
/** Reverse the most recent equip, both sides of it. */
|
||||
export function undoLast(state: StoredState): StoredState {
|
||||
const entry = state.undo[state.undo.length - 1];
|
||||
if (!entry) return state;
|
||||
const removed = new Set(entry.after.map((row) => row.instance));
|
||||
const equipment = state.db.equipment.filter((row) => !removed.has(row.instance));
|
||||
const restored = entry.before.filter(
|
||||
(row) => !equipment.some((existing) => existing.instance === row.instance),
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
db: { ...state.db, equipment: [...equipment, ...restored] },
|
||||
undo: state.undo.slice(0, -1),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Name an owner group.
|
||||
*
|
||||
* Keyed by the group, which is stable across exports, so this is answered once
|
||||
* ever rather than once per import - and the name is never written onto an
|
||||
* equipment row.
|
||||
*/
|
||||
export function nameOwnerGroup(
|
||||
state: StoredState,
|
||||
ownerGroup: string,
|
||||
characterId: string,
|
||||
now = Date.now,
|
||||
): StoredState {
|
||||
const ownerNames = state.db.ownerNames.filter((row) => row.ownerGroup !== ownerGroup);
|
||||
ownerNames.push({ ownerGroup, characterId, at: now() });
|
||||
return { ...state, db: { ...state.db, ownerNames } };
|
||||
}
|
||||
|
||||
/** Groups the capture found that nobody has named yet. */
|
||||
export function unnamedGroups(db: Database): string[] {
|
||||
const named = new Set(db.ownerNames.map((row) => row.ownerGroup));
|
||||
const groups = new Set<string>();
|
||||
for (const row of db.equipment) {
|
||||
if (row.ownerGroup && !named.has(row.ownerGroup)) groups.add(row.ownerGroup);
|
||||
}
|
||||
return [...groups].sort();
|
||||
}
|
||||
|
||||
export async function persist(
|
||||
adapter: PersistenceAdapter,
|
||||
state: StoredState,
|
||||
scope: "all" | "equipment",
|
||||
): Promise<void> {
|
||||
if (scope === "all") await adapter.replaceAll(state);
|
||||
else await adapter.writeEquipment(state.db.equipment, state.undo);
|
||||
}
|
||||
153
src/domain/arcs.ts
Normal file
153
src/domain/arcs.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/**
|
||||
* What an Arc contributes.
|
||||
*
|
||||
* Two separate things, and they behave differently:
|
||||
*
|
||||
* 1. Its **own stat line** (`stats`), indexed by the Arc's level 1..80. Every
|
||||
* Arc carries flat `AtkBase` plus one secondary stat.
|
||||
* 2. Its **effect placeholders** (`values`), indexed by refinement 1..5. These
|
||||
* are only usable when `data/arc_effects.json` classifies them, because the
|
||||
* game data gives 104 of the 144 placeholders an empty `id_stats` and no way
|
||||
* to tell a stat from a cooldown.
|
||||
*
|
||||
* The classification decides the control, not a guess about the text:
|
||||
* `always` is unconditional, `toggle` is a checkbox, `stacks` is a 0..N count,
|
||||
* and `duration` and `unmodellable` are never scored at all - they are reported
|
||||
* so the UI can say what it is leaving out rather than silently dropping it.
|
||||
*/
|
||||
import type { Arc, ArcEffect, ArcEffectMode, GameData } from "../state/gamedata.ts";
|
||||
import { emptyVector, slotOf } from "./statvec.ts";
|
||||
|
||||
export const MAX_ARC_LEVEL = 80;
|
||||
export const MAX_REFINEMENT = 5;
|
||||
|
||||
export interface ArcConfig {
|
||||
arcId: string;
|
||||
/** 1..80. */
|
||||
level: number;
|
||||
/** 1..5. */
|
||||
refinement: number;
|
||||
/**
|
||||
* Per placeholder index: `true`/`false` for a toggle, a count for stacks.
|
||||
* Missing means the effect's own default.
|
||||
*/
|
||||
toggles?: Record<number, boolean | number>;
|
||||
}
|
||||
|
||||
export interface ArcContribution {
|
||||
vector: Float32Array;
|
||||
/** Effects deliberately not scored, so the UI can say so. */
|
||||
omitted: Array<{ placeholder: number; mode: ArcEffectMode; why: string }>;
|
||||
/** Stats the model has no slot for. Should stay empty; a warning if not. */
|
||||
unknownStats: string[];
|
||||
}
|
||||
|
||||
interface RawStatCurve {
|
||||
id_stats: string;
|
||||
bIsPercent: boolean;
|
||||
values: number[];
|
||||
}
|
||||
|
||||
interface RawValue {
|
||||
id_value: number;
|
||||
id_stats: string;
|
||||
bIsPercent: boolean;
|
||||
values: Array<string | number>;
|
||||
}
|
||||
|
||||
/** `"12%"` is 0.12; a bare number is itself. */
|
||||
export function parseArcValue(raw: string | number, percent: boolean): number {
|
||||
if (typeof raw === "number") return percent ? raw / 100 : raw;
|
||||
const trimmed = raw.trim();
|
||||
const numeric = Number.parseFloat(trimmed.replace("%", ""));
|
||||
if (!Number.isFinite(numeric)) return 0;
|
||||
return trimmed.endsWith("%") || percent ? numeric / 100 : numeric;
|
||||
}
|
||||
|
||||
export function arcContribution(
|
||||
arc: Arc,
|
||||
effects: GameData["arcEffects"][string] | undefined,
|
||||
config: ArcConfig,
|
||||
): ArcContribution {
|
||||
const vector = emptyVector();
|
||||
const omitted: ArcContribution["omitted"] = [];
|
||||
const unknownStats: string[] = [];
|
||||
|
||||
const level = Math.min(Math.max(Math.round(config.level), 1), MAX_ARC_LEVEL);
|
||||
const refinement = Math.min(Math.max(Math.round(config.refinement), 1), MAX_REFINEMENT);
|
||||
|
||||
const add = (stat: string, value: number): void => {
|
||||
const slot = slotOf(stat);
|
||||
if (slot < 0) {
|
||||
if (stat && !unknownStats.includes(stat)) unknownStats.push(stat);
|
||||
return;
|
||||
}
|
||||
vector[slot] = vector[slot]! + value;
|
||||
};
|
||||
|
||||
for (const curve of (arc.stats ?? []) as RawStatCurve[]) {
|
||||
const raw = curve.values?.[level - 1];
|
||||
if (raw === undefined) continue;
|
||||
add(curve.id_stats, curve.bIsPercent ? raw / 100 : raw);
|
||||
}
|
||||
|
||||
const byPlaceholder = new Map<number, ArcEffect>(
|
||||
(effects?.effects ?? []).map((effect) => [effect.placeholder, effect]),
|
||||
);
|
||||
|
||||
for (const entry of (arc.values ?? []) as RawValue[]) {
|
||||
const effect = byPlaceholder.get(entry.id_value);
|
||||
const mode: ArcEffectMode = effect?.mode ?? "unmodellable";
|
||||
const raw = entry.values?.[refinement - 1];
|
||||
|
||||
if (mode === "duration" || mode === "unmodellable" || !entry.id_stats || raw === undefined) {
|
||||
omitted.push({
|
||||
placeholder: entry.id_value,
|
||||
mode,
|
||||
why: effect?.why ?? "no stat id in the game data",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = parseArcValue(raw, entry.bIsPercent);
|
||||
const setting = config.toggles?.[entry.id_value];
|
||||
|
||||
if (mode === "always") {
|
||||
add(entry.id_stats, value);
|
||||
continue;
|
||||
}
|
||||
if (mode === "toggle") {
|
||||
const on = typeof setting === "boolean" ? setting : (effect?.default ?? false);
|
||||
if (on) add(entry.id_stats, value);
|
||||
else omitted.push({ placeholder: entry.id_value, mode, why: "toggled off" });
|
||||
continue;
|
||||
}
|
||||
// stacks
|
||||
const count = typeof setting === "number" ? Math.max(0, Math.round(setting)) : 0;
|
||||
if (count > 0) add(entry.id_stats, value * count);
|
||||
else omitted.push({ placeholder: entry.id_value, mode, why: "no stacks assumed" });
|
||||
}
|
||||
|
||||
return { vector, omitted, unknownStats };
|
||||
}
|
||||
|
||||
/** The controls a UI needs for one Arc, in placeholder order. */
|
||||
export function arcControls(
|
||||
arc: Arc,
|
||||
effects: GameData["arcEffects"][string] | undefined,
|
||||
): Array<{ placeholder: number; mode: ArcEffectMode; stat: string; why: string }> {
|
||||
const byPlaceholder = new Map<number, ArcEffect>(
|
||||
(effects?.effects ?? []).map((effect) => [effect.placeholder, effect]),
|
||||
);
|
||||
return ((arc.values ?? []) as RawValue[])
|
||||
.map((entry) => {
|
||||
const effect = byPlaceholder.get(entry.id_value);
|
||||
return {
|
||||
placeholder: entry.id_value,
|
||||
mode: effect?.mode ?? ("unmodellable" as ArcEffectMode),
|
||||
stat: entry.id_stats,
|
||||
why: effect?.why ?? "",
|
||||
};
|
||||
})
|
||||
.filter((control) => control.mode === "toggle" || control.mode === "stacks");
|
||||
}
|
||||
132
src/domain/board.ts
Normal file
132
src/domain/board.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
/**
|
||||
* Board geometry and packing.
|
||||
*
|
||||
* A board is the character's console grid: everness ships it as a 7x7 matrix
|
||||
* with -1 blocked and 0 free. Every character's grid has 20 free cells.
|
||||
*
|
||||
* Positions never affect score - only the shape multiset does, via set tier and
|
||||
* console trait - so packing is decided once, offline, and the runtime only
|
||||
* ever looks up one canonical placement per multiset for rendering.
|
||||
*/
|
||||
import { SHAPES, SHAPE_IDS, sizeOf, type Cell, type ShapeId } from "./shapes.ts";
|
||||
|
||||
export type BoardCells = readonly number[];
|
||||
export interface Board {
|
||||
/** Free cells as row * 7 + col, ascending. */
|
||||
cells: BoardCells;
|
||||
rows: number;
|
||||
cols: number;
|
||||
}
|
||||
|
||||
export const GRID = 7;
|
||||
|
||||
export function boardFromSlots(slots: readonly (readonly number[])[]): Board {
|
||||
const cells: number[] = [];
|
||||
for (let row = 0; row < slots.length; row += 1) {
|
||||
const line = slots[row]!;
|
||||
for (let col = 0; col < line.length; col += 1) {
|
||||
if (line[col] === 0) cells.push(row * GRID + col);
|
||||
}
|
||||
}
|
||||
return { cells, rows: slots.length, cols: slots[0]?.length ?? GRID };
|
||||
}
|
||||
|
||||
/** A stable identity for a board, so identical grids share one tiling table. */
|
||||
export function boardKey(board: Board): string {
|
||||
return board.cells.join(",");
|
||||
}
|
||||
|
||||
/** Every translation of a shape that lands entirely on the board. */
|
||||
export function placements(shape: ShapeId, board: Board): number[][] {
|
||||
const free = new Set(board.cells);
|
||||
const offsets = SHAPES[shape] as readonly Cell[];
|
||||
const out: number[][] = [];
|
||||
for (let dr = 0; dr < board.rows; dr += 1) {
|
||||
for (let dc = 0; dc < board.cols; dc += 1) {
|
||||
const placed: number[] = [];
|
||||
let ok = true;
|
||||
for (const [r, c] of offsets) {
|
||||
const row = r + dr;
|
||||
const col = c + dc;
|
||||
if (col >= GRID) { ok = false; break; }
|
||||
const index = row * GRID + col;
|
||||
if (!free.has(index)) { ok = false; break; }
|
||||
placed.push(index);
|
||||
}
|
||||
if (ok) out.push(placed.sort((a, b) => a - b));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface Placement {
|
||||
shape: ShapeId;
|
||||
cells: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact-cover the board with the given shapes, or null.
|
||||
*
|
||||
* Always fills the lowest free cell next, so the search never explores two
|
||||
* orderings of the same placement set.
|
||||
*/
|
||||
export function tile(board: Board, multiset: readonly ShapeId[]): Placement[] | null {
|
||||
const ordered = [...multiset].sort();
|
||||
const options = new Map<ShapeId, number[][]>();
|
||||
for (const shape of new Set(ordered)) options.set(shape, placements(shape, board));
|
||||
|
||||
const free = new Set(board.cells);
|
||||
const order = [...board.cells];
|
||||
const placed: Placement[] = [];
|
||||
|
||||
const solve = (remaining: readonly ShapeId[]): boolean => {
|
||||
if (remaining.length === 0) return free.size === 0;
|
||||
const target = order.find((cell) => free.has(cell));
|
||||
if (target === undefined) return false;
|
||||
for (let i = 0; i < remaining.length; i += 1) {
|
||||
const shape = remaining[i]!;
|
||||
// Identical shapes are interchangeable; trying the second is wasted work.
|
||||
if (i > 0 && shape === remaining[i - 1]) continue;
|
||||
const rest = remaining.slice(0, i).concat(remaining.slice(i + 1));
|
||||
for (const spot of options.get(shape)!) {
|
||||
if (!spot.includes(target)) continue;
|
||||
let fits = true;
|
||||
for (const cell of spot) {
|
||||
if (!free.has(cell)) { fits = false; break; }
|
||||
}
|
||||
if (!fits) continue;
|
||||
for (const cell of spot) free.delete(cell);
|
||||
placed.push({ shape, cells: spot });
|
||||
if (solve(rest)) return true;
|
||||
placed.pop();
|
||||
for (const cell of spot) free.add(cell);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return solve(ordered) ? placed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape multisets whose cells total exactly `freeCells`.
|
||||
*
|
||||
* Sizes are 2, 3 and 4, so a multiset covering 8 cells holds 2 to 4 pieces -
|
||||
* which is why a full build is 6, 7 or 8 modules and never always 7.
|
||||
*/
|
||||
export function fillers(freeCells: number): ShapeId[][] {
|
||||
const found: ShapeId[][] = [];
|
||||
const walk = (start: number, left: number, current: ShapeId[]): void => {
|
||||
if (left === 0) { found.push([...current]); return; }
|
||||
for (let i = start; i < SHAPE_IDS.length; i += 1) {
|
||||
const shape = SHAPE_IDS[i]!;
|
||||
const size = sizeOf(shape);
|
||||
if (size > left) continue;
|
||||
current.push(shape);
|
||||
walk(i, left - size, current);
|
||||
current.pop();
|
||||
}
|
||||
};
|
||||
walk(0, freeCells, []);
|
||||
return found;
|
||||
}
|
||||
65
src/domain/cartridges.ts
Normal file
65
src/domain/cartridges.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* Cartridge sets: the four module shapes each one needs to switch its bonuses on.
|
||||
*
|
||||
* A set needs four *specific distinct* shapes - not four of a type. Every set's
|
||||
* four shapes total exactly 12 cells, so on a 20-cell board the requirement
|
||||
* fixes 12 cells and leaves 8 free. That is the optimizer's search space.
|
||||
*
|
||||
* Bonus tiers count how many *distinct* required shapes are on the board, in any
|
||||
* order and any position. Duplicates do not count twice.
|
||||
*/
|
||||
import type { ShapeId } from "./shapes.ts";
|
||||
|
||||
export type SetName = keyof typeof REQUIRED_PIECES;
|
||||
|
||||
export const REQUIRED_PIECES = {
|
||||
"Crimson: Twin Butterflies": ["cell2_style2", "cell3_style1", "cell3_style6", "cell4_style5"],
|
||||
"Devil's Blood: Curse": ["cell2_style2", "cell3_style2", "cell3_style4", "cell4_style6"],
|
||||
Diabolos: ["cell2_style2", "cell3_style4", "cell3_style6", "cell4_style1"],
|
||||
"Fireflies and the Forest": ["cell2_style1", "cell3_style2", "cell3_style3", "cell4_style6"],
|
||||
"Kingdom's Guard": ["cell3_style1", "cell3_style2", "cell3_style5", "cell3_style6"],
|
||||
"Lost Radiance": ["cell2_style1", "cell3_style3", "cell3_style5", "cell4_style2"],
|
||||
"Quiet Manor": ["cell2_style1", "cell2_style2", "cell4_style2", "cell4_style6"],
|
||||
"Shadow Creed": ["cell2_style1", "cell2_style2", "cell4_style1", "cell4_style6"],
|
||||
"Speedy Hedgehog": ["cell3_style3", "cell3_style4", "cell3_style5", "cell3_style6"],
|
||||
"Street Boxer": ["cell2_style1", "cell3_style1", "cell3_style5", "cell4_style5"],
|
||||
"Thea's Night Tavern": ["cell3_style1", "cell3_style2", "cell3_style3", "cell3_style4"],
|
||||
"Tiny Big Adventure": ["cell2_style1", "cell2_style2", "cell4_style1", "cell4_style5"],
|
||||
} as const satisfies Record<string, readonly [ShapeId, ShapeId, ShapeId, ShapeId]>;
|
||||
|
||||
export const SET_NAMES = Object.keys(REQUIRED_PIECES) as SetName[];
|
||||
|
||||
/**
|
||||
* Set ids as the packets spell them, confirmed against everness's cartridge
|
||||
* boxes (`Testeqbox_<id>`) - the only source that names the three sets this
|
||||
* account does not own. Those three are Psyche, Shield and Heal; an earlier
|
||||
* guess of Blood, Night and Kingdom was wrong on all three.
|
||||
*/
|
||||
export const SET_IDS: Record<string, SetName> = {
|
||||
Attack_orange: "Shadow Creed",
|
||||
Chaos_orange: "Diabolos",
|
||||
Cosmos_orange: "Lost Radiance",
|
||||
GetEfficiency_orange: "Speedy Hedgehog",
|
||||
Incantation_orange: "Crimson: Twin Butterflies",
|
||||
Lakshana_orange: "Street Boxer",
|
||||
Nature_orange: "Fireflies and the Forest",
|
||||
Psychically_orange: "Quiet Manor",
|
||||
Mag_orange: "Tiny Big Adventure",
|
||||
// Not owned, so never yet seen in a capture.
|
||||
Psyche_orange: "Devil's Blood: Curse",
|
||||
Heal_orange: "Thea's Night Tavern",
|
||||
Shield_orange: "Kingdom's Guard",
|
||||
};
|
||||
|
||||
export type Tier = 2 | 4;
|
||||
|
||||
/** Which bonus tiers a board unlocks: [], [2] or [2, 4]. */
|
||||
export function activeTiers(set: SetName, shapesOnBoard: Iterable<ShapeId>): Tier[] {
|
||||
const required = new Set<string>(REQUIRED_PIECES[set]);
|
||||
const present = new Set<string>();
|
||||
for (const shape of shapesOnBoard) {
|
||||
if (required.has(shape)) present.add(shape);
|
||||
}
|
||||
if (present.size >= 4) return [2, 4];
|
||||
return present.size >= 2 ? [2] : [];
|
||||
}
|
||||
85
src/domain/guides.ts
Normal file
85
src/domain/guides.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* Published build guidance: targets and a ranked substat priority per character.
|
||||
*
|
||||
* The **weights the solver runs with come from the ranking**, not from a number
|
||||
* somebody typed. A rank is what a guide actually publishes; inventing weights
|
||||
* from it in one place keeps every character on the same scale.
|
||||
*
|
||||
* `data-src/guides.json` is committed and human-reviewed. Nothing is scraped at
|
||||
* runtime: CORS forbids it, and a site redesign should break a script somebody
|
||||
* runs deliberately rather than the app in a player's hands.
|
||||
*/
|
||||
import { isKnownStat, type StatId } from "./statvec.ts";
|
||||
import type { StatTarget } from "./scoring.ts";
|
||||
|
||||
export interface GuideVariant {
|
||||
/** "Main DPS", "SubDPS", "Break", "DoT" - a character can have several. */
|
||||
name: string;
|
||||
/** Recommended endgame stats: a floor to reach, not a ceiling. */
|
||||
targets: Array<{ stat: string; target: number }>;
|
||||
/** Substat priority, best first. */
|
||||
priority: string[];
|
||||
}
|
||||
|
||||
export interface CharacterGuide {
|
||||
/** The `GA_<key>_*` ability key, so it joins to a capture directly. */
|
||||
key: string;
|
||||
source: string;
|
||||
updated: string;
|
||||
variants: GuideVariant[];
|
||||
}
|
||||
|
||||
export interface GuideTable {
|
||||
format: string;
|
||||
format_version: number;
|
||||
characters: CharacterGuide[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank to weight.
|
||||
*
|
||||
* Linear from `top` down to 1: rank 1 is worth `top`, and the last ranked stat
|
||||
* is still worth something. A geometric curve would make anything past third
|
||||
* place worthless, which is not what "priority" means on a guide - the lower
|
||||
* ranks are where a shortfall is *supposed* to land, not stats to ignore.
|
||||
*/
|
||||
export const TOP_WEIGHT = 5;
|
||||
|
||||
export function weightForRank(rank: number, total: number): number {
|
||||
if (total <= 1) return TOP_WEIGHT;
|
||||
const step = (TOP_WEIGHT - 1) / (total - 1);
|
||||
return TOP_WEIGHT - step * rank;
|
||||
}
|
||||
|
||||
/** The scoring targets a variant implies. */
|
||||
export function targetsFromGuide(variant: GuideVariant): StatTarget[] {
|
||||
const ranked = variant.priority.filter(isKnownStat);
|
||||
const rankOf = new Map<string, number>(ranked.map((stat, index) => [stat, index]));
|
||||
|
||||
const out: StatTarget[] = [];
|
||||
for (const entry of variant.targets) {
|
||||
if (!isKnownStat(entry.stat)) continue;
|
||||
const rank = rankOf.get(entry.stat);
|
||||
out.push({
|
||||
stat: entry.stat as StatId,
|
||||
target: entry.target,
|
||||
// A stat with a target but no place in the ranking still matters; it just
|
||||
// sits at the bottom rather than being dropped.
|
||||
weight: rank === undefined ? 1 : weightForRank(rank, ranked.length),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function guideFor(table: GuideTable, key: string): CharacterGuide | null {
|
||||
return table.characters.find((entry) => entry.key === key) ?? null;
|
||||
}
|
||||
|
||||
export function variantFor(
|
||||
guide: CharacterGuide | null,
|
||||
name: string | null,
|
||||
): GuideVariant | null {
|
||||
if (!guide || guide.variants.length === 0) return null;
|
||||
if (name) return guide.variants.find((variant) => variant.name === name) ?? guide.variants[0]!;
|
||||
return guide.variants[0]!;
|
||||
}
|
||||
136
src/domain/items.ts
Normal file
136
src/domain/items.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
/**
|
||||
* The owned pool, as the solver wants it.
|
||||
*
|
||||
* An export gives items as records with named stats. The solver wants one flat
|
||||
* `Float32Array` and integer indices into it, so this is the one place the two
|
||||
* representations meet. Nothing downstream of here handles a stat by name.
|
||||
*/
|
||||
import { SET_IDS, type SetName } from "./cartridges.ts";
|
||||
import type { ShapeId } from "./shapes.ts";
|
||||
import { SHAPES } from "./shapes.ts";
|
||||
import { emptyPool, poolSlice, vectorFrom, type StatPair } from "./statvec.ts";
|
||||
|
||||
/** One item as an export spells it. */
|
||||
export interface ExportedItem {
|
||||
instance: string;
|
||||
kind: "module" | "cartridge";
|
||||
item_id: string;
|
||||
// Explicitly `| undefined`: `exactOptionalPropertyTypes` otherwise refuses a
|
||||
// value built by spreading a row whose field may be absent.
|
||||
shape?: string | undefined;
|
||||
module_type?: string | undefined;
|
||||
set?: string | undefined;
|
||||
level: number;
|
||||
rarity: string;
|
||||
main_stats: StatPair[];
|
||||
substats: StatPair[];
|
||||
owner_group: string | null;
|
||||
}
|
||||
|
||||
export interface OwnedItem {
|
||||
instance: string;
|
||||
kind: "module" | "cartridge";
|
||||
itemId: string;
|
||||
/** Modules only. */
|
||||
shape: ShapeId | null;
|
||||
/** 2, 3 or 4. Modules only. */
|
||||
cells: number | null;
|
||||
/** Cartridges only. */
|
||||
set: SetName | null;
|
||||
level: number;
|
||||
rarity: string;
|
||||
ownerGroup: string | null;
|
||||
/** Index into the pool's flat vector array. */
|
||||
index: number;
|
||||
/**
|
||||
* The cartridge's main stat, which enters scoring only as a tiebreak. Null
|
||||
* for modules, whose mains are fixed by cell count and carry no choice.
|
||||
*/
|
||||
mainStat: string | null;
|
||||
}
|
||||
|
||||
export interface ItemPool {
|
||||
items: OwnedItem[];
|
||||
/** `items.length * SLOT_COUNT` values, laid end to end. */
|
||||
vectors: Float32Array;
|
||||
modulesByShape: Map<ShapeId, number[]>;
|
||||
cartridgesBySet: Map<SetName, number[]>;
|
||||
/** Items an import says are already worn, by owner group. */
|
||||
equippedByOwner: Map<string, number[]>;
|
||||
/** Item ids the decoder emitted that this build has no shape for. */
|
||||
unknownShapes: string[];
|
||||
}
|
||||
|
||||
const isShapeId = (value: string): value is ShapeId => value in SHAPES;
|
||||
|
||||
/**
|
||||
* Build the pool.
|
||||
*
|
||||
* Level is deliberately not a filter: substat values are identical at +0 and
|
||||
* +20, so an unlevelled item is a valid recommendation and the UI shows its
|
||||
* level rather than the solver hiding it.
|
||||
*/
|
||||
export function buildPool(exported: readonly ExportedItem[]): ItemPool {
|
||||
const items: OwnedItem[] = [];
|
||||
const vectors = emptyPool(exported.length);
|
||||
const modulesByShape = new Map<ShapeId, number[]>();
|
||||
const cartridgesBySet = new Map<SetName, number[]>();
|
||||
const equippedByOwner = new Map<string, number[]>();
|
||||
const unknownShapes: string[] = [];
|
||||
|
||||
exported.forEach((raw, index) => {
|
||||
const shape = raw.shape && isShapeId(raw.shape) ? raw.shape : null;
|
||||
if (raw.kind === "module" && shape === null) unknownShapes.push(raw.item_id);
|
||||
|
||||
const set =
|
||||
raw.kind === "cartridge"
|
||||
? (SET_IDS[raw.item_id] ?? (raw.set as SetName | undefined) ?? null)
|
||||
: null;
|
||||
|
||||
vectorFrom(raw.main_stats, poolSlice(vectors, index));
|
||||
vectorFrom(raw.substats, poolSlice(vectors, index));
|
||||
|
||||
const item: OwnedItem = {
|
||||
instance: raw.instance,
|
||||
kind: raw.kind,
|
||||
itemId: raw.item_id,
|
||||
shape,
|
||||
cells: shape ? SHAPES[shape].length : null,
|
||||
set,
|
||||
level: raw.level,
|
||||
rarity: raw.rarity,
|
||||
ownerGroup: raw.owner_group,
|
||||
index,
|
||||
mainStat: raw.kind === "cartridge" ? (raw.main_stats[0]?.stat ?? null) : null,
|
||||
};
|
||||
items.push(item);
|
||||
|
||||
if (item.kind === "module" && shape) {
|
||||
const bucket = modulesByShape.get(shape);
|
||||
if (bucket) bucket.push(index);
|
||||
else modulesByShape.set(shape, [index]);
|
||||
}
|
||||
if (item.kind === "cartridge" && set) {
|
||||
const bucket = cartridgesBySet.get(set);
|
||||
if (bucket) bucket.push(index);
|
||||
else cartridgesBySet.set(set, [index]);
|
||||
}
|
||||
if (item.ownerGroup) {
|
||||
const bucket = equippedByOwner.get(item.ownerGroup);
|
||||
if (bucket) bucket.push(index);
|
||||
else equippedByOwner.set(item.ownerGroup, [index]);
|
||||
}
|
||||
});
|
||||
|
||||
return { items, vectors, modulesByShape, cartridgesBySet, equippedByOwner, unknownShapes };
|
||||
}
|
||||
|
||||
/** Indices of every module of a shape, minus anything excluded. */
|
||||
export function availableModules(
|
||||
pool: ItemPool,
|
||||
shape: ShapeId,
|
||||
excluded: ReadonlySet<number>,
|
||||
): number[] {
|
||||
const all = pool.modulesByShape.get(shape) ?? [];
|
||||
return excluded.size === 0 ? all : all.filter((index) => !excluded.has(index));
|
||||
}
|
||||
76
src/domain/render.ts
Normal file
76
src/domain/render.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* Turning a packing into something drawable.
|
||||
*
|
||||
* The result view has to read as blocks, not as a grid of squares: borders are
|
||||
* drawn only on the edges facing a *different* piece, so each polyomino looks
|
||||
* like one object rather than three or four cells that happen to share a colour.
|
||||
* That is the whole point of the visual - the player looks at a block, reads its
|
||||
* stats, and finds the matching module in their own inventory list.
|
||||
*/
|
||||
import { GRID } from "./board.ts";
|
||||
|
||||
export interface RenderedCell {
|
||||
cell: number;
|
||||
row: number;
|
||||
col: number;
|
||||
/** Index of the piece covering this cell, or -1 for a free board cell. */
|
||||
piece: number;
|
||||
/** Draw a border on this side: the neighbour is a different piece or off-board. */
|
||||
top: boolean;
|
||||
right: boolean;
|
||||
bottom: boolean;
|
||||
left: boolean;
|
||||
}
|
||||
|
||||
export interface BoardRender {
|
||||
cells: RenderedCell[];
|
||||
rows: number;
|
||||
cols: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* `cells` are the board's free cells ascending; `placement[i]` is the piece
|
||||
* covering `cells[i]`, matching `Tiling`.
|
||||
*/
|
||||
export function renderBoard(
|
||||
cells: readonly number[],
|
||||
placement: readonly number[],
|
||||
): BoardRender {
|
||||
const pieceAt = new Map<number, number>();
|
||||
cells.forEach((cell, index) => pieceAt.set(cell, placement[index] ?? -1));
|
||||
|
||||
const samePiece = (cell: number, deltaRow: number, deltaCol: number): boolean => {
|
||||
const row = Math.floor(cell / GRID) + deltaRow;
|
||||
const col = (cell % GRID) + deltaCol;
|
||||
// A move off the grid is not a neighbour, so that side always gets a border.
|
||||
if (row < 0 || row >= GRID || col < 0 || col >= GRID) return false;
|
||||
const neighbour = pieceAt.get(row * GRID + col);
|
||||
return neighbour !== undefined && neighbour === pieceAt.get(cell);
|
||||
};
|
||||
|
||||
const rendered = cells.map((cell, index) => ({
|
||||
cell,
|
||||
row: Math.floor(cell / GRID),
|
||||
col: cell % GRID,
|
||||
piece: placement[index] ?? -1,
|
||||
top: !samePiece(cell, -1, 0),
|
||||
right: !samePiece(cell, 0, 1),
|
||||
bottom: !samePiece(cell, 1, 0),
|
||||
left: !samePiece(cell, 0, -1),
|
||||
}));
|
||||
|
||||
return { cells: rendered, rows: GRID, cols: GRID };
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct hues per piece, evenly spaced round the wheel.
|
||||
*
|
||||
* A build is 6 to 8 pieces, so an even split keeps neighbours far apart in hue
|
||||
* without needing a hand-tuned palette that would break as soon as a build had
|
||||
* one more piece than the palette had entries.
|
||||
*/
|
||||
export function pieceColour(piece: number, total: number): string {
|
||||
if (piece < 0) return "transparent";
|
||||
const hue = Math.round((360 / Math.max(total, 1)) * piece);
|
||||
return `hsl(${hue} 62% 55%)`;
|
||||
}
|
||||
220
src/domain/scoring.ts
Normal file
220
src/domain/scoring.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
/**
|
||||
* The objective.
|
||||
*
|
||||
* Targets are Prydwen's recommended endgame stats - a floor to reach, not a
|
||||
* ceiling - so each stat's attainment saturates at `min(value / target, 1)` and
|
||||
* the score is the weighted mean of those attainments. Weights come from the
|
||||
* character's published substat ranking, so where the targets cannot all be met
|
||||
* the shortfall lands on the lowest-ranked stats by construction.
|
||||
*
|
||||
* Saturation has a consequence worth stating plainly: **once every target is
|
||||
* cleared, every build ties at 1.0** and the winner is decided entirely by the
|
||||
* tie-breakers below. Both are therefore per-stat, configurable and rendered -
|
||||
* never a hidden constant.
|
||||
*
|
||||
* There is no damage model here. Without rotation or ability-multiplier data,
|
||||
* anything claiming to maximise DPS would be inventing numbers.
|
||||
*/
|
||||
import { SLOT_COUNT, STAT_SLOTS, slotOf, type StatId } from "./statvec.ts";
|
||||
|
||||
export interface StatTarget {
|
||||
stat: StatId;
|
||||
/** The floor to reach. A target of 0 means "no target": weight is ignored. */
|
||||
target: number;
|
||||
/** Relative importance, from the substat ranking. Any positive scale. */
|
||||
weight: number;
|
||||
/**
|
||||
* Credit for exceeding the target, as a fraction of this stat's own weight.
|
||||
* 0 disables it; the default is small on purpose, so overshoot breaks ties
|
||||
* rather than competing with reaching a target at all.
|
||||
*/
|
||||
overshoot?: number;
|
||||
}
|
||||
|
||||
export interface ScoringConfig {
|
||||
targets: StatTarget[];
|
||||
/**
|
||||
* How far past a target overshoot keeps paying, as a multiple of the target.
|
||||
* 1 means credit runs out at double the target.
|
||||
*/
|
||||
overshootCap?: number;
|
||||
/**
|
||||
* The cartridge main-stat ranking is only a tiebreak - substats dominate - so
|
||||
* it enters as an explicit epsilon rather than as a weight. Without a term
|
||||
* like this the rule is not implementable at all.
|
||||
*/
|
||||
mainStatEpsilon?: number;
|
||||
/** Published main-stat preference, 1 best down to 0. */
|
||||
mainStatRank?: Partial<Record<StatId, number>>;
|
||||
}
|
||||
|
||||
export interface CompiledScoring {
|
||||
targets: Float32Array;
|
||||
weights: Float32Array;
|
||||
overshoot: Float32Array;
|
||||
overshootCap: number;
|
||||
mainStatEpsilon: number;
|
||||
mainStatRank: Float32Array;
|
||||
/** Sum of weights, so the score lands in 0..1 before tie-breakers. */
|
||||
weightSum: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_OVERSHOOT = 0.1;
|
||||
export const DEFAULT_OVERSHOOT_CAP = 1;
|
||||
export const DEFAULT_MAIN_STAT_EPSILON = 0.001;
|
||||
|
||||
export function compile(config: ScoringConfig): CompiledScoring {
|
||||
const targets = new Float32Array(SLOT_COUNT);
|
||||
const weights = new Float32Array(SLOT_COUNT);
|
||||
const overshoot = new Float32Array(SLOT_COUNT);
|
||||
const mainStatRank = new Float32Array(SLOT_COUNT);
|
||||
let weightSum = 0;
|
||||
|
||||
for (const entry of config.targets) {
|
||||
const slot = slotOf(entry.stat);
|
||||
if (slot < 0) throw new Error(`no stat slot for ${entry.stat}`);
|
||||
if (entry.target <= 0 || entry.weight <= 0) continue;
|
||||
targets[slot] = entry.target;
|
||||
weights[slot] = entry.weight;
|
||||
overshoot[slot] = entry.overshoot ?? DEFAULT_OVERSHOOT;
|
||||
weightSum += entry.weight;
|
||||
}
|
||||
for (const [stat, rank] of Object.entries(config.mainStatRank ?? {})) {
|
||||
const slot = slotOf(stat);
|
||||
if (slot >= 0 && rank !== undefined) mainStatRank[slot] = rank;
|
||||
}
|
||||
|
||||
return {
|
||||
targets,
|
||||
weights,
|
||||
overshoot,
|
||||
overshootCap: config.overshootCap ?? DEFAULT_OVERSHOOT_CAP,
|
||||
mainStatEpsilon: config.mainStatEpsilon ?? DEFAULT_MAIN_STAT_EPSILON,
|
||||
mainStatRank,
|
||||
weightSum,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The hot path: one pass over the vector, no allocation.
|
||||
*
|
||||
* `mainStat` is the cartridge's main stat slot, or -1. It contributes only
|
||||
* epsilon - substats decide the build, the main-stat ranking breaks the tie.
|
||||
*/
|
||||
export function score(
|
||||
vector: Float32Array,
|
||||
compiled: CompiledScoring,
|
||||
mainStat = -1,
|
||||
): number {
|
||||
if (compiled.weightSum === 0) return 0;
|
||||
let total = 0;
|
||||
for (let i = 0; i < SLOT_COUNT; i += 1) {
|
||||
const weight = compiled.weights[i]!;
|
||||
if (weight === 0) continue;
|
||||
const ratio = vector[i]! / compiled.targets[i]!;
|
||||
if (ratio >= 1) {
|
||||
const excess = Math.min(ratio - 1, compiled.overshootCap);
|
||||
total += weight * (1 + compiled.overshoot[i]! * excess);
|
||||
} else {
|
||||
total += weight * ratio;
|
||||
}
|
||||
}
|
||||
let value = total / compiled.weightSum;
|
||||
if (mainStat >= 0) value += compiled.mainStatEpsilon * compiled.mainStatRank[mainStat]!;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* An admissible upper bound for branch and bound.
|
||||
*
|
||||
* Every stat's attainment is monotone nondecreasing and concave in its value, so
|
||||
* assuming each remaining slot takes the largest amount still available per stat
|
||||
* can only overestimate. If the search closes against this bound the answer is a
|
||||
* proof of optimality - and when it does not, the UI has to say so.
|
||||
*/
|
||||
export function upperBound(
|
||||
partial: Float32Array,
|
||||
bestRemaining: Float32Array,
|
||||
compiled: CompiledScoring,
|
||||
): number {
|
||||
if (compiled.weightSum === 0) return 0;
|
||||
let total = 0;
|
||||
for (let i = 0; i < SLOT_COUNT; i += 1) {
|
||||
const weight = compiled.weights[i]!;
|
||||
if (weight === 0) continue;
|
||||
const ratio = (partial[i]! + bestRemaining[i]!) / compiled.targets[i]!;
|
||||
if (ratio >= 1) {
|
||||
const excess = Math.min(ratio - 1, compiled.overshootCap);
|
||||
total += weight * (1 + compiled.overshoot[i]! * excess);
|
||||
} else {
|
||||
total += weight * ratio;
|
||||
}
|
||||
}
|
||||
return total / compiled.weightSum + compiled.mainStatEpsilon;
|
||||
}
|
||||
|
||||
export interface StatBreakdown {
|
||||
stat: StatId;
|
||||
value: number;
|
||||
target: number;
|
||||
weight: number;
|
||||
/** min(value / target, 1) - what the bars render. */
|
||||
attainment: number;
|
||||
/** How far past the target, as a fraction of it. 0 when short. */
|
||||
overshoot: number;
|
||||
/** This stat's share of the total, tie-breakers excluded. */
|
||||
contribution: number;
|
||||
}
|
||||
|
||||
export interface ScoreReport {
|
||||
total: number;
|
||||
/** The score with every overshoot and epsilon term removed. */
|
||||
base: number;
|
||||
/** True when every weighted target is met. */
|
||||
complete: boolean;
|
||||
stats: StatBreakdown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The same arithmetic, itemised. The result view renders this rather than a bare
|
||||
* number, because a saturating objective is only trustworthy if the player can
|
||||
* see which stats are carrying it.
|
||||
*/
|
||||
export function explain(
|
||||
vector: Float32Array,
|
||||
compiled: CompiledScoring,
|
||||
mainStat = -1,
|
||||
): ScoreReport {
|
||||
const stats: StatBreakdown[] = [];
|
||||
let base = 0;
|
||||
let complete = true;
|
||||
|
||||
for (let i = 0; i < SLOT_COUNT; i += 1) {
|
||||
const weight = compiled.weights[i]!;
|
||||
if (weight === 0) continue;
|
||||
const target = compiled.targets[i]!;
|
||||
const value = vector[i]!;
|
||||
const ratio = value / target;
|
||||
const attainment = Math.min(ratio, 1);
|
||||
const overshoot = ratio > 1 ? Math.min(ratio - 1, compiled.overshootCap) : 0;
|
||||
if (attainment < 1) complete = false;
|
||||
base += weight * attainment;
|
||||
stats.push({
|
||||
stat: STAT_SLOTS[i]!,
|
||||
value,
|
||||
target,
|
||||
weight,
|
||||
attainment,
|
||||
overshoot,
|
||||
contribution: (weight * attainment) / compiled.weightSum,
|
||||
});
|
||||
}
|
||||
|
||||
stats.sort((a, b) => b.weight - a.weight || a.stat.localeCompare(b.stat));
|
||||
return {
|
||||
total: score(vector, compiled, mainStat),
|
||||
base: compiled.weightSum === 0 ? 0 : base / compiled.weightSum,
|
||||
complete,
|
||||
stats,
|
||||
};
|
||||
}
|
||||
66
src/domain/setbonus.ts
Normal file
66
src/domain/setbonus.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* Cartridge set bonuses.
|
||||
*
|
||||
* The bonus *values* are the one piece of the model no available source
|
||||
* carries: the capture never sends them, and everness's `items.json` holds only
|
||||
* the cartridge boxes, not their effects. They have to be read in game or
|
||||
* scraped from a guide.
|
||||
*
|
||||
* Until then every tier is explicitly unknown and contributes **nothing**. The
|
||||
* requirements are firm that the app never invents numbers, so a missing bonus
|
||||
* shows as a gap in the UI rather than as a plausible-looking zero the player
|
||||
* cannot tell apart from a real one.
|
||||
*/
|
||||
import { activeTiers, type SetName, type Tier } from "./cartridges.ts";
|
||||
import type { ShapeId } from "./shapes.ts";
|
||||
import { vectorFrom, type StatPair } from "./statvec.ts";
|
||||
|
||||
export interface TierBonus {
|
||||
unknown: boolean;
|
||||
stats: StatPair[];
|
||||
}
|
||||
|
||||
export interface SetBonusTable {
|
||||
format: string;
|
||||
format_version: number;
|
||||
sets: Record<string, Record<"2" | "4", TierBonus>>;
|
||||
}
|
||||
|
||||
export interface SetBonusResult {
|
||||
tiers: Tier[];
|
||||
vector: Float32Array;
|
||||
/** Tiers that are active but whose values nobody has measured yet. */
|
||||
unknownTiers: Tier[];
|
||||
}
|
||||
|
||||
/** What a board's set bonuses contribute, and what is missing from that answer. */
|
||||
export function setBonus(
|
||||
table: SetBonusTable,
|
||||
set: SetName,
|
||||
shapesOnBoard: readonly ShapeId[],
|
||||
): SetBonusResult {
|
||||
const tiers = activeTiers(set, shapesOnBoard);
|
||||
const entry = table.sets[set];
|
||||
const pairs: StatPair[] = [];
|
||||
const unknownTiers: Tier[] = [];
|
||||
|
||||
for (const tier of tiers) {
|
||||
const bonus = entry?.[String(tier) as "2" | "4"];
|
||||
if (!bonus || bonus.unknown) {
|
||||
unknownTiers.push(tier);
|
||||
continue;
|
||||
}
|
||||
pairs.push(...bonus.stats);
|
||||
}
|
||||
|
||||
return { tiers, vector: vectorFrom(pairs), unknownTiers };
|
||||
}
|
||||
|
||||
/** Sets with at least one unmeasured tier, for the UI to flag up front. */
|
||||
export function incompleteSets(table: SetBonusTable): SetName[] {
|
||||
const out: SetName[] = [];
|
||||
for (const [set, tiers] of Object.entries(table.sets)) {
|
||||
if (tiers["2"].unknown || tiers["4"].unknown) out.push(set as SetName);
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
96
src/domain/shapes.ts
Normal file
96
src/domain/shapes.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
/**
|
||||
* Module polyomino geometry, keyed by the id the packets carry.
|
||||
*
|
||||
* This is the complete shape set: 12 shapes, every one the game currently makes
|
||||
* obtainable. The gap at cell4_style3/style4 is dead id space, not missing
|
||||
* inventory - Type IV has four shapes and no more.
|
||||
*
|
||||
* Orientation is part of the item, not a placement choice: the horizontal and
|
||||
* vertical dominoes are separate ids, as are the horizontal and vertical
|
||||
* I-pieces. Modules therefore cannot be rotated when placed, and packing is
|
||||
* translation-only.
|
||||
*
|
||||
* Cells are [row, col] with the origin at the shape's top-left corner.
|
||||
*/
|
||||
|
||||
export type Cell = readonly [row: number, col: number];
|
||||
export type ShapeId = keyof typeof SHAPES;
|
||||
|
||||
export const SHAPES = {
|
||||
cell2_style1: [[0, 0], [0, 1]],
|
||||
cell2_style2: [[0, 0], [1, 0]],
|
||||
|
||||
cell3_style1: [[0, 0], [0, 1], [0, 2]],
|
||||
cell3_style2: [[0, 0], [1, 0], [2, 0]],
|
||||
// The four L-trominoes are the four rotations of the same piece.
|
||||
cell3_style3: [[0, 0], [1, 0], [1, 1]],
|
||||
cell3_style4: [[0, 0], [0, 1], [1, 0]],
|
||||
cell3_style5: [[0, 0], [0, 1], [1, 1]],
|
||||
cell3_style6: [[0, 1], [1, 0], [1, 1]],
|
||||
|
||||
cell4_style1: [[0, 0], [0, 1], [0, 2], [0, 3]],
|
||||
cell4_style2: [[0, 0], [1, 0], [2, 0], [3, 0]],
|
||||
// These two are mirror images - an S and a Z - not one piece in two
|
||||
// orientations. Rotating style5 gives 10/11/01, which is not style6, so the
|
||||
// available orientations are an arbitrary subset per piece and cannot be
|
||||
// generated.
|
||||
cell4_style5: [[0, 1], [0, 2], [1, 0], [1, 1]],
|
||||
cell4_style6: [[0, 1], [1, 0], [1, 1], [2, 0]],
|
||||
} as const satisfies Record<string, readonly Cell[]>;
|
||||
|
||||
/** The order the in-game icons and Prydwen's `module_N.webp` use. */
|
||||
export const ICON_ORDER = [
|
||||
"cell2_style1",
|
||||
"cell2_style2",
|
||||
"cell3_style1",
|
||||
"cell3_style2",
|
||||
"cell3_style3",
|
||||
"cell3_style4",
|
||||
"cell3_style5",
|
||||
"cell3_style6",
|
||||
"cell4_style1",
|
||||
"cell4_style2",
|
||||
"cell4_style5",
|
||||
"cell4_style6",
|
||||
] as const satisfies readonly ShapeId[];
|
||||
|
||||
export const SHAPE_IDS = ICON_ORDER;
|
||||
|
||||
export function cellsOf(shape: ShapeId): readonly Cell[] {
|
||||
return SHAPES[shape];
|
||||
}
|
||||
|
||||
export function sizeOf(shape: ShapeId): number {
|
||||
return SHAPES[shape].length;
|
||||
}
|
||||
|
||||
/** 2, 3 or 4 - the module type, straight off the id. */
|
||||
export function moduleCells(shape: ShapeId): number {
|
||||
return Number(shape.slice(4, 5));
|
||||
}
|
||||
|
||||
export function extentOf(shape: ShapeId): { rows: number; cols: number } {
|
||||
const cells = SHAPES[shape];
|
||||
let rows = 0;
|
||||
let cols = 0;
|
||||
for (const [row, col] of cells) {
|
||||
if (row + 1 > rows) rows = row + 1;
|
||||
if (col + 1 > cols) cols = col + 1;
|
||||
}
|
||||
return { rows, cols };
|
||||
}
|
||||
|
||||
/** Rows of '1' and '0', for eyeballing and for test failure messages. */
|
||||
export function renderShape(shape: ShapeId): string[] {
|
||||
const { rows, cols } = extentOf(shape);
|
||||
const filled = new Set(SHAPES[shape].map(([r, c]) => r * cols + c));
|
||||
const out: string[] = [];
|
||||
for (let row = 0; row < rows; row += 1) {
|
||||
let line = "";
|
||||
for (let col = 0; col < cols; col += 1) {
|
||||
line += filled.has(row * cols + col) ? "1" : "0";
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
162
src/domain/sheet.ts
Normal file
162
src/domain/sheet.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
/**
|
||||
* The predicted character sheet.
|
||||
*
|
||||
* This exists to be **checked against the game**, not trusted. The stat model
|
||||
* has measured gaps - the base-stat multiplier has never been read at level 80,
|
||||
* which is the level builds actually use - so every value here is either derived
|
||||
* from something measured or reported as unavailable. Nothing is estimated to
|
||||
* fill a hole.
|
||||
*
|
||||
* Read sheets with the character OFF the active team. On field an Arc's
|
||||
* conditional effects are folded into the displayed numbers with no way to
|
||||
* separate them from gear.
|
||||
*/
|
||||
import {
|
||||
BASE_CHARGE_EFFICIENCY,
|
||||
BASE_CRIT_DAMAGE,
|
||||
BASE_CRIT_RATE,
|
||||
BASE_CYCLE_INTENSITY,
|
||||
baseStat,
|
||||
hasMultiplier,
|
||||
} from "./stats.ts";
|
||||
import { slotOf } from "./statvec.ts";
|
||||
|
||||
export interface SheetLine {
|
||||
key: string;
|
||||
label: string;
|
||||
/** Null when the model cannot produce a number honestly. */
|
||||
predicted: number | null;
|
||||
/** Why it is null, when it is. */
|
||||
unavailable?: string;
|
||||
percent: boolean;
|
||||
}
|
||||
|
||||
export interface EsperCurves {
|
||||
stats: Array<{ id_stats: string; values: number[] }>;
|
||||
}
|
||||
|
||||
const curve = (esper: EsperCurves, id: string): number[] | null =>
|
||||
esper.stats.find((entry) => entry.id_stats === id)?.values ?? null;
|
||||
|
||||
/**
|
||||
* `total` is the character's whole stat vector: base contributions (Arc
|
||||
* included) plus every equipped piece.
|
||||
*/
|
||||
export function predictSheet(
|
||||
esper: EsperCurves,
|
||||
level: number | null,
|
||||
total: Float32Array,
|
||||
): SheetLine[] {
|
||||
const get = (stat: string): number => {
|
||||
const slot = slotOf(stat);
|
||||
return slot < 0 ? 0 : (total[slot] ?? 0);
|
||||
};
|
||||
|
||||
const scaled = (
|
||||
key: string,
|
||||
label: string,
|
||||
curveId: string,
|
||||
which: "HP" | "ATK" | "DEF",
|
||||
percentStat: string,
|
||||
flatStat: string,
|
||||
extraBase = 0,
|
||||
): SheetLine => {
|
||||
const values = curve(esper, curveId);
|
||||
if (level === null) {
|
||||
return { key, label, predicted: null, unavailable: "character level unknown", percent: false };
|
||||
}
|
||||
if (!hasMultiplier(level)) {
|
||||
// Level 80 is the level builds actually use and has never been measured.
|
||||
// One gearless, off-team reading of any character at 80 fills it in for
|
||||
// everyone - so this says what is missing rather than guessing.
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
predicted: null,
|
||||
unavailable: `base multiplier not measured at level ${level}`,
|
||||
percent: false,
|
||||
};
|
||||
}
|
||||
const base = values ? baseStat(values, level, which) : null;
|
||||
if (base === null) {
|
||||
return { key, label, predicted: null, unavailable: "no stat curve", percent: false };
|
||||
}
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
predicted: (base + extraBase) * (1 + get(percentStat)) + get(flatStat),
|
||||
percent: false,
|
||||
};
|
||||
};
|
||||
|
||||
return [
|
||||
scaled("hp", "HP", "HPMaxBase", "HP", "HPMaxUp", "HPMaxAdd"),
|
||||
// The Arc's flat ATK scales with ATK%; gear's flat ATK does not, which is
|
||||
// why they sit in different slots and enter here differently.
|
||||
scaled("atk", "ATK", "AtkBase", "ATK", "AtkUp", "AtkAdd", get("AtkBase")),
|
||||
scaled("def", "DEF", "DefBase", "DEF", "DefUp", "DefAdd"),
|
||||
{
|
||||
key: "crit",
|
||||
label: "CRIT Rate",
|
||||
predicted: BASE_CRIT_RATE + get("CritBase"),
|
||||
percent: true,
|
||||
},
|
||||
{
|
||||
key: "critDamage",
|
||||
label: "CRIT DMG",
|
||||
predicted: BASE_CRIT_DAMAGE + get("CritDamageBase"),
|
||||
percent: true,
|
||||
},
|
||||
{
|
||||
key: "cycle",
|
||||
label: "Cycle Intensity",
|
||||
predicted: BASE_CYCLE_INTENSITY + get("UnbalIntensityBase") + get("UnbalIntensity"),
|
||||
percent: false,
|
||||
},
|
||||
{
|
||||
key: "charge",
|
||||
label: "Charge Efficiency",
|
||||
predicted: BASE_CHARGE_EFFICIENCY + get("ChargeGetEfficiencyBase"),
|
||||
percent: true,
|
||||
},
|
||||
{
|
||||
key: "break",
|
||||
label: "Break",
|
||||
// `MagBase` is believed to be the Break stat, but that mapping has never
|
||||
// been confirmed against a sheet, so it is shown for comparison rather
|
||||
// than presented as settled.
|
||||
predicted: get("MagBase") + get("Mag"),
|
||||
percent: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export interface SheetComparison extends SheetLine {
|
||||
actual: number | null;
|
||||
/** predicted - actual, when both exist. */
|
||||
delta: number | null;
|
||||
/** True when the gap is larger than rounding can explain. */
|
||||
drifted: boolean;
|
||||
}
|
||||
|
||||
/** Anything past this is a model error, not display rounding. */
|
||||
export const DRIFT_TOLERANCE = 1;
|
||||
|
||||
export function compareSheet(
|
||||
lines: readonly SheetLine[],
|
||||
measured: Record<string, number> | null | undefined,
|
||||
): SheetComparison[] {
|
||||
return lines.map((line) => {
|
||||
const actual = measured?.[line.key] ?? null;
|
||||
const delta =
|
||||
line.predicted !== null && actual !== null ? line.predicted - actual : null;
|
||||
return {
|
||||
...line,
|
||||
actual,
|
||||
delta,
|
||||
// Percentages are compared on their own scale, not against a flat 1.
|
||||
drifted:
|
||||
delta !== null && Math.abs(delta) > (line.percent ? 0.005 : DRIFT_TOLERANCE),
|
||||
};
|
||||
});
|
||||
}
|
||||
153
src/domain/stats.ts
Normal file
153
src/domain/stats.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/**
|
||||
* The stat model, ported from `stats.py`.
|
||||
*
|
||||
* Two rules govern everything here, and both were expensive to learn:
|
||||
*
|
||||
* 1. **Sheets must be read with the character OFF the active team.** On field an
|
||||
* Arc's conditional effects are folded into the displayed numbers with no way
|
||||
* to separate them from gear. Zankou reads 75% / 203.20% on field and
|
||||
* 59% / 200.00% off it.
|
||||
* 2. **One character cannot separate a flat bonus from a multiplicative one.**
|
||||
* An earlier model fitted a flat base-stat bonus to Zankou alone and was
|
||||
* simply wrong; a second character falsified it outright. Never confirm a
|
||||
* model shape against a single reading.
|
||||
*/
|
||||
import type { ShapeId } from "./shapes.ts";
|
||||
|
||||
export const BASE_CRIT_RATE = 0.05;
|
||||
export const BASE_CRIT_DAMAGE = 0.5;
|
||||
export const BASE_CYCLE_INTENSITY = 100;
|
||||
export const BASE_CHARGE_EFFICIENCY = 1;
|
||||
|
||||
/** A measured value, or the band a rounded curve leaves it in. */
|
||||
export type Measured = number | { readonly min: number; readonly max: number };
|
||||
|
||||
export const midpoint = (value: Measured): number =>
|
||||
typeof value === "number" ? value : (value.min + value.max) / 2;
|
||||
|
||||
/**
|
||||
* base(stat) = floor(curve[level - 1] * M(level, stat))
|
||||
*
|
||||
* M does not depend on the character: Haniel and Adler both give 1.48777 at
|
||||
* level 50 to five decimals while their flat deltas differ, so one table indexed
|
||||
* by level covers all 23 espers.
|
||||
*
|
||||
* HP and DEF track together; ATK runs lower, and at level 50 the bands do not
|
||||
* overlap (~1.4878 vs ~1.4380), so the split is real rather than rounding.
|
||||
*/
|
||||
export const MULTIPLIER: Record<number, Record<"HP" | "ATK" | "DEF", Measured>> = {
|
||||
// The level-50 HP figure is a band, not the five-decimal 1.48777 the Python
|
||||
// records: that value floors Adler to 8637 against a measured 8638. Two
|
||||
// readings pin it to [8638/5806, 8273/5560), and the midpoint reproduces both.
|
||||
50: {
|
||||
HP: { min: 1.4877713, max: 1.4879435 },
|
||||
ATK: { min: 1.43684, max: 1.43925 },
|
||||
DEF: { min: 1.48883, max: 1.49128 },
|
||||
},
|
||||
70: { HP: 1.61347, ATK: 1.56369, DEF: 1.6135 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Level 80 is the level builds actually use and it has never been measured. One
|
||||
* gearless, off-team reading of any character at 80 fills it in for everyone.
|
||||
*/
|
||||
export const UNMEASURED_LEVELS = [80];
|
||||
|
||||
export function hasMultiplier(level: number): boolean {
|
||||
return level in MULTIPLIER;
|
||||
}
|
||||
|
||||
export function baseStat(
|
||||
curve: readonly number[],
|
||||
level: number,
|
||||
stat: "HP" | "ATK" | "DEF",
|
||||
): number | null {
|
||||
const row = MULTIPLIER[level];
|
||||
const value = curve[level - 1];
|
||||
if (!row || value === undefined) return null;
|
||||
return Math.floor(value * midpoint(row[stat]));
|
||||
}
|
||||
|
||||
/**
|
||||
* total ATK = (base + arc) * (1 + atkPercent) + flat
|
||||
*
|
||||
* The Arc's ATK scales with ATK%; flat gear ATK does not. A rival fit of
|
||||
* `base * (1 + pct) + arc + flat` also lands on the sheet exactly - the sheet
|
||||
* truncates, so an exact hit is no stronger evidence than a truncated one, and
|
||||
* only a fully unequipped reading can settle it.
|
||||
*/
|
||||
export function totalAtk(base: number, arc: number, flat: number, atkPercent: number): number {
|
||||
return (base + arc) * (1 + atkPercent) + flat;
|
||||
}
|
||||
|
||||
export interface TraitStat {
|
||||
id_stats: string;
|
||||
name: string;
|
||||
bShowPercent: boolean;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface ConsoleTrait {
|
||||
/** Raw stat id, e.g. `CritDamageBase`. */
|
||||
stat: string;
|
||||
name: string;
|
||||
/** Per qualifying module: already divided by 100 when the stat is a percent. */
|
||||
per: number;
|
||||
/**
|
||||
* The module *cell count* the trait counts - 2 or 3. This is everness's
|
||||
* `OwnerGridCount`, which is NOT the grid type despite the name; Prydwen's
|
||||
* "Console Grid Type" means this same number.
|
||||
*/
|
||||
moduleCells: number;
|
||||
}
|
||||
|
||||
export interface EsperTraitSource {
|
||||
trait: readonly TraitStat[];
|
||||
ownerGridCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the trait from the data, never from a table.
|
||||
*
|
||||
* It is not always Type III and not always CRIT DMG: three characters key on
|
||||
* Type II, and the values run 6 to 16 across eight different stats.
|
||||
*/
|
||||
export function consoleTrait(esper: EsperTraitSource): ConsoleTrait | null {
|
||||
const stat = esper.trait[0];
|
||||
if (!stat) return null;
|
||||
return {
|
||||
stat: stat.id_stats,
|
||||
name: stat.name,
|
||||
per: stat.bShowPercent ? stat.value / 100 : stat.value,
|
||||
moduleCells: esper.ownerGridCount,
|
||||
};
|
||||
}
|
||||
|
||||
/** What the console trait contributes for a given set of modules on the board. */
|
||||
export function traitContribution(
|
||||
trait: ConsoleTrait | null,
|
||||
shapes: readonly ShapeId[],
|
||||
): { stat: string; value: number } | null {
|
||||
if (!trait) return null;
|
||||
let qualifying = 0;
|
||||
for (const shape of shapes) {
|
||||
if (Number(shape.slice(4, 5)) === trait.moduleCells) qualifying += 1;
|
||||
}
|
||||
return { stat: trait.stat, value: trait.per * qualifying };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ravenous Blade's +16% CRIT Rate needs the Arc equipped AND the character on
|
||||
* field, isolated by three readings: arc off / on field 35%, arc on / off field
|
||||
* 59%, arc on / on field 75%.
|
||||
*/
|
||||
export const ARC_ON_FIELD_EFFECTS: Record<string, Record<string, number>> = {
|
||||
"Ravenous Blade": { CritBase: 0.16 },
|
||||
};
|
||||
|
||||
/**
|
||||
* +3.2% CRIT DMG appears in both on-field readings, with and without the Arc, so
|
||||
* it is neither gear nor the Arc. Source unidentified; harmless as long as
|
||||
* sheets are read off-team, which is why the app never models on-field numbers.
|
||||
*/
|
||||
export const ON_FIELD_UNKNOWN: Record<string, number> = { CritDamageBase: 0.032 };
|
||||
146
src/domain/statvec.ts
Normal file
146
src/domain/statvec.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
/**
|
||||
* The fixed stat vector.
|
||||
*
|
||||
* Every contribution - module main, module substat, cartridge main, set bonus,
|
||||
* console trait, Arc - is normalised into one `Float32Array` with a fixed slot
|
||||
* per stat, and the whole owned pool lives in a single flat array. Scoring a
|
||||
* partial build then costs one vector add. Nearly all of the solver's speed is
|
||||
* this decision, which is why it is made before any solver code exists.
|
||||
*
|
||||
* Slots are raw stat ids, never display names: naming is presentation and
|
||||
* belongs in the UI, where it can change without invalidating stored data.
|
||||
*/
|
||||
|
||||
/** Elements the roster actually has. */
|
||||
export const ELEMENTS = [
|
||||
"Chaos",
|
||||
"Cosmos",
|
||||
"Incantation",
|
||||
"Lakshana",
|
||||
"Nature",
|
||||
"Psyche",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Slot order is part of the on-disk contract for anything that caches vectors,
|
||||
* so append only - never reorder.
|
||||
*
|
||||
* The first eleven are the stats that appear as substats; the rest appear only
|
||||
* as mains. `DamageUpPsychicallyBase` is a seventh damage-bonus id with no
|
||||
* matching element - it shows up on 19 real items, so it gets a slot whatever
|
||||
* it turns out to mean.
|
||||
*/
|
||||
export const STAT_SLOTS = [
|
||||
"HPMaxAdd",
|
||||
"HPMaxUp",
|
||||
"AtkAdd",
|
||||
"AtkUp",
|
||||
"DefAdd",
|
||||
"DefUp",
|
||||
"CritBase",
|
||||
"CritDamageBase",
|
||||
"MagBase",
|
||||
"UnbalIntensityBase",
|
||||
"DamageUpGeneralBase",
|
||||
"HealUp",
|
||||
"DamageUpChaosBase",
|
||||
"DamageUpCosmosBase",
|
||||
"DamageUpIncantationBase",
|
||||
"DamageUpLakshanaBase",
|
||||
"DamageUpNatureBase",
|
||||
"DamageUpPsycheBase",
|
||||
"DamageUpPsychicallyBase",
|
||||
|
||||
// --- appended for Arcs. Slot order is a contract: append only. ---
|
||||
//
|
||||
// `AtkBase` is the Arc's flat ATK and is deliberately NOT folded into
|
||||
// `AtkAdd`: the Arc's ATK scales with ATK% and gear's flat ATK does not, so
|
||||
// they are different quantities that happen to share a unit.
|
||||
"AtkBase",
|
||||
"ChargeGetEfficiencyBase",
|
||||
"DefIgnore",
|
||||
// `UnbalIntensity` and `Mag` appear only in Arc effect tables, while gear uses
|
||||
// `UnbalIntensityBase` and `MagBase`. They are probably the same stats spelled
|
||||
// differently, but nothing confirms it - and merging two stats wrongly is a
|
||||
// worse error than carrying two slots, so they stay separate until measured.
|
||||
"UnbalIntensity",
|
||||
"Mag",
|
||||
] as const;
|
||||
|
||||
export type StatId = (typeof STAT_SLOTS)[number];
|
||||
|
||||
export const SLOT_COUNT = STAT_SLOTS.length;
|
||||
|
||||
const SLOT_INDEX = new Map<string, number>(STAT_SLOTS.map((stat, index) => [stat, index]));
|
||||
|
||||
/** The slot for a stat id, or -1 for one the model has no place for. */
|
||||
export function slotOf(stat: string): number {
|
||||
return SLOT_INDEX.get(stat) ?? -1;
|
||||
}
|
||||
|
||||
export function isKnownStat(stat: string): stat is StatId {
|
||||
return SLOT_INDEX.has(stat);
|
||||
}
|
||||
|
||||
/** The damage-bonus slot a character's own element reads. */
|
||||
export function elementSlot(element: string | null): number {
|
||||
return element === null ? -1 : slotOf(`DamageUp${element}Base`);
|
||||
}
|
||||
|
||||
export function emptyVector(): Float32Array {
|
||||
return new Float32Array(SLOT_COUNT);
|
||||
}
|
||||
|
||||
/**
|
||||
* A flat pool: `count` vectors laid end to end, so an item's contribution is a
|
||||
* subarray rather than an object.
|
||||
*/
|
||||
export function emptyPool(count: number): Float32Array {
|
||||
return new Float32Array(count * SLOT_COUNT);
|
||||
}
|
||||
|
||||
export function poolSlice(pool: Float32Array, index: number): Float32Array {
|
||||
return pool.subarray(index * SLOT_COUNT, (index + 1) * SLOT_COUNT);
|
||||
}
|
||||
|
||||
/** `into += from`. Both must be full-length vectors. */
|
||||
export function addInto(into: Float32Array, from: Float32Array): void {
|
||||
for (let i = 0; i < SLOT_COUNT; i += 1) into[i] = into[i]! + from[i]!;
|
||||
}
|
||||
|
||||
/** `into += pool[index]`, without materialising a subarray. */
|
||||
export function addPoolInto(into: Float32Array, pool: Float32Array, index: number): void {
|
||||
const base = index * SLOT_COUNT;
|
||||
for (let i = 0; i < SLOT_COUNT; i += 1) into[i] = into[i]! + pool[base + i]!;
|
||||
}
|
||||
|
||||
export function subtractInto(into: Float32Array, from: Float32Array): void {
|
||||
for (let i = 0; i < SLOT_COUNT; i += 1) into[i] = into[i]! - from[i]!;
|
||||
}
|
||||
|
||||
export type StatPair = { stat: string; value: number | null };
|
||||
|
||||
/**
|
||||
* Fold a list of `{stat, value}` into a vector.
|
||||
*
|
||||
* Unknown stats and null values are skipped: a main stat whose displayed value
|
||||
* the game never transmits is a real, expected null, not a parse failure.
|
||||
*/
|
||||
export function vectorFrom(pairs: Iterable<StatPair>, into?: Float32Array): Float32Array {
|
||||
const vector = into ?? emptyVector();
|
||||
for (const { stat, value } of pairs) {
|
||||
if (value === null) continue;
|
||||
const slot = slotOf(stat);
|
||||
if (slot >= 0) vector[slot] = vector[slot]! + value;
|
||||
}
|
||||
return vector;
|
||||
}
|
||||
|
||||
/** Non-zero slots, for display and for test failure messages. */
|
||||
export function describe(vector: Float32Array): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (let i = 0; i < SLOT_COUNT; i += 1) {
|
||||
if (vector[i] !== 0) out[STAT_SLOTS[i]!] = vector[i]!;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
82
src/domain/tilings.ts
Normal file
82
src/domain/tilings.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
/**
|
||||
* Reads the precomputed packing table.
|
||||
*
|
||||
* Exact cover never runs in the app: `tools/precompute-tilings.ts` solved every
|
||||
* (board, set, filler) combination offline. What survives is which shape
|
||||
* multisets tile a board with a given set, plus one canonical placement each
|
||||
* for drawing.
|
||||
*/
|
||||
import type { SetName } from "./cartridges.ts";
|
||||
import type { ShapeId } from "./shapes.ts";
|
||||
|
||||
type RawEntry = [set: number, pieces: string, placement: string];
|
||||
|
||||
interface RawBoard {
|
||||
cells: number[];
|
||||
espers: string[];
|
||||
entries: RawEntry[];
|
||||
}
|
||||
|
||||
export interface RawTilings {
|
||||
format: string;
|
||||
format_version: number;
|
||||
gridSize: number;
|
||||
cellCount: number;
|
||||
shapes: ShapeId[];
|
||||
sets: SetName[];
|
||||
boards: RawBoard[];
|
||||
}
|
||||
|
||||
export interface Tiling {
|
||||
set: SetName;
|
||||
/** In packing order, so `placement` indexes straight into it. */
|
||||
pieces: ShapeId[];
|
||||
/** Board cells, ascending. */
|
||||
cells: number[];
|
||||
/** `placement[i]` is the piece covering `cells[i]`. */
|
||||
placement: number[];
|
||||
}
|
||||
|
||||
export interface BoardTilings {
|
||||
cells: number[];
|
||||
espers: string[];
|
||||
/** Every packing, grouped by cartridge set. */
|
||||
bySet: Map<SetName, Tiling[]>;
|
||||
}
|
||||
|
||||
const digit = (char: string): number => parseInt(char, 36);
|
||||
|
||||
export function loadTilings(raw: RawTilings): BoardTilings[] {
|
||||
return raw.boards.map((board) => {
|
||||
const bySet = new Map<SetName, Tiling[]>();
|
||||
for (const [setIndex, pieces, placement] of board.entries) {
|
||||
const set = raw.sets[setIndex]!;
|
||||
const tiling: Tiling = {
|
||||
set,
|
||||
pieces: [...pieces].map((char) => raw.shapes[digit(char)]!),
|
||||
cells: board.cells,
|
||||
placement: [...placement].map(digit),
|
||||
};
|
||||
const list = bySet.get(set);
|
||||
if (list) list.push(tiling);
|
||||
else bySet.set(set, [tiling]);
|
||||
}
|
||||
return { cells: board.cells, espers: board.espers, bySet };
|
||||
});
|
||||
}
|
||||
|
||||
/** The shape multiset of a packing, as counts keyed by shape. */
|
||||
export function multisetOf(tiling: Tiling): Map<ShapeId, number> {
|
||||
const counts = new Map<ShapeId, number>();
|
||||
for (const shape of tiling.pieces) counts.set(shape, (counts.get(shape) ?? 0) + 1);
|
||||
return counts;
|
||||
}
|
||||
|
||||
/** Cells covered by piece `index`, as board cell ids. */
|
||||
export function cellsOfPiece(tiling: Tiling, index: number): number[] {
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < tiling.placement.length; i += 1) {
|
||||
if (tiling.placement[i] === index) out.push(tiling.cells[i]!);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
1
src/generated/gamedata.json
Normal file
1
src/generated/gamedata.json
Normal file
File diff suppressed because one or more lines are too long
6
src/generated/icons.empty.json
Normal file
6
src/generated/icons.empty.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"format": "nte-icons",
|
||||
"format_version": 1,
|
||||
"_note": "The single-file build's stand-in manifest. Empty on purpose: with no entries, Icon renders the fallback and the page never requests an image it does not carry.",
|
||||
"files": {}
|
||||
}
|
||||
78
src/generated/icons.json
Normal file
78
src/generated/icons.json
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
{
|
||||
"format": "nte-icons",
|
||||
"format_version": 1,
|
||||
"files": {
|
||||
"esper:Sagiri": "player_003-ace97f94.webp",
|
||||
"esper:Lacrimosa": "player_004-874ed495.webp",
|
||||
"esper:Skia": "player_008-4976544f.webp",
|
||||
"esper:Nanally": "player_010-924dc73d.webp",
|
||||
"esper:Mint019": "player_019-a75c3fbd.webp",
|
||||
"esper:Haniel": "player_020-3e088a55.webp",
|
||||
"esper:Edgar": "player_021-6cfb115e.webp",
|
||||
"esper:Cang": "player_028-79597e4e.webp",
|
||||
"esper:Hathor": "player_012-36c8b09f.webp",
|
||||
"esper:Adler": "player_015-ddebe8cc.webp",
|
||||
"esper:Zankou": "player_canhong-8fd9dc5e.webp",
|
||||
"esper:Fadia": "player_027-b3e5e8dc.webp",
|
||||
"esper:Female046": "player_046-b5837c27.webp",
|
||||
"esper:Female051": "player_009-b9f7ef78.webp",
|
||||
"esper:Jin": "player_013-bf2054be.webp",
|
||||
"esper:Daffodill": "player_005-712a486b.webp",
|
||||
"esper:Kuhara": "player_104-e857d709.webp",
|
||||
"esper:Mitsuki": "player_haiyue-28720124.webp",
|
||||
"esper:Radio072": "player_lingke-a6dfa678.webp",
|
||||
"esper:Chiichan073": "player_073-6a824f1f.webp",
|
||||
"esper:Oneiroi": "player_yiluoyi-2eeff6ec.webp",
|
||||
"esper:Chaos071": "player_kaesi1-043cca1a.webp",
|
||||
"esper:Shinku": "player_zhenhong-1c5f3031.webp",
|
||||
"arc:fork_yuren": "fork_yuren_256-727dde45.webp",
|
||||
"arc:fork_bopu": "fork_bopu_256-e045cc0d.webp",
|
||||
"arc:fork_mofeikesi": "fork_mofeikesi_256-db984ecf.webp",
|
||||
"arc:fork_jiaojuan": "fork_jiaojuan_256-769fdf83.webp",
|
||||
"arc:fork_wuhuakuang": "fork_wuhuakuang_256-ea10c6c4.webp",
|
||||
"arc:fork_jingmotingyuan": "fork_jingmotingyuan_256-eccbc72b.webp",
|
||||
"arc:fork_oulaquantao": "fork_oulaquantao_256-bcc71d33.webp",
|
||||
"arc:fork_lingganzhongjiezhe": "fork_lingganzhongjiezhe_256-dd4950fd.webp",
|
||||
"arc:fork_rishi": "fork_rishi_256-e4137b85.webp",
|
||||
"arc:fork_dustbin": "fork_dustbin_256-6595915b.webp",
|
||||
"arc:fork_nonos": "fork_nonos_256-4cb5e0fa.webp",
|
||||
"arc:fork_vine": "fork_HugVine_256-aaa93ce6.webp",
|
||||
"arc:fork_Prokaryon": "fork_yuanheti_256-087d4df9.webp",
|
||||
"arc:fork_BitterCake": "fork_BitterCake_256-2498fb35.webp",
|
||||
"arc:fork_appliance": "fork_fudianling_256-fe144d13.webp",
|
||||
"arc:fork_TigerTally": "fork_TigerTally_256-47cfb268.webp",
|
||||
"arc:fork_BitGame": "fork_BitGame_256-0cb5217a.webp",
|
||||
"arc:fork_BlackBook": "fork_BlackBook_256-56849e90.webp",
|
||||
"arc:fork_mamen": "fork_mamen_256-2d0a8b9a.webp",
|
||||
"arc:fork_Nakupeda": "fork_Nakupeda_256-96338388.webp",
|
||||
"arc:fork_wushoutieyu": "fork_wushoutieyu_256-d83ef63e.webp",
|
||||
"arc:fork_Arachne": "fork_Arachne_256-159158f2.webp",
|
||||
"arc:fork_PoliceRat": "fork_PoliceRat_256-d05f72e2.webp",
|
||||
"arc:fork_tuansanlang": "fork_tuansanlang_256-937209b3.webp",
|
||||
"arc:fork_Kite": "fork_Kite_256-a8b52a91.webp",
|
||||
"arc:fork_PaperPlane": "fork_PaperPlane_256-2d4e5717.webp",
|
||||
"arc:fork_spider": "fork_spider_256-656150ea.webp",
|
||||
"arc:fork_yaodao": "fork_yaodao_256-e8f57727.webp",
|
||||
"arc:fork_koinobori": "fork_koinobori_256-c8624200.webp",
|
||||
"arc:fork_snowman": "fork_snowman_256-013ccbd0.webp",
|
||||
"arc:fork_BoxingCandy": "fork_BoxingCandy_256-2d9a5897.webp",
|
||||
"arc:fork_KnightCandy": "fork_KnightCandy_256-fea87c21.webp",
|
||||
"arc:fork_ThiefCandy": "fork_ThiefCandy_256-9b9444e3.webp",
|
||||
"arc:fork_MotorCandy": "fork_MotorCandy_256-fd81bc62.webp",
|
||||
"arc:fork_BlastCandy": "fork_BlastCandy_256-4bd61b87.webp",
|
||||
"arc:fork_Crowbar": "fork_Qiaoqiao_256-5fad717f.webp",
|
||||
"arc:fork_Castle": "fork_Castle_256-3ce261aa.webp",
|
||||
"arc:fork_Butterfly": "fork_Butterfly_256-1395ba48.webp",
|
||||
"arc:fork_NestBird": "fork_NestBird_256-f6e28be2.webp",
|
||||
"arc:fork_Whale": "fork_Whale_256-76862d07.webp",
|
||||
"arc:fork_Time": "fork_Time_256-9cb02755.webp",
|
||||
"arc:fork_Rose": "fork_Rose_256-d33fcc29.webp",
|
||||
"arc:fork_worldrain": "fork_worldrain_256-cf0b4b4a.webp",
|
||||
"arc:fork_moon": "fork_moon_256-19817a0c.webp",
|
||||
"arc:fork_GoldWool": "fork_GoldWool_256-929d1333.webp",
|
||||
"arc:fork_LunarPhase": "fork_LunarPhase_256-58ab7825.webp",
|
||||
"arc:fork_Door": "fork_Door_256-7a125283.webp",
|
||||
"arc:fork_DemonBlade": "fork_DemonBlade_256-89200ec7.webp",
|
||||
"arc:fork_GoldRecord": "fork_GoldRecord_256-9e37a9a5.webp"
|
||||
}
|
||||
}
|
||||
1
src/generated/tilings.json
Normal file
1
src/generated/tilings.json
Normal file
File diff suppressed because one or more lines are too long
13
src/main.tsx
Normal file
13
src/main.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { App } from "./ui/App.tsx";
|
||||
import "./ui/styles.css";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (!root) throw new Error("no #root");
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
67
src/solver/bitset.ts
Normal file
67
src/solver/bitset.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* Fixed-width bitsets over the item pool.
|
||||
*
|
||||
* A build's item set is the thing the team phase intersects, over and over, so
|
||||
* it is a `Uint32Array` of about 26 words at 817 items rather than a `Set`.
|
||||
* Disjointness of two builds is then a handful of ANDs.
|
||||
*/
|
||||
export type Bitset = Uint32Array;
|
||||
|
||||
export function bitsetFor(size: number): Bitset {
|
||||
return new Uint32Array((size + 31) >>> 5);
|
||||
}
|
||||
|
||||
export function bitsetOf(size: number, members: Iterable<number>): Bitset {
|
||||
const set = bitsetFor(size);
|
||||
for (const member of members) add(set, member);
|
||||
return set;
|
||||
}
|
||||
|
||||
export function add(set: Bitset, member: number): void {
|
||||
set[member >>> 5] = set[member >>> 5]! | (1 << (member & 31));
|
||||
}
|
||||
|
||||
export function has(set: Bitset, member: number): boolean {
|
||||
return (set[member >>> 5]! & (1 << (member & 31))) !== 0;
|
||||
}
|
||||
|
||||
/** True when the two share no member - the hot check in the team search. */
|
||||
export function disjoint(a: Bitset, b: Bitset): boolean {
|
||||
for (let i = 0; i < a.length; i += 1) {
|
||||
if ((a[i]! & b[i]!) !== 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function overlapCount(a: Bitset, b: Bitset): number {
|
||||
let count = 0;
|
||||
for (let i = 0; i < a.length; i += 1) {
|
||||
let word = a[i]! & b[i]!;
|
||||
while (word !== 0) {
|
||||
word &= word - 1;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function unionInto(into: Bitset, from: Bitset): void {
|
||||
for (let i = 0; i < into.length; i += 1) into[i] = into[i]! | from[i]!;
|
||||
}
|
||||
|
||||
export function clone(set: Bitset): Bitset {
|
||||
return new Uint32Array(set);
|
||||
}
|
||||
|
||||
export function members(set: Bitset): number[] {
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < set.length; i += 1) {
|
||||
let word = set[i]!;
|
||||
while (word !== 0) {
|
||||
const bit = 31 - Math.clz32(word & -word);
|
||||
out.push(i * 32 + bit);
|
||||
word &= word - 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
140
src/solver/host.ts
Normal file
140
src/solver/host.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
/**
|
||||
* Where the search runs.
|
||||
*
|
||||
* `Worker` when the page has one, the same generator on the main thread when it
|
||||
* does not - which is always the case in the single-file `file://` build, where
|
||||
* Chrome hands the document an opaque origin and workers are simply
|
||||
* unavailable.
|
||||
*
|
||||
* The fallback is nearly free precisely because the solver was written as a
|
||||
* chunked generator from the start rather than retrofitted: the inline host
|
||||
* pumps it in slices and returns to the event loop between them, so the UI keeps
|
||||
* painting and Stop keeps working.
|
||||
*/
|
||||
import { runJob, type JobProgress, type JobResult, type SolveJob } from "./job.ts";
|
||||
import type { WorkerIn, WorkerOut } from "./worker.ts";
|
||||
import { makeWorker } from "./worker-host.ts";
|
||||
|
||||
export interface SolveHandle {
|
||||
promise: Promise<JobResult>;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
export interface SolverHost {
|
||||
readonly kind: "worker" | "inline";
|
||||
solve: (job: SolveJob, onProgress?: (progress: JobProgress) => void) => SolveHandle;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
/** Milliseconds of work between returns to the event loop. */
|
||||
export const SLICE_MS = 30;
|
||||
|
||||
export class CancelledError extends Error {
|
||||
constructor() {
|
||||
super("solve cancelled");
|
||||
this.name = "CancelledError";
|
||||
}
|
||||
}
|
||||
|
||||
export function createInlineHost(): SolverHost {
|
||||
return {
|
||||
kind: "inline",
|
||||
solve(job, onProgress) {
|
||||
let cancelled = false;
|
||||
const promise = new Promise<JobResult>((resolve, reject) => {
|
||||
const run = runJob(job);
|
||||
const pump = () => {
|
||||
if (cancelled) {
|
||||
reject(new CancelledError());
|
||||
return;
|
||||
}
|
||||
const deadline = Date.now() + SLICE_MS;
|
||||
try {
|
||||
for (;;) {
|
||||
const step = run.next();
|
||||
if (step.done) {
|
||||
resolve(step.value);
|
||||
return;
|
||||
}
|
||||
onProgress?.(step.value);
|
||||
if (Date.now() >= deadline) break;
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
// Yield to the event loop so the page can paint and Stop can land.
|
||||
setTimeout(pump, 0);
|
||||
};
|
||||
setTimeout(pump, 0);
|
||||
});
|
||||
return {
|
||||
promise,
|
||||
cancel: () => {
|
||||
cancelled = true;
|
||||
},
|
||||
};
|
||||
},
|
||||
dispose() {},
|
||||
};
|
||||
}
|
||||
|
||||
export function createWorkerHost(worker: Worker): SolverHost {
|
||||
let nextId = 1;
|
||||
return {
|
||||
kind: "worker",
|
||||
solve(job, onProgress) {
|
||||
const id = nextId;
|
||||
nextId += 1;
|
||||
let settled = false;
|
||||
|
||||
const promise = new Promise<JobResult>((resolve, reject) => {
|
||||
const onMessage = (event: MessageEvent<WorkerOut>) => {
|
||||
const message = event.data;
|
||||
if (message.id !== id) return;
|
||||
if (message.type === "progress") {
|
||||
onProgress?.(message.progress);
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
worker.removeEventListener("message", onMessage);
|
||||
if (message.type === "result") resolve(message.result);
|
||||
else reject(new Error(message.message));
|
||||
};
|
||||
worker.addEventListener("message", onMessage);
|
||||
const request: WorkerIn = { type: "solve", id, job };
|
||||
worker.postMessage(request);
|
||||
});
|
||||
|
||||
return {
|
||||
promise,
|
||||
cancel: () => {
|
||||
if (settled) return;
|
||||
const request: WorkerIn = { type: "cancel" };
|
||||
worker.postMessage(request);
|
||||
},
|
||||
};
|
||||
},
|
||||
dispose() {
|
||||
worker.terminate();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A worker host when one can be constructed, an inline host otherwise.
|
||||
*
|
||||
* Construction is the test, not feature detection: `typeof Worker` is defined
|
||||
* on a `file://` page even though building a module worker there throws.
|
||||
*/
|
||||
export function createSolverHost(): SolverHost {
|
||||
if (typeof Worker !== "undefined") {
|
||||
try {
|
||||
return createWorkerHost(makeWorker());
|
||||
} catch {
|
||||
// Fall through to inline. In the single-file build `makeWorker` is aliased
|
||||
// to a stub that always throws, so this is the only path there.
|
||||
}
|
||||
}
|
||||
return createInlineHost();
|
||||
}
|
||||
203
src/solver/job.ts
Normal file
203
src/solver/job.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
/**
|
||||
* A whole team solve as one serialisable value.
|
||||
*
|
||||
* The worker boundary only carries structured-cloneable data, so the job holds
|
||||
* plain arrays and configuration - never a callback, a `Map`, or a prebuilt
|
||||
* pool. Everything derived is rebuilt inside `runJob`, which means the inline
|
||||
* host and the worker host run byte-identical code and cannot drift.
|
||||
*/
|
||||
import { boardFromSlots, boardKey } from "../domain/board.ts";
|
||||
import type { SetName } from "../domain/cartridges.ts";
|
||||
import { buildPool, type ExportedItem } from "../domain/items.ts";
|
||||
import { compile, type ScoringConfig } from "../domain/scoring.ts";
|
||||
import type { SetBonusTable } from "../domain/setbonus.ts";
|
||||
import type { ConsoleTrait } from "../domain/stats.ts";
|
||||
import { SLOT_COUNT } from "../domain/statvec.ts";
|
||||
import { loadTilings, type RawTilings, type Tiling } from "../domain/tilings.ts";
|
||||
import { diversify, type Portfolio } from "./portfolio.ts";
|
||||
import type { Build } from "./protocol.ts";
|
||||
import { solveSingle } from "./single.ts";
|
||||
import { solveTeam, type TeamAssignment } from "./team.ts";
|
||||
|
||||
export interface CharacterJob {
|
||||
key: string;
|
||||
/** The character's console grid, 7x7 with -1 blocked. */
|
||||
slots: number[][];
|
||||
trait: ConsoleTrait | null;
|
||||
/** Contributions before gear, as a plain array of `SLOT_COUNT` numbers. */
|
||||
base: number[];
|
||||
scoring: ScoringConfig;
|
||||
/** Restrict to these sets. Omit for every set the account owns. */
|
||||
sets?: SetName[];
|
||||
/**
|
||||
* Instances this character may not use - R2's "use equipped items" toggle
|
||||
* turned off, meaning items another character is wearing are off limits.
|
||||
*
|
||||
* Carried as instance ids rather than pool indices because the job crosses a
|
||||
* worker boundary and the pool is rebuilt on the other side.
|
||||
*/
|
||||
excludedInstances?: string[];
|
||||
}
|
||||
|
||||
export interface SolveJob {
|
||||
items: ExportedItem[];
|
||||
characters: CharacterJob[];
|
||||
setBonuses: SetBonusTable;
|
||||
tilings: RawTilings;
|
||||
dragOrder?: string[];
|
||||
options?: {
|
||||
beamWidth?: number;
|
||||
candidateWidth?: number;
|
||||
perTilingKeep?: number;
|
||||
portfolioSize?: number;
|
||||
rounds?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type JobProgress =
|
||||
| { phase: "portfolio"; character: string; done: number; total: number }
|
||||
| { phase: "team"; round: number; rounds: number; sorted: number[] };
|
||||
|
||||
export interface JobResult {
|
||||
assignment: TeamAssignment[];
|
||||
sorted: number[];
|
||||
unbuildable: string[];
|
||||
infeasible: boolean;
|
||||
rounds: number;
|
||||
/** Item ids the export carried that this build has no shape for. */
|
||||
unknownShapes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Defaults measured on the real 817-item pool: beam 60 with 12 candidates finds
|
||||
* the same best builds as beam 200 with 20 while running about seven times
|
||||
* faster, and four characters then solve in a few seconds rather than half a
|
||||
* minute.
|
||||
*/
|
||||
const DEFAULTS = {
|
||||
beamWidth: 60,
|
||||
candidateWidth: 12,
|
||||
perTilingKeep: 6,
|
||||
portfolioSize: 300,
|
||||
rounds: 6,
|
||||
};
|
||||
|
||||
export function* runJob(job: SolveJob): Generator<JobProgress, JobResult, void> {
|
||||
const options = { ...DEFAULTS, ...job.options };
|
||||
const pool = buildPool(job.items);
|
||||
const tables = loadTilings(job.tilings);
|
||||
|
||||
const tilingsFor = (character: CharacterJob): Tiling[] => {
|
||||
const key = boardKey(boardFromSlots(character.slots));
|
||||
const table = tables.find((entry) => entry.cells.join(",") === key);
|
||||
if (!table) return [];
|
||||
const wanted = character.sets;
|
||||
const lists = wanted
|
||||
? wanted.map((set) => table.bySet.get(set) ?? [])
|
||||
: [...table.bySet.values()];
|
||||
return lists.flat();
|
||||
};
|
||||
|
||||
const baseVector = (character: CharacterJob): Float32Array => {
|
||||
const vector = new Float32Array(SLOT_COUNT);
|
||||
vector.set(character.base.slice(0, SLOT_COUNT));
|
||||
return vector;
|
||||
};
|
||||
|
||||
const indexOfInstance = new Map(pool.items.map((item, index) => [item.instance, index]));
|
||||
const excludedFor = (character: CharacterJob): Set<number> => {
|
||||
const out = new Set<number>();
|
||||
for (const instance of character.excludedInstances ?? []) {
|
||||
const index = indexOfInstance.get(instance);
|
||||
if (index !== undefined) out.add(index);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const solveOne = function* (
|
||||
character: CharacterJob,
|
||||
extraExcluded: ReadonlySet<number>,
|
||||
): Generator<JobProgress, Build[], void> {
|
||||
// The character's own restriction always applies; column generation adds
|
||||
// whatever the rest of the team is currently holding on top of it.
|
||||
const excluded = excludedFor(character);
|
||||
for (const index of extraExcluded) excluded.add(index);
|
||||
|
||||
const run = solveSingle({
|
||||
pool,
|
||||
tilings: tilingsFor(character),
|
||||
base: baseVector(character),
|
||||
trait: character.trait,
|
||||
setBonuses: job.setBonuses,
|
||||
scoring: compile(character.scoring),
|
||||
excluded,
|
||||
beamWidth: options.beamWidth,
|
||||
candidateWidth: options.candidateWidth,
|
||||
perTilingKeep: options.perTilingKeep,
|
||||
});
|
||||
let step = run.next();
|
||||
while (!step.done) {
|
||||
yield {
|
||||
phase: "portfolio",
|
||||
character: character.key,
|
||||
done: step.value.done,
|
||||
total: step.value.total,
|
||||
};
|
||||
step = run.next();
|
||||
}
|
||||
return step.value.portfolio;
|
||||
};
|
||||
|
||||
// --- phase 0 ----------------------------------------------------------
|
||||
const portfolios: Portfolio[] = [];
|
||||
for (const character of job.characters) {
|
||||
const builds = yield* solveOne(character, new Set());
|
||||
const entries = diversify(builds, pool.items.length, options.portfolioSize);
|
||||
portfolios.push({ key: character.key, entries, unbuildable: entries.length === 0 });
|
||||
}
|
||||
|
||||
// --- phases 1 and 2 ---------------------------------------------------
|
||||
//
|
||||
// Column generation re-solves synchronously: `solveTeam` takes a plain
|
||||
// function, so the re-solve cannot yield progress of its own. That is a
|
||||
// deliberate trade - the alternative is threading a generator through the
|
||||
// leximin search for a step that already takes well under a second.
|
||||
const byKey = new Map(job.characters.map((character) => [character.key, character]));
|
||||
const resolve = (key: string, excluded: ReadonlySet<number>): Build[] => {
|
||||
const character = byKey.get(key);
|
||||
if (!character) return [];
|
||||
const run = solveOne(character, excluded);
|
||||
let step = run.next();
|
||||
while (!step.done) step = run.next();
|
||||
return step.value.slice(0, 40);
|
||||
};
|
||||
|
||||
const team = solveTeam({
|
||||
portfolios,
|
||||
poolSize: pool.items.length,
|
||||
dragOrder: job.dragOrder ?? job.characters.map((character) => character.key),
|
||||
resolve,
|
||||
rounds: options.rounds,
|
||||
});
|
||||
|
||||
let step = team.next();
|
||||
while (!step.done) {
|
||||
yield {
|
||||
phase: "team",
|
||||
round: step.value.round,
|
||||
rounds: step.value.rounds,
|
||||
sorted: step.value.sorted,
|
||||
};
|
||||
step = team.next();
|
||||
}
|
||||
|
||||
const result = step.value;
|
||||
return {
|
||||
assignment: result.assignment,
|
||||
sorted: result.sorted,
|
||||
unbuildable: result.unbuildable,
|
||||
infeasible: result.infeasible,
|
||||
rounds: result.rounds,
|
||||
unknownShapes: pool.unknownShapes,
|
||||
};
|
||||
}
|
||||
88
src/solver/portfolio.ts
Normal file
88
src/solver/portfolio.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/**
|
||||
* Phase 0 of the team solve: a diverse portfolio per character.
|
||||
*
|
||||
* Every portfolio is generated against the **entire pool, conflicts ignored**.
|
||||
* That is what makes the team phase anti-greedy structurally rather than
|
||||
* heuristically: no character is solved after another has already taken items,
|
||||
* so generation order confers no advantage at all.
|
||||
*
|
||||
* What is kept is not the best build repeated with cosmetic variation but a
|
||||
* spread: builds that reuse items already covered are penalised, so the leximin
|
||||
* phase has genuinely different options to trade between.
|
||||
*/
|
||||
import type { Build } from "./protocol.ts";
|
||||
import { bitsetOf, type Bitset } from "./bitset.ts";
|
||||
|
||||
export interface PortfolioEntry {
|
||||
build: Build;
|
||||
/** The build's items - cartridge and modules - as a bitset over the pool. */
|
||||
items: Bitset;
|
||||
}
|
||||
|
||||
export interface Portfolio {
|
||||
/** Whatever identifies the character to the caller. */
|
||||
key: string;
|
||||
entries: PortfolioEntry[];
|
||||
/** True when the character has no valid full-set build at all. */
|
||||
unbuildable: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_DIVERSITY = 0.15;
|
||||
|
||||
export function itemsOf(build: Build, poolSize: number): Bitset {
|
||||
return bitsetOf(poolSize, [build.cartridge, ...build.modules]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim a solve's builds to a spread of `size`.
|
||||
*
|
||||
* Greedy by `score - diversity * (mean usage of this build's items so far)`.
|
||||
*
|
||||
* The penalty counts **how many times** each item has already been kept, not
|
||||
* merely whether it has been. An earlier version measured overlap against the
|
||||
* union of everything kept, and that saturates: once the union is large every
|
||||
* remaining build overlaps it completely, the penalty stops discriminating, and
|
||||
* the portfolio collapses onto a few dozen items. Four characters then have no
|
||||
* conflict-free selection between them at all - the team phase returns nothing.
|
||||
*/
|
||||
export function diversify(
|
||||
builds: readonly Build[],
|
||||
poolSize: number,
|
||||
size: number,
|
||||
diversity = DEFAULT_DIVERSITY,
|
||||
): PortfolioEntry[] {
|
||||
const remaining = builds
|
||||
.map((build) => ({
|
||||
entry: { build, items: itemsOf(build, poolSize) } as PortfolioEntry,
|
||||
pieces: [build.cartridge, ...build.modules],
|
||||
}))
|
||||
.sort((a, b) => b.entry.build.score - a.entry.build.score);
|
||||
|
||||
const usage = new Uint16Array(poolSize);
|
||||
const kept: PortfolioEntry[] = [];
|
||||
const taken = new Uint8Array(remaining.length);
|
||||
|
||||
while (kept.length < size) {
|
||||
let bestIndex = -1;
|
||||
let bestValue = -Infinity;
|
||||
for (let i = 0; i < remaining.length; i += 1) {
|
||||
if (taken[i]) continue;
|
||||
const candidate = remaining[i]!;
|
||||
let used = 0;
|
||||
for (const item of candidate.pieces) used += usage[item]!;
|
||||
const value =
|
||||
candidate.entry.build.score - (diversity * used) / candidate.pieces.length;
|
||||
if (value > bestValue) {
|
||||
bestValue = value;
|
||||
bestIndex = i;
|
||||
}
|
||||
}
|
||||
if (bestIndex < 0) break;
|
||||
const chosen = remaining[bestIndex]!;
|
||||
taken[bestIndex] = 1;
|
||||
kept.push(chosen.entry);
|
||||
for (const item of chosen.pieces) usage[item] = usage[item]! + 1;
|
||||
}
|
||||
|
||||
return kept;
|
||||
}
|
||||
48
src/solver/protocol.ts
Normal file
48
src/solver/protocol.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* The wire between the app and whatever is running the search.
|
||||
*
|
||||
* The same types serve the Worker host and the inline host, so nothing above
|
||||
* the solver knows which one it got.
|
||||
*/
|
||||
import type { Tier } from "../domain/cartridges.ts";
|
||||
import type { Tiling } from "../domain/tilings.ts";
|
||||
|
||||
export interface SolveProgress {
|
||||
/** Combinations finished. Every phase has a real denominator. */
|
||||
done: number;
|
||||
total: number;
|
||||
/** The best complete build so far. Stop always yields something usable. */
|
||||
best: Build | null;
|
||||
}
|
||||
|
||||
export interface Build {
|
||||
/** Pool index of the cartridge. */
|
||||
cartridge: number;
|
||||
/** Pool indices of the modules, aligned with `tiling.pieces`. */
|
||||
modules: number[];
|
||||
tiling: Tiling;
|
||||
score: number;
|
||||
vector: Float32Array;
|
||||
/**
|
||||
* True when branch and bound closed, so **this packing with this cartridge**
|
||||
* has no better module assignment. It is deliberately not a claim of global
|
||||
* optimality: packings and cartridges are enumerated exhaustively, but the
|
||||
* module beam inside each one is not proved except for the winner. False
|
||||
* means even that narrower claim did not close within budget.
|
||||
*
|
||||
* The UI must render the distinction rather than implying certainty.
|
||||
*/
|
||||
proven: boolean;
|
||||
/** Active set tiers whose values nobody has measured yet. */
|
||||
unknownTiers: Tier[];
|
||||
}
|
||||
|
||||
export interface SolveResult {
|
||||
best: Build | null;
|
||||
/** Every build considered good enough to keep, best first. */
|
||||
portfolio: Build[];
|
||||
/** Combinations examined. */
|
||||
examined: number;
|
||||
/** True when no cartridge and tiling combination existed at all. */
|
||||
unbuildable: boolean;
|
||||
}
|
||||
398
src/solver/single.ts
Normal file
398
src/solver/single.ts
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
/**
|
||||
* One character, one pool.
|
||||
*
|
||||
* Shape and structure of the search:
|
||||
*
|
||||
* - **Tiling is a table lookup, never a search.** Positions do not affect score,
|
||||
* so `tools/precompute-tilings.ts` already solved the packing offline and each
|
||||
* entry is a distinct shape multiset.
|
||||
* - **Subsets are unordered.** Modules of the same shape are interchangeable, so
|
||||
* choosing 2 of 20 is 190 options and not 380; a beam that permutes identical
|
||||
* assignments spends its whole width on duplicates.
|
||||
* - **Modules are solved per packing, not per (packing, cartridge) pair.** The
|
||||
* first version beamed inside that product and needed hundreds of millions of
|
||||
* vector operations for one character. A cartridge shifts one main stat and
|
||||
* four substats, so the beam runs once per packing against the character's
|
||||
* fixed contribution, keeps its best `carry` module sets, and the cartridges
|
||||
* are then scored against those. That turns a product into a sum.
|
||||
* - **Beam first, then prove it - once.** Branch and bound runs on the single
|
||||
* winning (packing, cartridge) pair, seeded with the beam's answer. Packings
|
||||
* and cartridge buckets are both enumerated exhaustively, so the only
|
||||
* approximation left anywhere is the module beam inside a packing - and for
|
||||
* the winner even that is closed. `proven` therefore means "no better module
|
||||
* assignment exists for this packing and cartridge", which is a narrower claim
|
||||
* than global optimality and is worded that way on purpose.
|
||||
*
|
||||
* The whole thing is a generator that yields every few thousand nodes. That is
|
||||
* not a nicety: it is the only cancellation mechanism that also works in the
|
||||
* `file://` build, where there are no workers at all, and it gives real progress
|
||||
* for free.
|
||||
*/
|
||||
import type { ConsoleTrait } from "../domain/stats.ts";
|
||||
import type { ItemPool } from "../domain/items.ts";
|
||||
import type { SetBonusTable } from "../domain/setbonus.ts";
|
||||
import type { Tiling } from "../domain/tilings.ts";
|
||||
import { setBonus } from "../domain/setbonus.ts";
|
||||
import { traitContribution } from "../domain/stats.ts";
|
||||
import { score, upperBound, type CompiledScoring } from "../domain/scoring.ts";
|
||||
import { SLOT_COUNT, addPoolInto, emptyVector, slotOf, vectorFrom } from "../domain/statvec.ts";
|
||||
import type { Build, SolveProgress, SolveResult } from "./protocol.ts";
|
||||
|
||||
export interface SingleRequest {
|
||||
pool: ItemPool;
|
||||
/** Packings available to this character, already filtered to its board. */
|
||||
tilings: readonly Tiling[];
|
||||
/** Everything the character brings before gear: base stats, Arc, and so on. */
|
||||
base: Float32Array;
|
||||
trait: ConsoleTrait | null;
|
||||
setBonuses: SetBonusTable;
|
||||
scoring: CompiledScoring;
|
||||
/** Pool indices held by other characters and therefore off limits. */
|
||||
excluded?: ReadonlySet<number>;
|
||||
beamWidth?: number;
|
||||
/** Candidates kept per shape before subsets are formed. */
|
||||
candidateWidth?: number;
|
||||
/** Module sets carried out of each packing's beam into the cartridge pass. */
|
||||
carry?: number;
|
||||
/** Builds kept for the team phase's portfolio. */
|
||||
portfolioSize?: number;
|
||||
/**
|
||||
* Builds kept **per packing**.
|
||||
*
|
||||
* A global top-N cut is useless to the team phase: the top 300 builds of one
|
||||
* character are 300 minor variations on the same modules, so no disjoint team
|
||||
* selection exists at all. Keeping a few per packing spans the search instead.
|
||||
*/
|
||||
perTilingKeep?: number;
|
||||
/** Nodes before branch and bound gives up and the beam answer stands. */
|
||||
nodeBudget?: number;
|
||||
/** Nodes between yields. */
|
||||
chunkNodes?: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_BEAM_WIDTH = 200;
|
||||
export const DEFAULT_CANDIDATE_WIDTH = 20;
|
||||
export const DEFAULT_CARRY = 12;
|
||||
export const DEFAULT_PORTFOLIO_SIZE = 3000;
|
||||
export const DEFAULT_PER_TILING_KEEP = 6;
|
||||
export const DEFAULT_NODE_BUDGET = 300_000;
|
||||
export const DEFAULT_CHUNK_NODES = 20_000;
|
||||
|
||||
interface BeamState {
|
||||
vector: Float32Array;
|
||||
modules: number[];
|
||||
value: number;
|
||||
}
|
||||
|
||||
/** Rank a module by how much of the targets it covers, ignoring saturation. */
|
||||
function density(pool: ItemPool, index: number, scoring: CompiledScoring): number {
|
||||
const base = index * SLOT_COUNT;
|
||||
let total = 0;
|
||||
for (let i = 0; i < SLOT_COUNT; i += 1) {
|
||||
const weight = scoring.weights[i]!;
|
||||
if (weight === 0) continue;
|
||||
total += (weight * pool.vectors[base + i]!) / scoring.targets[i]!;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every unordered k-subset of `items`.
|
||||
*
|
||||
* The same array is reused between yields; callers must copy what they keep.
|
||||
*/
|
||||
function* subsets(items: readonly number[], k: number): Generator<number[]> {
|
||||
const chosen: number[] = [];
|
||||
const walk = function* (start: number): Generator<number[]> {
|
||||
if (chosen.length === k) {
|
||||
yield chosen;
|
||||
return;
|
||||
}
|
||||
// Stop once too few candidates remain to finish the subset.
|
||||
for (let i = start; i <= items.length - (k - chosen.length); i += 1) {
|
||||
chosen.push(items[i]!);
|
||||
yield* walk(i + 1);
|
||||
chosen.pop();
|
||||
}
|
||||
};
|
||||
yield* walk(0);
|
||||
}
|
||||
|
||||
/** Distinct shapes in a packing, with how many copies each needs. */
|
||||
function shapeGroups(tiling: Tiling): Array<{ shape: string; count: number }> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const shape of tiling.pieces) counts.set(shape, (counts.get(shape) ?? 0) + 1);
|
||||
return [...counts].map(([shape, count]) => ({ shape, count }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cartridges whose contribution is identical are interchangeable, so only one of
|
||||
* each needs solving. With 55 owned cartridges of a set this removes most of the
|
||||
* work before any search starts.
|
||||
*/
|
||||
function bucketCartridges(pool: ItemPool, indices: readonly number[]): number[] {
|
||||
const seen = new Map<string, number>();
|
||||
for (const index of indices) {
|
||||
const base = index * SLOT_COUNT;
|
||||
const key = `${pool.items[index]!.mainStat}|${Array.from(
|
||||
pool.vectors.subarray(base, base + SLOT_COUNT),
|
||||
).join(",")}`;
|
||||
if (!seen.has(key)) seen.set(key, index);
|
||||
}
|
||||
return [...seen.values()];
|
||||
}
|
||||
|
||||
export function* solveSingle(
|
||||
request: SingleRequest,
|
||||
): Generator<SolveProgress, SolveResult, void> {
|
||||
const {
|
||||
pool,
|
||||
tilings,
|
||||
base,
|
||||
trait,
|
||||
setBonuses,
|
||||
scoring,
|
||||
excluded = new Set<number>(),
|
||||
beamWidth = DEFAULT_BEAM_WIDTH,
|
||||
candidateWidth = DEFAULT_CANDIDATE_WIDTH,
|
||||
carry = DEFAULT_CARRY,
|
||||
portfolioSize = DEFAULT_PORTFOLIO_SIZE,
|
||||
perTilingKeep = DEFAULT_PER_TILING_KEEP,
|
||||
nodeBudget = DEFAULT_NODE_BUDGET,
|
||||
chunkNodes = DEFAULT_CHUNK_NODES,
|
||||
} = request;
|
||||
|
||||
// Candidates per shape, best first, truncated once.
|
||||
const candidates = new Map<string, number[]>();
|
||||
for (const [shape, indices] of pool.modulesByShape) {
|
||||
const usable = indices.filter((index) => !excluded.has(index));
|
||||
usable.sort((a, b) => density(pool, b, scoring) - density(pool, a, scoring));
|
||||
candidates.set(shape, usable.slice(0, candidateWidth));
|
||||
}
|
||||
|
||||
const cartridgesFor = new Map<string, number[]>();
|
||||
for (const tiling of tilings) {
|
||||
if (cartridgesFor.has(tiling.set)) continue;
|
||||
const owned = (pool.cartridgesBySet.get(tiling.set) ?? []).filter(
|
||||
(index) => !excluded.has(index),
|
||||
);
|
||||
cartridgesFor.set(tiling.set, bucketCartridges(pool, owned));
|
||||
}
|
||||
|
||||
const usable = tilings.filter((tiling) => (cartridgesFor.get(tiling.set) ?? []).length > 0);
|
||||
const portfolio: Build[] = [];
|
||||
let best: Build | null = null;
|
||||
let examined = 0;
|
||||
let nodes = 0;
|
||||
|
||||
for (const tiling of usable) {
|
||||
const groups = shapeGroups(tiling);
|
||||
const cartridges = cartridgesFor.get(tiling.set)!;
|
||||
|
||||
// Enough of every shape must exist, or this packing is simply unavailable.
|
||||
if (groups.some((group) => (candidates.get(group.shape) ?? []).length < group.count)) {
|
||||
examined += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fixed part: character base, console trait, set bonus. Not the cartridge -
|
||||
// that is folded in afterwards, which is what keeps this a sum.
|
||||
const fixed = emptyVector();
|
||||
fixed.set(base);
|
||||
const traitStat = traitContribution(trait, tiling.pieces);
|
||||
if (traitStat) vectorFrom([traitStat], fixed);
|
||||
const bonus = setBonus(setBonuses, tiling.set, tiling.pieces);
|
||||
for (let i = 0; i < SLOT_COUNT; i += 1) fixed[i] = fixed[i]! + bonus.vector[i]!;
|
||||
|
||||
// Hardest groups first: fewer choices near the root prunes the most.
|
||||
const ordered = [...groups].sort(
|
||||
(a, b) =>
|
||||
candidates.get(a.shape)!.length - a.count - (candidates.get(b.shape)!.length - b.count),
|
||||
);
|
||||
|
||||
// --- beam over modules, cartridge-free -------------------------------
|
||||
let beam: BeamState[] = [{ vector: fixed, modules: [], value: 0 }];
|
||||
for (const group of ordered) {
|
||||
const available = candidates.get(group.shape)!;
|
||||
const next: BeamState[] = [];
|
||||
for (const state of beam) {
|
||||
// An item belongs to exactly one shape, so groups are disjoint and no
|
||||
// overlap check is needed here.
|
||||
for (const pick of subsets(available, group.count)) {
|
||||
const vector = new Float32Array(state.vector);
|
||||
for (const index of pick) addPoolInto(vector, pool.vectors, index);
|
||||
next.push({
|
||||
vector,
|
||||
modules: [...state.modules, ...pick],
|
||||
value: score(vector, scoring),
|
||||
});
|
||||
nodes += 1;
|
||||
}
|
||||
}
|
||||
if (next.length === 0) {
|
||||
beam = [];
|
||||
break;
|
||||
}
|
||||
next.sort((a, b) => b.value - a.value);
|
||||
next.length = Math.min(next.length, beamWidth);
|
||||
beam = next;
|
||||
if (nodes >= chunkNodes) {
|
||||
nodes = 0;
|
||||
yield { done: examined, total: usable.length, best };
|
||||
}
|
||||
}
|
||||
|
||||
examined += 1;
|
||||
if (beam.length === 0) continue;
|
||||
const kept = beam.slice(0, carry);
|
||||
|
||||
// --- fold in each cartridge ------------------------------------------
|
||||
// One build per *distinct module set*, keeping that set's best cartridge.
|
||||
//
|
||||
// Taking the top-N builds instead would return N copies of the same modules
|
||||
// with different cartridges, which is no use to the team phase: it needs
|
||||
// builds that differ in the items they occupy, not in their trim.
|
||||
const forThisTiling: Build[] = [];
|
||||
for (const state of kept) {
|
||||
let bestForState: Build | null = null;
|
||||
for (const cartridge of cartridges) {
|
||||
const mainStat = pool.items[cartridge]!.mainStat;
|
||||
const mainSlot = mainStat === null ? -1 : slotOf(mainStat);
|
||||
const vector = new Float32Array(state.vector);
|
||||
addPoolInto(vector, pool.vectors, cartridge);
|
||||
const value = score(vector, scoring, mainSlot);
|
||||
if (bestForState !== null && value <= bestForState.score) continue;
|
||||
bestForState = {
|
||||
cartridge,
|
||||
modules: state.modules,
|
||||
tiling,
|
||||
score: value,
|
||||
vector,
|
||||
proven: false,
|
||||
unknownTiers: bonus.unknownTiers,
|
||||
};
|
||||
}
|
||||
if (bestForState === null) continue;
|
||||
if (best === null || bestForState.score > best.score) best = bestForState;
|
||||
forThisTiling.push(bestForState);
|
||||
}
|
||||
forThisTiling.sort((a, b) => b.score - a.score);
|
||||
portfolio.push(...forThisTiling.slice(0, perTilingKeep));
|
||||
|
||||
yield { done: examined, total: usable.length, best };
|
||||
}
|
||||
|
||||
// --- prove the winner, once -------------------------------------------
|
||||
if (best !== null) {
|
||||
const winner = best;
|
||||
const groups = shapeGroups(winner.tiling);
|
||||
const fixed = new Float32Array(winner.vector);
|
||||
for (const index of winner.modules) {
|
||||
const from = index * SLOT_COUNT;
|
||||
for (let i = 0; i < SLOT_COUNT; i += 1) fixed[i] = fixed[i]! - pool.vectors[from + i]!;
|
||||
}
|
||||
const mainStat = pool.items[winner.cartridge]!.mainStat;
|
||||
winner.proven = yield* prove(
|
||||
pool,
|
||||
groups,
|
||||
candidates,
|
||||
fixed,
|
||||
scoring,
|
||||
mainStat === null ? -1 : slotOf(mainStat),
|
||||
winner,
|
||||
nodeBudget,
|
||||
chunkNodes,
|
||||
() => ({ done: examined, total: usable.length, best }),
|
||||
);
|
||||
}
|
||||
|
||||
portfolio.sort((a, b) => b.score - a.score);
|
||||
portfolio.length = Math.min(portfolio.length, portfolioSize);
|
||||
return { best, portfolio, examined, unbuildable: usable.length === 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Branch and bound over one packing's groups, improving `incumbent` in place.
|
||||
*
|
||||
* Returns true only if the whole tree was closed within budget - that is the
|
||||
* difference between "this is the best build" and "this is the best we found".
|
||||
*/
|
||||
function* prove(
|
||||
pool: ItemPool,
|
||||
groups: ReadonlyArray<{ shape: string; count: number }>,
|
||||
candidates: ReadonlyMap<string, number[]>,
|
||||
fixed: Float32Array,
|
||||
scoring: CompiledScoring,
|
||||
mainSlot: number,
|
||||
incumbent: Build,
|
||||
nodeBudget: number,
|
||||
chunkNodes: number,
|
||||
progress: () => SolveProgress,
|
||||
): Generator<SolveProgress, boolean, void> {
|
||||
const lists = groups.map((group) => ({
|
||||
...group,
|
||||
available: candidates.get(group.shape) ?? [],
|
||||
}));
|
||||
if (lists.some((group) => group.available.length < group.count)) return false;
|
||||
|
||||
// Per-stat best still reachable from group `i` onward, for the bound.
|
||||
const suffixBest: Float32Array[] = new Array(lists.length + 1);
|
||||
suffixBest[lists.length] = new Float32Array(SLOT_COUNT);
|
||||
for (let g = lists.length - 1; g >= 0; g -= 1) {
|
||||
const vector = new Float32Array(suffixBest[g + 1]!);
|
||||
const group = lists[g]!;
|
||||
for (let slot = 0; slot < SLOT_COUNT; slot += 1) {
|
||||
if (scoring.weights[slot] === 0) continue;
|
||||
const values = group.available
|
||||
.map((index) => pool.vectors[index * SLOT_COUNT + slot]!)
|
||||
.sort((a, b) => b - a)
|
||||
.slice(0, group.count);
|
||||
let sum = 0;
|
||||
for (const value of values) sum += value;
|
||||
vector[slot] = vector[slot]! + sum;
|
||||
}
|
||||
suffixBest[g] = vector;
|
||||
}
|
||||
|
||||
let nodes = 0;
|
||||
let exhausted = false;
|
||||
|
||||
const walk = function* (
|
||||
depth: number,
|
||||
vector: Float32Array,
|
||||
used: number[],
|
||||
): Generator<SolveProgress, void, void> {
|
||||
if (exhausted) return;
|
||||
if (depth === lists.length) {
|
||||
const value = score(vector, scoring, mainSlot);
|
||||
if (value > incumbent.score) {
|
||||
incumbent.score = value;
|
||||
incumbent.modules = [...used];
|
||||
incumbent.vector = new Float32Array(vector);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (upperBound(vector, suffixBest[depth]!, scoring) <= incumbent.score) return;
|
||||
|
||||
const group = lists[depth]!;
|
||||
for (const pick of subsets(group.available, group.count)) {
|
||||
if (exhausted) return;
|
||||
nodes += 1;
|
||||
if (nodes >= nodeBudget) {
|
||||
exhausted = true;
|
||||
return;
|
||||
}
|
||||
if (nodes % chunkNodes === 0) yield progress();
|
||||
|
||||
const child = new Float32Array(vector);
|
||||
for (const index of pick) addPoolInto(child, pool.vectors, index);
|
||||
const depthUsed = pick.length;
|
||||
used.push(...pick);
|
||||
yield* walk(depth + 1, child, used);
|
||||
used.length -= depthUsed;
|
||||
}
|
||||
};
|
||||
|
||||
yield* walk(0, fixed, []);
|
||||
return !exhausted;
|
||||
}
|
||||
374
src/solver/team.ts
Normal file
374
src/solver/team.ts
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
/**
|
||||
* Phases 1 and 2 of the team solve: leximin over portfolios, then column
|
||||
* generation.
|
||||
*
|
||||
* **Leximin, not sum.** Maximising the total lets one character eat the pool and
|
||||
* leaves another with scraps. Leximin maximises the worst score, then the second
|
||||
* worst, and so on, so improving a strong character can never come at the cost
|
||||
* of a weak one.
|
||||
*
|
||||
* **Drag order is a tie-break and nothing more.** It is applied only once the
|
||||
* sorted score vector is already fixed, so the list has real meaning to the
|
||||
* player without ever driving the allocation.
|
||||
*
|
||||
* **The result is anytime.** After each round the current selection is a valid,
|
||||
* complete, conflict-free assignment, so stopping early always yields something
|
||||
* usable.
|
||||
*/
|
||||
import type { Build } from "./protocol.ts";
|
||||
import { clone, disjoint, unionInto, type Bitset } from "./bitset.ts";
|
||||
import { itemsOf, type Portfolio, type PortfolioEntry } from "./portfolio.ts";
|
||||
|
||||
export interface TeamRequest {
|
||||
portfolios: Portfolio[];
|
||||
poolSize: number;
|
||||
/**
|
||||
* Character keys in the order the player dragged them. Earlier wins ties.
|
||||
* Characters missing from the list sort last, in portfolio order.
|
||||
*/
|
||||
dragOrder?: readonly string[];
|
||||
/**
|
||||
* Re-solve one character against a restricted pool, for column generation.
|
||||
* Omit and phase 2 is skipped, which is a supported, cheaper mode.
|
||||
*/
|
||||
resolve?: (key: string, excluded: ReadonlySet<number>) => Build[];
|
||||
/** Column-generation rounds. */
|
||||
rounds?: number;
|
||||
/** Feasibility nodes before a threshold test gives up and reports infeasible. */
|
||||
nodeBudget?: number;
|
||||
}
|
||||
|
||||
export interface TeamAssignment {
|
||||
key: string;
|
||||
build: Build;
|
||||
}
|
||||
|
||||
export interface TeamProgress {
|
||||
round: number;
|
||||
rounds: number;
|
||||
/** Scores ascending - the vector the player watches climb. */
|
||||
sorted: number[];
|
||||
assignment: TeamAssignment[];
|
||||
}
|
||||
|
||||
export interface TeamResult {
|
||||
assignment: TeamAssignment[];
|
||||
sorted: number[];
|
||||
/** Characters with no valid build, so no assignment could include them. */
|
||||
unbuildable: string[];
|
||||
rounds: number;
|
||||
/**
|
||||
* True when every character had builds but no conflict-free selection existed.
|
||||
* Distinct from an empty team, and usually means the portfolios are variations
|
||||
* on the same items rather than a genuine spread.
|
||||
*/
|
||||
infeasible: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_ROUNDS = 20;
|
||||
export const DEFAULT_NODE_BUDGET = 200_000;
|
||||
|
||||
/** Lexicographic comparison of ascending-sorted score vectors. Positive: a wins. */
|
||||
export function compareLeximin(a: readonly number[], b: readonly number[]): number {
|
||||
const left = [...a].sort((x, y) => x - y);
|
||||
const right = [...b].sort((x, y) => x - y);
|
||||
for (let i = 0; i < Math.min(left.length, right.length); i += 1) {
|
||||
if (left[i]! !== right[i]!) return left[i]! - right[i]!;
|
||||
}
|
||||
return left.length - right.length;
|
||||
}
|
||||
|
||||
interface Candidate {
|
||||
entry: PortfolioEntry;
|
||||
score: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One build per character, all item-disjoint, every score at or above its
|
||||
* threshold. Null when no such selection exists.
|
||||
*
|
||||
* With four characters and bitset intersection this is microseconds, which is
|
||||
* what makes the binary search over thresholds affordable.
|
||||
*/
|
||||
function selectDisjoint(
|
||||
portfolios: readonly Portfolio[],
|
||||
thresholds: readonly number[],
|
||||
nodeBudget: number,
|
||||
): PortfolioEntry[] | null {
|
||||
const options: PortfolioEntry[][] = portfolios.map((portfolio, index) =>
|
||||
portfolio.entries.filter((entry) => entry.build.score >= thresholds[index]!),
|
||||
);
|
||||
if (options.some((list) => list.length === 0)) return null;
|
||||
|
||||
const words = portfolios[0]!.entries[0]!.items.length;
|
||||
const chosen = new Array<PortfolioEntry | null>(portfolios.length).fill(null);
|
||||
let nodes = 0;
|
||||
|
||||
/**
|
||||
* Forward checking with dynamic MRV.
|
||||
*
|
||||
* A static character order with no propagation thrashes: it commits to the
|
||||
* highest-scoring build for the first character, then grinds through hundreds
|
||||
* of options for each of the rest before backtracking. Filtering each
|
||||
* character's surviving options at every step, failing the moment any of them
|
||||
* empties, and always branching on the most constrained character turns a
|
||||
* search that exhausted five million nodes into one that finishes immediately.
|
||||
*/
|
||||
const walk = (remaining: number[], live: PortfolioEntry[][], used: Bitset): boolean => {
|
||||
if (remaining.length === 0) return true;
|
||||
|
||||
let pick = 0;
|
||||
for (let i = 1; i < remaining.length; i += 1) {
|
||||
if (live[remaining[i]!]!.length < live[remaining[pick]!]!.length) pick = i;
|
||||
}
|
||||
const index = remaining[pick]!;
|
||||
const rest = remaining.filter((_, i) => i !== pick);
|
||||
|
||||
for (const candidate of live[index]!) {
|
||||
nodes += 1;
|
||||
if (nodes > nodeBudget) return false;
|
||||
|
||||
const next = clone(used);
|
||||
unionInto(next, candidate.items);
|
||||
|
||||
const filtered = live.slice();
|
||||
let dead = false;
|
||||
for (const other of rest) {
|
||||
const survivors = live[other]!.filter((entry) => disjoint(entry.items, next));
|
||||
if (survivors.length === 0) {
|
||||
dead = true;
|
||||
break;
|
||||
}
|
||||
filtered[other] = survivors;
|
||||
}
|
||||
if (dead) continue;
|
||||
|
||||
chosen[index] = candidate;
|
||||
if (walk(rest, filtered, next)) return true;
|
||||
chosen[index] = null;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const all = options.map((_, index) => index);
|
||||
return walk(all, options, new Uint32Array(words)) ? (chosen as PortfolioEntry[]) : null;
|
||||
}
|
||||
|
||||
/** Distinct scores available to a character, descending. */
|
||||
function scoreLevels(portfolio: Portfolio): number[] {
|
||||
return [...new Set(portfolio.entries.map((entry) => entry.build.score))].sort(
|
||||
(a, b) => b - a,
|
||||
);
|
||||
}
|
||||
|
||||
export function* solveTeam(
|
||||
request: TeamRequest,
|
||||
): Generator<TeamProgress, TeamResult, void> {
|
||||
const {
|
||||
portfolios,
|
||||
poolSize,
|
||||
dragOrder = [],
|
||||
resolve,
|
||||
rounds = DEFAULT_ROUNDS,
|
||||
nodeBudget = DEFAULT_NODE_BUDGET,
|
||||
} = request;
|
||||
|
||||
const unbuildable = portfolios
|
||||
.filter((portfolio) => portfolio.unbuildable || portfolio.entries.length === 0)
|
||||
.map((portfolio) => portfolio.key);
|
||||
const active = portfolios.filter(
|
||||
(portfolio) => !portfolio.unbuildable && portfolio.entries.length > 0,
|
||||
);
|
||||
|
||||
if (active.length === 0) {
|
||||
return { assignment: [], sorted: [], unbuildable, rounds: 0, infeasible: false };
|
||||
}
|
||||
|
||||
const dragRank = (key: string): number => {
|
||||
const index = dragOrder.indexOf(key);
|
||||
return index < 0 ? dragOrder.length : index;
|
||||
};
|
||||
|
||||
/**
|
||||
* Raise the floor for everyone still unfixed as high as it will go, fix the
|
||||
* characters that cannot clear it, and repeat. Four cheap binary searches.
|
||||
*/
|
||||
const leximin = (): PortfolioEntry[] | null => {
|
||||
const fixed = new Array<number | null>(active.length).fill(null);
|
||||
let selection: PortfolioEntry[] | null = null;
|
||||
|
||||
for (let pass = 0; pass < active.length; pass += 1) {
|
||||
const unfixed = fixed
|
||||
.map((value, index) => (value === null ? index : -1))
|
||||
.filter((index) => index >= 0);
|
||||
if (unfixed.length === 0) break;
|
||||
|
||||
// Candidate floors: every score any unfixed character can actually hit.
|
||||
const levels = [
|
||||
...new Set(unfixed.flatMap((index) => scoreLevels(active[index]!))),
|
||||
].sort((a, b) => a - b);
|
||||
|
||||
const thresholdsFor = (floor: number): number[] =>
|
||||
fixed.map((value) => (value === null ? floor : value));
|
||||
|
||||
let low = 0;
|
||||
let high = levels.length - 1;
|
||||
let bestFloor: number | null = null;
|
||||
let bestSelection: PortfolioEntry[] | null = null;
|
||||
while (low <= high) {
|
||||
const mid = (low + high) >>> 1;
|
||||
const found = selectDisjoint(active, thresholdsFor(levels[mid]!), nodeBudget);
|
||||
if (found) {
|
||||
bestFloor = levels[mid]!;
|
||||
bestSelection = found;
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
if (bestFloor === null || bestSelection === null) return selection;
|
||||
selection = bestSelection;
|
||||
|
||||
// Anyone who cannot be pushed above the floor is bottlenecked here.
|
||||
const above = levels.filter((level) => level > bestFloor!);
|
||||
const nextLevel = above.length > 0 ? above[0]! : null;
|
||||
let fixedAny = false;
|
||||
for (const index of unfixed) {
|
||||
if (nextLevel === null) {
|
||||
fixed[index] = bestFloor;
|
||||
fixedAny = true;
|
||||
continue;
|
||||
}
|
||||
const probe = thresholdsFor(bestFloor);
|
||||
probe[index] = nextLevel;
|
||||
if (!selectDisjoint(active, probe, nodeBudget)) {
|
||||
fixed[index] = bestFloor;
|
||||
fixedAny = true;
|
||||
}
|
||||
}
|
||||
// No character is the bottleneck on its own: settle everyone here rather
|
||||
// than looping without progress.
|
||||
if (!fixedAny) {
|
||||
for (const index of unfixed) fixed[index] = bestFloor;
|
||||
}
|
||||
}
|
||||
|
||||
return selection;
|
||||
};
|
||||
|
||||
let first = leximin();
|
||||
|
||||
/**
|
||||
* Seed a feasible team when the portfolios alone cannot produce one.
|
||||
*
|
||||
* Portfolios are generated against the full pool with conflicts ignored, so
|
||||
* every character's builds concentrate on the same strongest items. With four
|
||||
* characters that regularly leaves **no** conflict-free selection at all, and
|
||||
* phase 1 has nothing to start from.
|
||||
*
|
||||
* Solving in drag order with the running selection excluded always yields a
|
||||
* valid team, because the pool is far larger than four builds. That seed is
|
||||
* order-dependent and therefore greedy - which is exactly what phase 2 exists
|
||||
* to undo, since it only ever accepts a lexicographic improvement to the whole
|
||||
* sorted vector. The anti-greedy guarantee survives; it just moves from
|
||||
* phase 1 to phase 2 in this case.
|
||||
*/
|
||||
if (!first && resolve) {
|
||||
const used = new Set<number>();
|
||||
let seeded = true;
|
||||
for (const portfolio of active) {
|
||||
const builds = resolve(portfolio.key, used);
|
||||
const seed = builds[0];
|
||||
if (!seed) {
|
||||
seeded = false;
|
||||
break;
|
||||
}
|
||||
portfolio.entries.push({ build: seed, items: itemsOf(seed, poolSize) });
|
||||
for (const item of [seed.cartridge, ...seed.modules]) used.add(item);
|
||||
}
|
||||
if (seeded) first = leximin();
|
||||
}
|
||||
|
||||
if (!first) {
|
||||
return { assignment: [], sorted: [], unbuildable, rounds: 0, infeasible: true };
|
||||
}
|
||||
let selection: PortfolioEntry[] = first;
|
||||
|
||||
const assignmentOf = (entries: PortfolioEntry[]): TeamAssignment[] =>
|
||||
entries
|
||||
.map((entry, index) => ({ key: active[index]!.key, build: entry.build }))
|
||||
.sort((a, b) => dragRank(a.key) - dragRank(b.key));
|
||||
|
||||
const sortedOf = (entries: PortfolioEntry[]): number[] =>
|
||||
entries.map((entry) => entry.build.score).sort((a, b) => a - b);
|
||||
|
||||
let sorted = sortedOf(selection);
|
||||
yield { round: 0, rounds, sorted, assignment: assignmentOf(selection) };
|
||||
|
||||
// --- phase 2: column generation ---------------------------------------
|
||||
let round = 0;
|
||||
if (resolve) {
|
||||
for (round = 1; round <= rounds; round += 1) {
|
||||
// The worst-off character, with drag order breaking a tie.
|
||||
let worst = 0;
|
||||
for (let i = 1; i < selection.length; i += 1) {
|
||||
const better = selection[i]!.build.score < selection[worst]!.build.score;
|
||||
const tie =
|
||||
selection[i]!.build.score === selection[worst]!.build.score &&
|
||||
dragRank(active[i]!.key) < dragRank(active[worst]!.key);
|
||||
if (better || tie) worst = i;
|
||||
}
|
||||
|
||||
const held = new Set<number>();
|
||||
selection.forEach((entry, index) => {
|
||||
if (index === worst) return;
|
||||
for (const item of [entry.build.cartridge, ...entry.build.modules]) held.add(item);
|
||||
});
|
||||
|
||||
// Move one: what the worst-off can do without touching anyone's items.
|
||||
const fresh = resolve(active[worst]!.key, held);
|
||||
|
||||
// Move two, the donor move: also offer it whatever strictly better-off
|
||||
// characters are holding. Accepted only if the whole vector improves.
|
||||
const betterOff = new Set<number>();
|
||||
selection.forEach((entry, index) => {
|
||||
if (index === worst) return;
|
||||
if (entry.build.score <= selection[worst]!.build.score) {
|
||||
for (const item of [entry.build.cartridge, ...entry.build.modules]) {
|
||||
betterOff.add(item);
|
||||
}
|
||||
}
|
||||
});
|
||||
const donated = resolve(active[worst]!.key, betterOff);
|
||||
|
||||
const added = [...fresh, ...donated];
|
||||
if (added.length === 0) break;
|
||||
|
||||
const before = active[worst]!.entries.length;
|
||||
for (const build of added) {
|
||||
active[worst]!.entries.push({ build, items: itemsOf(build, poolSize) });
|
||||
}
|
||||
if (active[worst]!.entries.length === before) break;
|
||||
|
||||
const candidate = leximin();
|
||||
if (!candidate) break;
|
||||
const candidateSorted = sortedOf(candidate);
|
||||
if (compareLeximin(candidateSorted, sorted) <= 0) {
|
||||
// No lexicographic improvement: keep what we had and stop paying for
|
||||
// rounds that cannot help.
|
||||
break;
|
||||
}
|
||||
selection = candidate;
|
||||
sorted = candidateSorted;
|
||||
yield { round, rounds, sorted, assignment: assignmentOf(selection) };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
assignment: assignmentOf(selection),
|
||||
sorted,
|
||||
unbuildable,
|
||||
rounds: round,
|
||||
infeasible: false,
|
||||
};
|
||||
}
|
||||
10
src/solver/worker-host.single.ts
Normal file
10
src/solver/worker-host.single.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* The single-file build's stand-in for `worker-host.ts`.
|
||||
*
|
||||
* A `file://` document has an opaque origin and cannot start a worker at all, so
|
||||
* this throws and `createSolverHost` falls through to the inline host - the same
|
||||
* path it takes in Node.
|
||||
*/
|
||||
export function makeWorker(): Worker {
|
||||
throw new Error("workers are unavailable in the single-file build");
|
||||
}
|
||||
12
src/solver/worker-host.ts
Normal file
12
src/solver/worker-host.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* Constructing the worker, isolated in its own module.
|
||||
*
|
||||
* `new Worker(new URL(...))` is rewritten by Vite at transform time whether or
|
||||
* not the surrounding branch can ever run, so guarding it with a flag would
|
||||
* still emit a worker chunk and still need `import.meta.url` - which a classic
|
||||
* script does not have. Keeping it here lets the single-file build alias this
|
||||
* module to a stub, so the worker never enters that bundle at all.
|
||||
*/
|
||||
export function makeWorker(): Worker {
|
||||
return new Worker(new URL("./worker.ts", import.meta.url), { type: "module" });
|
||||
}
|
||||
47
src/solver/worker.ts
Normal file
47
src/solver/worker.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* The worker entry point.
|
||||
*
|
||||
* It does nothing but pump `runJob` and post what comes out, so the only
|
||||
* difference between running here and running inline is which thread blocks.
|
||||
*/
|
||||
import { runJob, type JobProgress, type JobResult, type SolveJob } from "./job.ts";
|
||||
|
||||
export type WorkerIn = { type: "solve"; id: number; job: SolveJob } | { type: "cancel" };
|
||||
|
||||
export type WorkerOut =
|
||||
| { type: "progress"; id: number; progress: JobProgress }
|
||||
| { type: "result"; id: number; result: JobResult }
|
||||
| { type: "error"; id: number; message: string };
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
self.onmessage = (event: MessageEvent<WorkerIn>) => {
|
||||
const message = event.data;
|
||||
if (message.type === "cancel") {
|
||||
cancelled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
cancelled = false;
|
||||
const { id, job } = message;
|
||||
try {
|
||||
const run = runJob(job);
|
||||
let step = run.next();
|
||||
while (!step.done) {
|
||||
if (cancelled) return;
|
||||
const out: WorkerOut = { type: "progress", id, progress: step.value };
|
||||
self.postMessage(out);
|
||||
step = run.next();
|
||||
}
|
||||
if (cancelled) return;
|
||||
const out: WorkerOut = { type: "result", id, result: step.value };
|
||||
self.postMessage(out);
|
||||
} catch (error) {
|
||||
const out: WorkerOut = {
|
||||
type: "error",
|
||||
id,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
self.postMessage(out);
|
||||
}
|
||||
};
|
||||
87
src/state/gamedata.ts
Normal file
87
src/state/gamedata.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/**
|
||||
* The generated game data.
|
||||
*
|
||||
* Loaded through a dynamic import so Vite emits it as a content-hashed chunk -
|
||||
* immutable-cacheable, and off the first-paint path. It stays a plain module
|
||||
* rather than a `fetch` precisely so the single-file build can inline it;
|
||||
* `fetch` could not be.
|
||||
*/
|
||||
import type { RawTilings } from "../domain/tilings.ts";
|
||||
import type { SetBonusTable } from "../domain/setbonus.ts";
|
||||
import type { GuideTable } from "../domain/guides.ts";
|
||||
|
||||
export interface Esper {
|
||||
id: number;
|
||||
abilityKey: string;
|
||||
name: string;
|
||||
element: string | null;
|
||||
rarity: number;
|
||||
icon: string;
|
||||
stats: Array<{ id_stats: string; name: string; bShowPercent: boolean; values: number[] }>;
|
||||
slots: number[][];
|
||||
ownerGridCount: number;
|
||||
trait: Array<{ id_stats: string; name: string; bShowPercent: boolean; value: number }>;
|
||||
breakthroughLevels: number[];
|
||||
}
|
||||
|
||||
export interface Arc {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
quality: number;
|
||||
desc: string;
|
||||
stats: unknown;
|
||||
values: unknown;
|
||||
}
|
||||
|
||||
export type ArcEffectMode = "always" | "stacks" | "toggle" | "duration" | "unmodellable";
|
||||
|
||||
export interface ArcEffect {
|
||||
placeholder: number;
|
||||
stat: string | null;
|
||||
percent?: boolean;
|
||||
mode: ArcEffectMode;
|
||||
default?: boolean;
|
||||
why: string;
|
||||
}
|
||||
|
||||
export interface GameData {
|
||||
format: string;
|
||||
format_version: number;
|
||||
generated: string;
|
||||
espers: Esper[];
|
||||
arcs: Arc[];
|
||||
arcEffects: Record<string, { name: string; effects: ArcEffect[] }>;
|
||||
}
|
||||
|
||||
export interface LoadedData {
|
||||
gamedata: GameData;
|
||||
tilings: RawTilings;
|
||||
setBonuses: SetBonusTable;
|
||||
guides: GuideTable;
|
||||
}
|
||||
|
||||
let cached: Promise<LoadedData> | null = null;
|
||||
|
||||
export function loadGameData(): Promise<LoadedData> {
|
||||
cached ??= (async () => {
|
||||
const [gamedata, tilings, setBonuses, guides] = await Promise.all([
|
||||
import("../generated/gamedata.json"),
|
||||
import("../generated/tilings.json"),
|
||||
import("../../data-src/set-bonuses.json"),
|
||||
import("../../data-src/guides.json"),
|
||||
]);
|
||||
return {
|
||||
gamedata: (gamedata.default ?? gamedata) as unknown as GameData,
|
||||
tilings: (tilings.default ?? tilings) as unknown as RawTilings,
|
||||
setBonuses: (setBonuses.default ?? setBonuses) as unknown as SetBonusTable,
|
||||
guides: (guides.default ?? guides) as unknown as GuideTable,
|
||||
};
|
||||
})();
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** The esper whose ability key a capture used, or null for a codename with no record. */
|
||||
export function esperFor(data: GameData, characterId: string): Esper | null {
|
||||
return data.espers.find((esper) => esper.abilityKey === characterId) ?? null;
|
||||
}
|
||||
163
src/state/store.ts
Normal file
163
src/state/store.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
/**
|
||||
* The app store.
|
||||
*
|
||||
* Deliberately not a library: an observable holding one immutable `StoredState`,
|
||||
* plus the adapter it persists through. React subscribes via
|
||||
* `useSyncExternalStore`, so nothing here imports React and the whole thing is
|
||||
* testable in Node.
|
||||
*/
|
||||
import { createBestAdapter } from "../db/adapters/idb.ts";
|
||||
import { emptyState, type PersistenceAdapter, type StoredState } from "../db/adapters/types.ts";
|
||||
import { parseGearExport, type ImportResult } from "../db/import.ts";
|
||||
import {
|
||||
applyImport,
|
||||
editItem,
|
||||
equip,
|
||||
nameOwnerGroup,
|
||||
persist,
|
||||
undoLast,
|
||||
type EquipOptions,
|
||||
type EquipOutcome,
|
||||
} from "../db/store.ts";
|
||||
import { importDatabase, exportDatabase, type DbFile } from "../db/dbfile.ts";
|
||||
|
||||
export interface AppState {
|
||||
ready: boolean;
|
||||
/** False in the degraded persistence tiers, which the UI must announce. */
|
||||
durable: boolean;
|
||||
adapterKind: PersistenceAdapter["kind"];
|
||||
data: StoredState;
|
||||
/** The last import that was offered and refused, so the report can be shown. */
|
||||
lastImportReport: ImportResult | null;
|
||||
}
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
export class Store {
|
||||
private state: AppState;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
constructor(private adapter: PersistenceAdapter) {
|
||||
this.state = {
|
||||
ready: false,
|
||||
durable: adapter.durable,
|
||||
adapterKind: adapter.kind,
|
||||
data: emptyState(),
|
||||
lastImportReport: null,
|
||||
};
|
||||
}
|
||||
|
||||
static async open(adapter?: PersistenceAdapter): Promise<Store> {
|
||||
const store = new Store(adapter ?? (await createBestAdapter()));
|
||||
await store.load();
|
||||
return store;
|
||||
}
|
||||
|
||||
getState = (): AppState => this.state;
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
|
||||
private set(next: Partial<AppState>): void {
|
||||
this.state = { ...this.state, ...next };
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
this.set({ data: await this.adapter.read(), ready: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an export without applying it.
|
||||
*
|
||||
* Import is deliberate and irreversible by design, so the confirmation dialog
|
||||
* gets the counts first and the player gets to refuse.
|
||||
*/
|
||||
reviewImport(raw: unknown): ImportResult {
|
||||
const result = parseGearExport(raw);
|
||||
this.set({ lastImportReport: result });
|
||||
return result;
|
||||
}
|
||||
|
||||
async applyReviewedImport(result: ImportResult): Promise<void> {
|
||||
const next = applyImport(this.state.data, result);
|
||||
await persist(this.adapter, next, "all");
|
||||
this.set({ data: next, lastImportReport: null });
|
||||
}
|
||||
|
||||
/** The escape hatch: not undo, but one click back from a wrong file. */
|
||||
async restorePreImport(): Promise<boolean> {
|
||||
const snapshot = this.state.data.snapshot;
|
||||
if (!snapshot) return false;
|
||||
const next: StoredState = { db: snapshot, undo: [], snapshot: null };
|
||||
await persist(this.adapter, next, "all");
|
||||
this.set({ data: next });
|
||||
return true;
|
||||
}
|
||||
|
||||
async equip(
|
||||
characterId: string,
|
||||
instances: readonly string[],
|
||||
options?: EquipOptions,
|
||||
): Promise<EquipOutcome["displaced"]> {
|
||||
const outcome = equip(this.state.data, characterId, instances, options);
|
||||
await persist(this.adapter, outcome.state, "equipment");
|
||||
this.set({ data: outcome.state });
|
||||
return outcome.displaced;
|
||||
}
|
||||
|
||||
/** Record what the game's sheet actually reads for a character. */
|
||||
async setMeasuredSheet(
|
||||
characterId: string,
|
||||
measured: Record<string, number> | null,
|
||||
): Promise<void> {
|
||||
const characters = this.state.data.db.characters.map((row) =>
|
||||
row.characterId === characterId ? { ...row, measuredSheet: measured } : row,
|
||||
);
|
||||
const next = { ...this.state.data, db: { ...this.state.data.db, characters } };
|
||||
await persist(this.adapter, next, "all");
|
||||
this.set({ data: next });
|
||||
}
|
||||
|
||||
/** Correct a decoded value by hand. Overwritten by the next capture import. */
|
||||
async editItem(
|
||||
instance: string,
|
||||
patch: Parameters<typeof editItem>[2],
|
||||
): Promise<void> {
|
||||
const next = editItem(this.state.data, instance, patch);
|
||||
await persist(this.adapter, next, "all");
|
||||
this.set({ data: next });
|
||||
}
|
||||
|
||||
async undo(): Promise<void> {
|
||||
const next = undoLast(this.state.data);
|
||||
await persist(this.adapter, next, "equipment");
|
||||
this.set({ data: next });
|
||||
}
|
||||
|
||||
async nameGroup(ownerGroup: string, characterId: string): Promise<void> {
|
||||
const next = nameOwnerGroup(this.state.data, ownerGroup, characterId);
|
||||
await persist(this.adapter, next, "all");
|
||||
this.set({ data: next });
|
||||
}
|
||||
|
||||
exportFile(gamedataVersion: string | null): DbFile {
|
||||
return exportDatabase(this.state.data, gamedataVersion);
|
||||
}
|
||||
|
||||
async importFile(raw: unknown): Promise<string[]> {
|
||||
const result = importDatabase(raw);
|
||||
if (!result.ok || !result.state) return result.problems;
|
||||
// Same protection as a capture import: keep a way back.
|
||||
const next: StoredState = { ...result.state, snapshot: this.state.data.db };
|
||||
await persist(this.adapter, next, "all");
|
||||
this.set({ data: next });
|
||||
return [];
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.adapter.close();
|
||||
}
|
||||
}
|
||||
114
src/ui/App.tsx
Normal file
114
src/ui/App.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Store } from "../state/store.ts";
|
||||
import { loadGameData, type LoadedData } from "../state/gamedata.ts";
|
||||
import { resolveCharacter, todoInGame, unnamedGroups } from "../db/store.ts";
|
||||
import { DataPanel } from "./DataPanel.tsx";
|
||||
import { ItemTable } from "./ItemTable.tsx";
|
||||
import { CharactersTab } from "./CharactersTab.tsx";
|
||||
import { TeamTab } from "./TeamTab.tsx";
|
||||
import { StoreContext, useAppState } from "./useStore.ts";
|
||||
import { hashFor, useRoute, TABS } from "./router.ts";
|
||||
|
||||
export function App() {
|
||||
const [store, setStore] = useState<Store | null>(null);
|
||||
const [data, setData] = useState<LoadedData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const [opened, loaded] = await Promise.all([Store.open(), loadGameData()]);
|
||||
if (cancelled) return;
|
||||
setStore(opened);
|
||||
setData(loaded);
|
||||
} catch (caught) {
|
||||
if (!cancelled) setError(caught instanceof Error ? caught.message : String(caught));
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (error) return <p className="problem">Could not start: {error}</p>;
|
||||
if (!store || !data) return <p className="dim">Loading…</p>;
|
||||
|
||||
return (
|
||||
<StoreContext.Provider value={store}>
|
||||
<Shell data={data} />
|
||||
</StoreContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Shell({ data }: { data: LoadedData }) {
|
||||
const state = useAppState();
|
||||
const route = useRoute();
|
||||
const db = state.data.db;
|
||||
|
||||
const ownerOf = (instance: string): string | null => {
|
||||
const row = db.equipment.find((entry) => entry.instance === instance);
|
||||
if (!row) return null;
|
||||
return resolveCharacter(row, db.ownerNames) ?? `group ${row.ownerGroup?.slice(0, 8)}`;
|
||||
};
|
||||
|
||||
const todo = todoInGame(db);
|
||||
const unnamed = unnamedGroups(db);
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header>
|
||||
<h1>NTE gear optimizer</h1>
|
||||
<nav>
|
||||
{TABS.map((tab) => (
|
||||
<a key={tab} href={hashFor(tab)} className={route.tab === tab ? "active" : ""}>
|
||||
{tab}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{/* The degraded persistence tiers have to say so, permanently. */}
|
||||
{!state.durable && (
|
||||
<p className="warning">
|
||||
Storage is not durable here ({state.adapterKind}). Use <strong>Export database</strong>
|
||||
{" "}to keep your work.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{db.items.length === 0 && (
|
||||
<p className="empty">
|
||||
No gear imported yet. Run the exporter, then import the{" "}
|
||||
<code>*_Gear_*.json</code> file it writes.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DataPanel gamedataVersion={data.gamedata.generated} />
|
||||
|
||||
{todo.size > 0 && (
|
||||
<details className="todo">
|
||||
<summary>To do in game ({[...todo.values()].flat().length} pieces)</summary>
|
||||
{[...todo].map(([character, rows]) => (
|
||||
<p key={character || "unassigned"}>
|
||||
<strong>{character || "unassigned"}</strong>: {rows.length} pieces
|
||||
</p>
|
||||
))}
|
||||
</details>
|
||||
)}
|
||||
|
||||
<main>
|
||||
{route.tab === "cartridges" && (
|
||||
<ItemTable kind="cartridge" items={db.items} ownerOf={ownerOf} />
|
||||
)}
|
||||
{route.tab === "modules" && (
|
||||
<ItemTable kind="module" items={db.items} ownerOf={ownerOf} />
|
||||
)}
|
||||
{route.tab === "characters" && (
|
||||
<CharactersTab data={data} route={route} unnamedGroups={unnamed} />
|
||||
)}
|
||||
{route.tab === "team" && <TeamTab data={data} />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
src/ui/Board.tsx
Normal file
60
src/ui/Board.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* A character's console grid.
|
||||
*
|
||||
* Blocked cells are drawn as holes, free cells as the piece covering them, and
|
||||
* borders only where a piece meets something else - so a build reads as six to
|
||||
* eight objects rather than twenty squares.
|
||||
*/
|
||||
import { pieceColour, renderBoard } from "../domain/render.ts";
|
||||
|
||||
export interface BoardProps {
|
||||
cells: readonly number[];
|
||||
placement: readonly number[];
|
||||
pieces: number;
|
||||
/** Called with the piece index under the pointer, or null. */
|
||||
onHover?: (piece: number | null) => void;
|
||||
labelFor?: (piece: number) => string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function Board({
|
||||
cells,
|
||||
placement,
|
||||
pieces,
|
||||
onHover,
|
||||
labelFor,
|
||||
size = 34,
|
||||
}: BoardProps) {
|
||||
const render = renderBoard(cells, placement);
|
||||
const free = new Set(cells);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="board"
|
||||
style={{ gridTemplateColumns: `repeat(${render.cols}, ${size}px)` }}
|
||||
onPointerLeave={() => onHover?.(null)}
|
||||
>
|
||||
{Array.from({ length: render.rows * render.cols }, (_, index) => {
|
||||
if (!free.has(index)) {
|
||||
return <div key={index} className="board-cell board-cell--blocked" />;
|
||||
}
|
||||
const cell = render.cells.find((entry) => entry.cell === index)!;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="board-cell"
|
||||
title={labelFor?.(cell.piece)}
|
||||
onPointerEnter={() => onHover?.(cell.piece)}
|
||||
style={{
|
||||
background: pieceColour(cell.piece, pieces),
|
||||
borderTopWidth: cell.top ? 2 : 0,
|
||||
borderRightWidth: cell.right ? 2 : 0,
|
||||
borderBottomWidth: cell.bottom ? 2 : 0,
|
||||
borderLeftWidth: cell.left ? 2 : 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
701
src/ui/CharacterView.tsx
Normal file
701
src/ui/CharacterView.tsx
Normal file
|
|
@ -0,0 +1,701 @@
|
|||
/**
|
||||
* One character's optimizer view.
|
||||
*
|
||||
* Holds the Arc selector, the console grid the result is drawn into, the
|
||||
* targets the solve runs against, and Equip.
|
||||
*
|
||||
* Two things this view has to be honest about rather than paper over:
|
||||
*
|
||||
* - A set bonus whose value nobody has measured contributes **nothing**, and the
|
||||
* panel says so. Scoring it as zero silently would look like a real number.
|
||||
* - `proven` means "no better module assignment for this packing and cartridge",
|
||||
* not global optimality, and the label says exactly that.
|
||||
*/
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
|
||||
import { arcContribution, arcControls, MAX_ARC_LEVEL, MAX_REFINEMENT } from "../domain/arcs.ts";
|
||||
import { boardFromSlots } from "../domain/board.ts";
|
||||
import { compile, explain, type StatTarget } from "../domain/scoring.ts";
|
||||
import { guideFor, targetsFromGuide, variantFor } from "../domain/guides.ts";
|
||||
import { compareSheet, predictSheet } from "../domain/sheet.ts";
|
||||
import { consoleTrait } from "../domain/stats.ts";
|
||||
import {
|
||||
emptyVector,
|
||||
isKnownStat,
|
||||
slotOf,
|
||||
SLOT_COUNT,
|
||||
STAT_SLOTS,
|
||||
type StatId,
|
||||
} from "../domain/statvec.ts";
|
||||
import { equipmentOf, heldByOthers, resolveCharacter } from "../db/store.ts";
|
||||
import { createSolverHost, CancelledError, type SolveHandle } from "../solver/host.ts";
|
||||
import type { JobProgress, JobResult, SolveJob } from "../solver/job.ts";
|
||||
import { esperFor, type LoadedData } from "../state/gamedata.ts";
|
||||
import { Board } from "./Board.tsx";
|
||||
import { ItemCard } from "./ItemCard.tsx";
|
||||
import { Icon } from "./Icon.tsx";
|
||||
import { useAppState, useStore } from "./useStore.ts";
|
||||
|
||||
const DEFAULT_TARGETS: StatTarget[] = [
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
export function CharacterView({
|
||||
data,
|
||||
characterId,
|
||||
}: {
|
||||
data: LoadedData;
|
||||
characterId: string;
|
||||
}) {
|
||||
const store = useStore();
|
||||
const state = useAppState();
|
||||
const db = state.data.db;
|
||||
|
||||
const character = db.characters.find((row) => row.characterId === characterId);
|
||||
const esper = esperFor(data.gamedata, characterId);
|
||||
|
||||
const guide = guideFor(data.guides, characterId);
|
||||
// Defaults to Prydwen on every page load, as specified - but a character with
|
||||
// no published guide has nothing to default to, so it starts on Custom.
|
||||
const [useCustom, setUseCustom] = useState(() => guide === null);
|
||||
const [variantName, setVariantName] = useState<string | null>(
|
||||
guide?.variants[0]?.name ?? null,
|
||||
);
|
||||
|
||||
const [customTargets, setTargets] = useState<StatTarget[]>(() => {
|
||||
const stored = character?.customTargets;
|
||||
if (!stored?.length) return DEFAULT_TARGETS;
|
||||
return stored
|
||||
.filter((entry) => isKnownStat(entry.stat))
|
||||
.map((entry) => ({ stat: entry.stat as StatId, target: entry.target, weight: entry.weight }));
|
||||
});
|
||||
const variant = variantFor(guide, variantName);
|
||||
// Switching back to Prydwen does not discard what was customised.
|
||||
const targets = useCustom || !variant ? customTargets : targetsFromGuide(variant);
|
||||
|
||||
const [arcId, setArcId] = useState<string | null>(character?.arcId ?? null);
|
||||
const [arcLevel, setArcLevel] = useState(MAX_ARC_LEVEL);
|
||||
const [refinement, setRefinement] = useState(Math.max(character?.arcRefinement ?? 1, 1));
|
||||
const [arcToggles, setArcToggles] = useState<Record<number, boolean | number>>(
|
||||
() => (character?.effectToggles as Record<number, boolean | number>) ?? {},
|
||||
);
|
||||
const [result, setResult] = useState<JobResult | null>(null);
|
||||
const [progress, setProgress] = useState<JobProgress | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
const [hovered, setHovered] = useState<number | null>(null);
|
||||
// R2. Off by default: taking gear off another character is a real cost, so it
|
||||
// is opted into rather than assumed.
|
||||
const [useEquipped, setUseEquipped] = useState(false);
|
||||
const handle = useRef<SolveHandle | null>(null);
|
||||
|
||||
const worn = equipmentOf(db, characterId);
|
||||
|
||||
const arc = arcId ? data.gamedata.arcs.find((entry) => entry.id === arcId) : undefined;
|
||||
// The Arc is part of the character's base, not part of the gear pool: it
|
||||
// contributes flat ATK and one stat line before any module is placed.
|
||||
const arcResult = useMemo(
|
||||
() =>
|
||||
arc
|
||||
? arcContribution(arc, data.gamedata.arcEffects[arc.id], {
|
||||
arcId: arc.id,
|
||||
level: arcLevel,
|
||||
refinement,
|
||||
toggles: arcToggles,
|
||||
})
|
||||
: null,
|
||||
[arc, data.gamedata.arcEffects, arcLevel, refinement, arcToggles],
|
||||
);
|
||||
const build = result?.assignment[0]?.build ?? null;
|
||||
|
||||
const board = useMemo(() => (esper ? boardFromSlots(esper.slots) : null), [esper]);
|
||||
|
||||
if (!esper || !board) {
|
||||
return (
|
||||
<section>
|
||||
<h2>{characterId}</h2>
|
||||
{/* Seven codenames have no everness counterpart at all. */}
|
||||
<p className="warning">
|
||||
No game data for this codename, so there is no console grid, stat curve or artwork for
|
||||
it. Its gear still appears in the item tabs.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const trait = consoleTrait(esper);
|
||||
|
||||
const solve = async () => {
|
||||
setProblem(null);
|
||||
setResult(null);
|
||||
setRunning(true);
|
||||
const job: SolveJob = {
|
||||
items: db.items.map((item) => ({
|
||||
instance: item.instance,
|
||||
kind: item.kind,
|
||||
item_id: item.itemId,
|
||||
shape: item.shape ?? undefined,
|
||||
set: item.set ?? undefined,
|
||||
level: item.level,
|
||||
rarity: item.rarity,
|
||||
main_stats: item.mainStats,
|
||||
substats: item.substats,
|
||||
owner_group: null,
|
||||
})),
|
||||
setBonuses: data.setBonuses,
|
||||
tilings: data.tilings,
|
||||
characters: [
|
||||
{
|
||||
key: characterId,
|
||||
slots: esper.slots,
|
||||
trait,
|
||||
base: arcResult ? Array.from(arcResult.vector) : new Array(SLOT_COUNT).fill(0),
|
||||
scoring: { targets },
|
||||
excludedInstances: useEquipped ? [] : heldByOthers(db, [characterId]),
|
||||
},
|
||||
],
|
||||
options: { rounds: 0 },
|
||||
};
|
||||
const host = createSolverHost();
|
||||
const started = host.solve(job, setProgress);
|
||||
handle.current = started;
|
||||
try {
|
||||
setResult(await started.promise);
|
||||
} catch (error) {
|
||||
if (!(error instanceof CancelledError)) {
|
||||
setProblem(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
} finally {
|
||||
setRunning(false);
|
||||
host.dispose();
|
||||
}
|
||||
};
|
||||
|
||||
// The solve's own packing decides the cells; a captured loadout carries them
|
||||
// only when the capture did.
|
||||
const placement = build
|
||||
? build.tiling.placement
|
||||
: board.cells.map((cell) => worn.findIndex((row) => row.cells?.includes(cell)));
|
||||
const cells = build ? build.tiling.cells : board.cells;
|
||||
const pieces = build ? build.tiling.pieces.length : Math.max(worn.length, 1);
|
||||
|
||||
const itemFor = (piece: number) => {
|
||||
if (!build) {
|
||||
const row = worn[piece];
|
||||
return row ? db.items.find((item) => item.instance === row.instance) : undefined;
|
||||
}
|
||||
const index = build.modules[piece];
|
||||
return index === undefined ? undefined : db.items[index];
|
||||
};
|
||||
|
||||
const report = build ? explain(build.vector, compile({ targets })) : null;
|
||||
|
||||
// The sheet reflects what the character would actually have: the solved build
|
||||
// when there is one, otherwise the Arc alone plus what they are wearing.
|
||||
const sheetTotal = useMemo(() => {
|
||||
if (build) return build.vector;
|
||||
const total = arcResult ? new Float32Array(arcResult.vector) : emptyVector();
|
||||
for (const row of worn) {
|
||||
const item = db.items.find((entry) => entry.instance === row.instance);
|
||||
if (!item) continue;
|
||||
for (const stat of [...item.mainStats, ...item.substats]) {
|
||||
if (stat.value === null) continue;
|
||||
const slot = slotOf(stat.stat);
|
||||
if (slot >= 0) total[slot] = total[slot]! + stat.value;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}, [build, arcResult, worn, db.items]);
|
||||
|
||||
const sheet = compareSheet(
|
||||
predictSheet(esper, character?.level ?? null, sheetTotal),
|
||||
character?.measuredSheet,
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="character">
|
||||
<h2 className="card-head">
|
||||
<Icon entry={`esper:${characterId}`} alt={esper.name} size={56} />
|
||||
{esper.name}{" "}
|
||||
<span className="dim">
|
||||
level {character?.level ?? "?"} · {character?.breakthroughs ?? "?"} breakthroughs
|
||||
</span>
|
||||
</h2>
|
||||
<p className="dim">
|
||||
{esper.element} · console trait{" "}
|
||||
{trait
|
||||
? `${trait.name} +${trait.per} per Type ${trait.moduleCells === 2 ? "II" : "III"}`
|
||||
: "none"}
|
||||
</p>
|
||||
|
||||
<div className="arc-row">
|
||||
<label className="arc">
|
||||
Arc
|
||||
<select value={arcId ?? ""} onChange={(event) => setArcId(event.target.value || null)}>
|
||||
<option value="">none</option>
|
||||
{data.gamedata.arcs.map((entry) => (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
{entry.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{arc && (
|
||||
<>
|
||||
<label className="arc">
|
||||
Level
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_ARC_LEVEL}
|
||||
value={arcLevel}
|
||||
onChange={(event) => setArcLevel(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="arc">
|
||||
Refinement
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_REFINEMENT}
|
||||
value={refinement}
|
||||
onChange={(event) => setRefinement(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{arc && (
|
||||
<ArcEffects
|
||||
controls={arcControls(arc, data.gamedata.arcEffects[arc.id])}
|
||||
toggles={arcToggles}
|
||||
onChange={setArcToggles}
|
||||
omitted={arcResult?.omitted ?? []}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="source-toggle">
|
||||
<button
|
||||
className={useCustom ? "" : "active"}
|
||||
disabled={guide === null}
|
||||
onClick={() => setUseCustom(false)}
|
||||
>
|
||||
Prydwen
|
||||
</button>
|
||||
<button className={useCustom ? "active" : ""} onClick={() => setUseCustom(true)}>
|
||||
Custom
|
||||
</button>
|
||||
{guide && !useCustom && guide.variants.length > 1 && (
|
||||
<select
|
||||
value={variantName ?? ""}
|
||||
onChange={(event) => setVariantName(event.target.value)}
|
||||
>
|
||||
{guide.variants.map((entry) => (
|
||||
<option key={entry.name} value={entry.name}>
|
||||
{entry.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{guide === null && (
|
||||
<span className="dim">
|
||||
No published guide for this character — see RESUME.md on why guide data is not
|
||||
scraped.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{useCustom || !variant ? (
|
||||
<TargetEditor targets={customTargets} onChange={setTargets} />
|
||||
) : (
|
||||
<ReadOnlyTargets targets={targets} source={guide!.source} updated={guide!.updated} />
|
||||
)}
|
||||
|
||||
<label className="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useEquipped}
|
||||
onChange={(event) => setUseEquipped(event.target.checked)}
|
||||
/>
|
||||
Use items other characters are wearing
|
||||
</label>
|
||||
|
||||
<div className="actions">
|
||||
<button disabled={running} onClick={() => void solve()}>
|
||||
Calculate
|
||||
</button>
|
||||
<button disabled={!running} onClick={() => handle.current?.cancel()}>
|
||||
Stop
|
||||
</button>
|
||||
{build && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const instances = [build.cartridge, ...build.modules]
|
||||
.map((index) => db.items[index]?.instance)
|
||||
.filter((instance) => instance !== undefined);
|
||||
const cellsByInstance: Record<string, number[]> = {};
|
||||
build.modules.forEach((index, piece) => {
|
||||
const instance = db.items[index]?.instance;
|
||||
if (!instance) return;
|
||||
cellsByInstance[instance] = build.tiling.cells.filter(
|
||||
(_cell, position) => build.tiling.placement[position] === piece,
|
||||
);
|
||||
});
|
||||
void store.equip(characterId, instances, { cells: cellsByInstance });
|
||||
}}
|
||||
>
|
||||
Equip this build
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{running && progress && (
|
||||
<p className="dim">
|
||||
{progress.phase === "portfolio"
|
||||
? `${progress.done} / ${progress.total} packings`
|
||||
: `round ${progress.round}`}
|
||||
</p>
|
||||
)}
|
||||
{problem && <p className="problem">{problem}</p>}
|
||||
{result?.unbuildable.includes(characterId) && (
|
||||
<p className="warning">No valid full-set build exists for this character and pool.</p>
|
||||
)}
|
||||
|
||||
<Board
|
||||
cells={cells}
|
||||
placement={placement}
|
||||
pieces={pieces}
|
||||
onHover={setHovered}
|
||||
labelFor={(piece) => {
|
||||
const item = itemFor(piece);
|
||||
return item ? `${item.shape ?? item.set} +${item.level}` : "";
|
||||
}}
|
||||
/>
|
||||
|
||||
{hovered !== null && hovered >= 0 && (
|
||||
<ItemCard item={itemFor(hovered)} db={db} />
|
||||
)}
|
||||
|
||||
<SheetPanel
|
||||
sheet={sheet}
|
||||
measured={character?.measuredSheet ?? null}
|
||||
onSave={(next) => void store.setMeasuredSheet(characterId, next)}
|
||||
/>
|
||||
|
||||
{build && (
|
||||
<div className="result">
|
||||
<p className="dim">
|
||||
score {build.score.toFixed(4)} ·{" "}
|
||||
{build.proven
|
||||
? "optimal for this packing and cartridge"
|
||||
: "best found, not proved optimal"}
|
||||
</p>
|
||||
{build.unknownTiers.length > 0 && (
|
||||
<p className="warning">
|
||||
Set bonus tier {build.unknownTiers.join(" and ")} is active but its values have
|
||||
never been measured, so it contributes nothing to this score.
|
||||
</p>
|
||||
)}
|
||||
{report && (
|
||||
<table className="items">
|
||||
<tbody>
|
||||
{report.stats.map((stat) => (
|
||||
<tr key={stat.stat}>
|
||||
<td>{stat.stat}</td>
|
||||
<td>{stat.value.toFixed(3)}</td>
|
||||
<td className="dim">/ {stat.target}</td>
|
||||
<td>
|
||||
<div className="bar">
|
||||
<div style={{ width: `${stat.attainment * 100}%` }} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="dim">{(stat.attainment * 100).toFixed(0)}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TargetEditor({
|
||||
targets,
|
||||
onChange,
|
||||
}: {
|
||||
targets: StatTarget[];
|
||||
onChange: (next: StatTarget[]) => void;
|
||||
}) {
|
||||
return (
|
||||
<details className="targets">
|
||||
<summary>Targets and weights</summary>
|
||||
<p className="dim">
|
||||
Targets are a floor to reach, not a ceiling: each stat stops earning once it is met, so
|
||||
the shortfall falls on the lowest-weighted stats.
|
||||
</p>
|
||||
{targets.map((entry, index) => (
|
||||
<div key={entry.stat} className="target-row">
|
||||
<select
|
||||
value={entry.stat}
|
||||
onChange={(event) => {
|
||||
const next = [...targets];
|
||||
next[index] = { ...entry, stat: event.target.value as StatId };
|
||||
onChange(next);
|
||||
}}
|
||||
>
|
||||
{STAT_SLOTS.map((stat) => (
|
||||
<option key={stat} value={stat}>
|
||||
{stat}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={entry.target}
|
||||
onChange={(event) => {
|
||||
const next = [...targets];
|
||||
next[index] = { ...entry, target: Number(event.target.value) };
|
||||
onChange(next);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={entry.weight}
|
||||
onChange={(event) => {
|
||||
const next = [...targets];
|
||||
next[index] = { ...entry, weight: Number(event.target.value) };
|
||||
onChange(next);
|
||||
}}
|
||||
/>
|
||||
<button onClick={() => onChange(targets.filter((_, i) => i !== index))}>remove</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() =>
|
||||
onChange([
|
||||
...targets,
|
||||
{ stat: STAT_SLOTS.find((stat) => !targets.some((t) => t.stat === stat))!, target: 1, weight: 1 },
|
||||
])
|
||||
}
|
||||
>
|
||||
add a stat
|
||||
</button>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Controls for an Arc's conditional effects.
|
||||
*
|
||||
* The control follows the classification, never the effect text: `toggle` is a
|
||||
* checkbox, `stacks` is a count. Anything classified `duration` or
|
||||
* `unmodellable` gets no control and is listed as left out, because a build that
|
||||
* silently assumed a conditional buff was active would read as a better build
|
||||
* than it is.
|
||||
*/
|
||||
function ArcEffects({
|
||||
controls,
|
||||
toggles,
|
||||
onChange,
|
||||
omitted,
|
||||
}: {
|
||||
controls: ReturnType<typeof arcControls>;
|
||||
toggles: Record<number, boolean | number>;
|
||||
onChange: (next: Record<number, boolean | number>) => void;
|
||||
omitted: Array<{ placeholder: number; mode: string; why: string }>;
|
||||
}) {
|
||||
const skipped = omitted.filter(
|
||||
(entry) => entry.mode === "duration" || entry.mode === "unmodellable",
|
||||
);
|
||||
return (
|
||||
<details className="targets">
|
||||
<summary>Arc effects</summary>
|
||||
{controls.length === 0 && <p className="dim">Nothing on this Arc is conditional.</p>}
|
||||
{controls.map((control) =>
|
||||
control.mode === "toggle" ? (
|
||||
<label key={control.placeholder} className="target-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toggles[control.placeholder] === true}
|
||||
onChange={(event) =>
|
||||
onChange({ ...toggles, [control.placeholder]: event.target.checked })
|
||||
}
|
||||
/>
|
||||
{control.stat} <span className="dim">{control.why}</span>
|
||||
</label>
|
||||
) : (
|
||||
<label key={control.placeholder} className="target-row">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={Number(toggles[control.placeholder] ?? 0)}
|
||||
onChange={(event) =>
|
||||
onChange({ ...toggles, [control.placeholder]: Number(event.target.value) })
|
||||
}
|
||||
/>
|
||||
{control.stat} stacks <span className="dim">{control.why}</span>
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
{skipped.length > 0 && (
|
||||
<p className="dim">
|
||||
Not modelled: {skipped.map((entry) => entry.why).join("; ")}
|
||||
</p>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicted against what the game actually shows.
|
||||
*
|
||||
* Permanent rather than a debug view. The stat model was solved against one
|
||||
* character's sheet and has measured gaps, so the only honest way to ship it is
|
||||
* to keep the comparison in front of the player: drift then gets noticed by
|
||||
* whoever can see the real number, instead of quietly skewing every build.
|
||||
*/
|
||||
function SheetPanel({
|
||||
sheet,
|
||||
measured,
|
||||
onSave,
|
||||
}: {
|
||||
sheet: ReturnType<typeof compareSheet>;
|
||||
measured: Record<string, number> | null;
|
||||
onSave: (next: Record<string, number> | null) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(Object.entries(measured ?? {}).map(([key, value]) => [key, String(value)])),
|
||||
);
|
||||
const drifted = sheet.filter((line) => line.drifted);
|
||||
|
||||
return (
|
||||
<details className="targets sheet" open={drifted.length > 0}>
|
||||
<summary>
|
||||
Predicted vs actual sheet
|
||||
{drifted.length > 0 && (
|
||||
<span className="warning"> — {drifted.length} disagree</span>
|
||||
)}
|
||||
</summary>
|
||||
<p className="dim">
|
||||
Read these with the character <strong>off the active team</strong>. On field an Arc's
|
||||
conditional effects are folded into the numbers and cannot be separated from gear.
|
||||
</p>
|
||||
<table className="items">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Stat</th>
|
||||
<th>Predicted</th>
|
||||
<th>In game</th>
|
||||
<th>Difference</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sheet.map((line) => (
|
||||
<tr key={line.key} className={line.drifted ? "row--drifted" : ""}>
|
||||
<td>{line.label}</td>
|
||||
<td>
|
||||
{line.predicted === null ? (
|
||||
<span className="warning" title={line.unavailable}>
|
||||
unavailable
|
||||
</span>
|
||||
) : line.percent ? (
|
||||
`${(line.predicted * 100).toFixed(2)}%`
|
||||
) : (
|
||||
line.predicted.toFixed(0)
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={draft[line.key] ?? ""}
|
||||
placeholder={line.percent ? "0.00" : "—"}
|
||||
onChange={(event) => setDraft({ ...draft, [line.key]: event.target.value })}
|
||||
/>
|
||||
</td>
|
||||
<td className={line.drifted ? "warning" : "dim"}>
|
||||
{line.delta === null
|
||||
? "—"
|
||||
: line.percent
|
||||
? `${(line.delta * 100).toFixed(2)}%`
|
||||
: line.delta.toFixed(1)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="actions">
|
||||
<button
|
||||
onClick={() => {
|
||||
const next: Record<string, number> = {};
|
||||
for (const [key, value] of Object.entries(draft)) {
|
||||
const parsed = Number(value);
|
||||
if (value.trim() !== "" && Number.isFinite(parsed)) next[key] = parsed;
|
||||
}
|
||||
onSave(Object.keys(next).length > 0 ? next : null);
|
||||
}}
|
||||
>
|
||||
Save readings
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setDraft({});
|
||||
onSave(null);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
{drifted.length > 0 && (
|
||||
<p className="warning">
|
||||
The model disagrees with the game on {drifted.map((line) => line.label).join(", ")}.
|
||||
That is a bug in the stat model, not in your gear — worth recording before trusting a
|
||||
build.
|
||||
</p>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
/** Published targets, rendered read-only: they are a citation, not a setting. */
|
||||
function ReadOnlyTargets({
|
||||
targets,
|
||||
source,
|
||||
updated,
|
||||
}: {
|
||||
targets: StatTarget[];
|
||||
source: string;
|
||||
updated: string;
|
||||
}) {
|
||||
return (
|
||||
<details className="targets" open>
|
||||
<summary>
|
||||
Targets and weights <span className="dim">from {source}, {updated}</span>
|
||||
</summary>
|
||||
<p className="dim">
|
||||
Weights come from the published substat ranking, so a shortfall lands on the
|
||||
lowest-ranked stats. Switch to Custom to change any of it.
|
||||
</p>
|
||||
<table className="items">
|
||||
<tbody>
|
||||
{targets.map((entry) => (
|
||||
<tr key={entry.stat}>
|
||||
<td>{entry.stat}</td>
|
||||
<td>{entry.target}</td>
|
||||
<td className="dim">weight {entry.weight.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
111
src/ui/CharactersTab.tsx
Normal file
111
src/ui/CharactersTab.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/**
|
||||
* Characters, and one character's optimizer view.
|
||||
*
|
||||
* The list is every character the capture saw. Seven codenames have no everness
|
||||
* record at all (`Chiichan073`, `Female051`, `Jin`, `Kuhara`, `Mint019`,
|
||||
* `Mitsuki`, `Oneiroi`), so the card has to work without artwork, a grid or stat
|
||||
* curves rather than assuming they exist.
|
||||
*/
|
||||
import { esperFor, type LoadedData } from "../state/gamedata.ts";
|
||||
import { CharacterView } from "./CharacterView.tsx";
|
||||
import { Icon } from "./Icon.tsx";
|
||||
import { equipmentOf } from "../db/store.ts";
|
||||
import { hashFor, type Route } from "./router.ts";
|
||||
import { useAppState, useStore } from "./useStore.ts";
|
||||
|
||||
export function CharactersTab({
|
||||
data,
|
||||
route,
|
||||
unnamedGroups,
|
||||
}: {
|
||||
data: LoadedData;
|
||||
route: Route;
|
||||
unnamedGroups: string[];
|
||||
}) {
|
||||
const state = useAppState();
|
||||
const db = state.data.db;
|
||||
|
||||
if (route.id) return <CharacterView data={data} characterId={route.id} />;
|
||||
|
||||
return (
|
||||
<section>
|
||||
{unnamedGroups.length > 0 && (
|
||||
<UnidentifiedGroups groups={unnamedGroups} data={data} />
|
||||
)}
|
||||
<div className="cards">
|
||||
{db.characters.map((character) => {
|
||||
const esper = esperFor(data.gamedata, character.characterId);
|
||||
return (
|
||||
<a
|
||||
key={character.characterId}
|
||||
className="card"
|
||||
href={hashFor("characters", character.characterId)}
|
||||
>
|
||||
<span className="card-head">
|
||||
<Icon entry={`esper:${character.characterId}`} alt={esper?.name ?? character.characterId} />
|
||||
<strong>{esper?.name ?? character.characterId}</strong>
|
||||
</span>
|
||||
<span className="dim">
|
||||
{esper ? esper.element : "no game data"} · level {character.level ?? "?"} ·{" "}
|
||||
{character.breakthroughs ?? "?"} breakthroughs
|
||||
</span>
|
||||
<span className="dim">
|
||||
{equipmentOf(db, character.characterId).length} pieces equipped
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The owner groups nobody has named.
|
||||
*
|
||||
* Seven of thirteen cannot be matched from the capture at all - three sets of
|
||||
* characters share both their module shapes and their cartridge set - so the
|
||||
* player names them once, keyed on values that are stable across exports.
|
||||
*/
|
||||
function UnidentifiedGroups({ groups, data }: { groups: string[]; data: LoadedData }) {
|
||||
const store = useStore();
|
||||
const state = useAppState();
|
||||
const db = state.data.db;
|
||||
|
||||
return (
|
||||
<div className="unidentified">
|
||||
<h2>Unidentified groups</h2>
|
||||
<p className="dim">
|
||||
The capture says these items are worn together, but not by whom. Name each group once
|
||||
and it sticks.
|
||||
</p>
|
||||
{groups.map((group) => {
|
||||
const worn = db.equipment.filter((row) => row.ownerGroup === group);
|
||||
const items = worn
|
||||
.map((row) => db.items.find((item) => item.instance === row.instance))
|
||||
.filter((item) => item !== undefined);
|
||||
return (
|
||||
<div key={group} className="group">
|
||||
<code>{group.slice(0, 12)}</code>
|
||||
<span className="dim">
|
||||
{items.map((item) => item.shape ?? item.set).join(", ")}
|
||||
</span>
|
||||
<select
|
||||
defaultValue=""
|
||||
onChange={(event) => {
|
||||
if (event.target.value) void store.nameGroup(group, event.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="">name this group…</option>
|
||||
{data.gamedata.espers.map((esper) => (
|
||||
<option key={esper.abilityKey} value={esper.abilityKey}>
|
||||
{esper.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
196
src/ui/DataPanel.tsx
Normal file
196
src/ui/DataPanel.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
/**
|
||||
* Import and export.
|
||||
*
|
||||
* Two separate things, labelled separately because they are not the same and
|
||||
* confusing them loses work:
|
||||
*
|
||||
* - **Import gear capture** replaces what you own, from the game.
|
||||
* - **Import database** replaces your whole workspace, from a file this app
|
||||
* wrote - including everything you configured by hand.
|
||||
*
|
||||
* A capture import is validated first and shown as a diff before it is applied,
|
||||
* because it is deliberate and irreversible. `Restore pre-import state` is the
|
||||
* way back, and is deliberately not called undo: it is one snapshot, not a
|
||||
* history.
|
||||
*/
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { fileName } from "../db/dbfile.ts";
|
||||
import type { ImportResult } from "../db/import.ts";
|
||||
import { useAppState, useStore } from "./useStore.ts";
|
||||
|
||||
async function readJson(file: File): Promise<unknown> {
|
||||
return JSON.parse(await file.text());
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand the browser a file.
|
||||
*
|
||||
* A blob URL and a click, revoked afterwards - the same call works in the
|
||||
* hosted build and is the only save path the `file://` build has at all, since
|
||||
* it cannot reach IndexedDB.
|
||||
*/
|
||||
function download(name: string, contents: string): void {
|
||||
const url = URL.createObjectURL(new Blob([contents], { type: "application/json" }));
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = name;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function DataPanel({ gamedataVersion }: { gamedataVersion: string | null }) {
|
||||
const store = useStore();
|
||||
const state = useAppState();
|
||||
const [pending, setPending] = useState<ImportResult | null>(null);
|
||||
const [problems, setProblems] = useState<string[]>([]);
|
||||
const [note, setNote] = useState<string | null>(null);
|
||||
const gearInput = useRef<HTMLInputElement>(null);
|
||||
const dbInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const reviewGear = async (file: File) => {
|
||||
setProblems([]);
|
||||
setNote(null);
|
||||
try {
|
||||
setPending(store.reviewImport(await readJson(file)));
|
||||
} catch (error) {
|
||||
setProblems([error instanceof Error ? error.message : String(error)]);
|
||||
}
|
||||
};
|
||||
|
||||
const apply = async () => {
|
||||
if (!pending?.ok) return;
|
||||
await store.applyReviewedImport(pending);
|
||||
setPending(null);
|
||||
setNote("Gear imported.");
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="data">
|
||||
<h2>Data</h2>
|
||||
|
||||
<div className="actions">
|
||||
<button onClick={() => gearInput.current?.click()}>Import gear capture</button>
|
||||
<input
|
||||
ref={gearInput}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
hidden
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) void reviewGear(file);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() =>
|
||||
download(fileName(), JSON.stringify(store.exportFile(gamedataVersion)))
|
||||
}
|
||||
>
|
||||
Export database
|
||||
</button>
|
||||
<button onClick={() => dbInput.current?.click()}>Import database</button>
|
||||
<input
|
||||
ref={dbInput}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
hidden
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
void (async () => {
|
||||
setNote(null);
|
||||
try {
|
||||
const found = await store.importFile(await readJson(file));
|
||||
setProblems(found);
|
||||
if (found.length === 0) setNote("Database restored.");
|
||||
} catch (error) {
|
||||
setProblems([error instanceof Error ? error.message : String(error)]);
|
||||
}
|
||||
})();
|
||||
}
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
|
||||
{state.data.snapshot && (
|
||||
<button
|
||||
onClick={() => {
|
||||
void store.restorePreImport().then((done) => {
|
||||
setNote(done ? "Restored the state from before the last import." : null);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Restore pre-import state
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{note && <p className="dim">{note}</p>}
|
||||
{problems.length > 0 && (
|
||||
<ul className="problem">
|
||||
{problems.map((problem) => (
|
||||
<li key={problem}>{problem}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{pending && <ImportReview result={pending} onApply={apply} onCancel={() => setPending(null)} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportReview({
|
||||
result,
|
||||
onApply,
|
||||
onCancel,
|
||||
}: {
|
||||
result: ImportResult;
|
||||
onApply: () => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { report } = result;
|
||||
return (
|
||||
<div className="review">
|
||||
<p>
|
||||
<strong>
|
||||
{report.expected ?? "?"} expected, {report.parsed} parsed, {report.rejected} rejected
|
||||
</strong>
|
||||
</p>
|
||||
|
||||
{result.ok ? (
|
||||
<>
|
||||
<p className="warning">
|
||||
Importing replaces every item and every equipped loadout. Anything you equipped in
|
||||
the app but have not applied in game will be lost. Your Arcs, targets, priorities and
|
||||
group names are kept.
|
||||
</p>
|
||||
<div className="actions">
|
||||
<button onClick={() => void onApply()}>Import</button>
|
||||
<button onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Fail closed: nothing at all is written when anything is wrong. */}
|
||||
<p className="problem">Nothing was imported.</p>
|
||||
<ul className="problem">
|
||||
{report.problems.slice(0, 20).map((problem, index) => (
|
||||
<li key={`${problem.code}-${index}`}>
|
||||
{problem.code}: {problem.detail}
|
||||
{problem.subject ? ` (${problem.subject})` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{report.problems.length > 20 && (
|
||||
<p className="dim">…and {report.problems.length - 20} more.</p>
|
||||
)}
|
||||
<div className="actions">
|
||||
<button onClick={onCancel}>Close</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
src/ui/Icon.tsx
Normal file
51
src/ui/Icon.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Character and Arc artwork.
|
||||
*
|
||||
* Files are fetched at build time into `public/img/` and referenced by their
|
||||
* hashed name - everness is never contacted at runtime.
|
||||
*
|
||||
* The single-file `file://` build carries no images, and seven character
|
||||
* codenames have no everness record and so no icon at all. Both cases end up
|
||||
* here, and both fall back to the name rather than a broken-image box: the name
|
||||
* is the information that actually identifies the character.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
|
||||
import icons from "../generated/icons.json";
|
||||
|
||||
const FILES = (icons as { files: Record<string, string> }).files;
|
||||
|
||||
export function iconFile(key: string): string | null {
|
||||
return FILES[key] ?? null;
|
||||
}
|
||||
|
||||
export function Icon({
|
||||
entry,
|
||||
alt,
|
||||
size = 44,
|
||||
}: {
|
||||
entry: string;
|
||||
alt: string;
|
||||
size?: number;
|
||||
}) {
|
||||
const [broken, setBroken] = useState(false);
|
||||
const file = iconFile(entry);
|
||||
if (!file || broken) {
|
||||
return (
|
||||
<span className="icon icon--fallback" style={{ width: size, height: size }}>
|
||||
{alt.slice(0, 2)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<img
|
||||
className="icon"
|
||||
src={`./img/${file}`}
|
||||
alt={alt}
|
||||
width={size}
|
||||
height={size}
|
||||
loading="lazy"
|
||||
onError={() => setBroken(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
44
src/ui/ItemCard.tsx
Normal file
44
src/ui/ItemCard.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* Everything needed to find one module in the game's own inventory.
|
||||
*
|
||||
* Instance ids mean nothing to a person, so identification is by the properties
|
||||
* visible on the item in game: shape, type, level, rarity, main stat and all
|
||||
* four substats. That is the whole point of the visual result - read the block,
|
||||
* find the matching module in your list, drop it in the cell shown.
|
||||
*/
|
||||
import type { ItemRow } from "../db/schema.ts";
|
||||
import type { Database } from "../db/schema.ts";
|
||||
import { resolveCharacter } from "../db/store.ts";
|
||||
|
||||
export function typeName(cells: number | null): string | null {
|
||||
if (cells === 2) return "II";
|
||||
if (cells === 3) return "III";
|
||||
if (cells === 4) return "IV";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ItemCard({ item, db }: { item: ItemRow | undefined; db: Database }) {
|
||||
if (!item) return null;
|
||||
const row = db.equipment.find((entry) => entry.instance === item.instance);
|
||||
const owner = row ? resolveCharacter(row, db.ownerNames) : null;
|
||||
const type = typeName(item.cells);
|
||||
return (
|
||||
<div className="tooltip">
|
||||
<strong>
|
||||
{item.shape ?? item.set}
|
||||
{type ? ` · Type ${type}` : ""} · +{item.level} · {item.rarity}
|
||||
</strong>
|
||||
<div>{item.mainStats.map((stat) => stat.stat).join(", ") || "—"}</div>
|
||||
<ul>
|
||||
{item.substats.map((stat) => (
|
||||
<li key={stat.stat}>
|
||||
{stat.stat} {stat.value}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{/* R2's toggle makes this the difference between "free" and "you would be
|
||||
taking it off someone". */}
|
||||
{owner && <div className="dim">Equipped by: {owner}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
231
src/ui/ItemTable.tsx
Normal file
231
src/ui/ItemTable.tsx
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
/**
|
||||
* The Cartridges and Modules tabs.
|
||||
*
|
||||
* One component, because the two differ only in which columns distinguish an
|
||||
* item. 817 rows is `.filter().sort()` on an array - a table library would earn
|
||||
* nothing here and would be one more thing to still work in three years.
|
||||
*
|
||||
* Filters combine on purpose: "Crimson with a CRIT DMG main and a Cycle sub" is
|
||||
* the question a player actually asks.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { useStore } from "./useStore.ts";
|
||||
|
||||
import { STAT_SLOTS } from "../domain/statvec.ts";
|
||||
import type { ItemRow } from "../db/schema.ts";
|
||||
|
||||
export interface ItemTableProps {
|
||||
kind: "cartridge" | "module";
|
||||
items: readonly ItemRow[];
|
||||
/** Owner group per instance, when the item is worn. */
|
||||
ownerOf: (instance: string) => string | null;
|
||||
}
|
||||
|
||||
type SortKey = "itemId" | "level" | "set" | "shape";
|
||||
|
||||
export function ItemTable({ kind, items, ownerOf }: ItemTableProps) {
|
||||
const [group, setGroup] = useState("");
|
||||
const [main, setMain] = useState("");
|
||||
const [sub, setSub] = useState("");
|
||||
const [minLevel, setMinLevel] = useState(0);
|
||||
const [sort, setSort] = useState<SortKey>(kind === "cartridge" ? "set" : "shape");
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const filtered = items.filter((item) => {
|
||||
if (item.kind !== kind) return false;
|
||||
if (group && (kind === "cartridge" ? item.set : item.shape) !== group) return false;
|
||||
if (main && !item.mainStats.some((stat) => stat.stat === main)) return false;
|
||||
if (sub && !item.substats.some((stat) => stat.stat === sub)) return false;
|
||||
if (item.level < minLevel) return false;
|
||||
return true;
|
||||
});
|
||||
return [...filtered].sort((a, b) => {
|
||||
if (sort === "level") return b.level - a.level;
|
||||
const left = String(a[sort] ?? "");
|
||||
const right = String(b[sort] ?? "");
|
||||
return left.localeCompare(right) || a.itemId.localeCompare(b.itemId);
|
||||
});
|
||||
}, [items, kind, group, main, sub, minLevel, sort]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
for (const item of items) {
|
||||
if (item.kind !== kind) continue;
|
||||
const value = kind === "cartridge" ? item.set : item.shape;
|
||||
if (value) seen.add(value);
|
||||
}
|
||||
return [...seen].sort();
|
||||
}, [items, kind]);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="filters">
|
||||
<label>
|
||||
{kind === "cartridge" ? "Set" : "Shape"}
|
||||
<select value={group} onChange={(event) => setGroup(event.target.value)}>
|
||||
<option value="">any</option>
|
||||
{groups.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Main
|
||||
<select value={main} onChange={(event) => setMain(event.target.value)}>
|
||||
<option value="">any</option>
|
||||
{STAT_SLOTS.map((stat) => (
|
||||
<option key={stat} value={stat}>
|
||||
{stat}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Substat
|
||||
<select value={sub} onChange={(event) => setSub(event.target.value)}>
|
||||
<option value="">any</option>
|
||||
{STAT_SLOTS.map((stat) => (
|
||||
<option key={stat} value={stat}>
|
||||
{stat}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Min level
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={20}
|
||||
value={minLevel}
|
||||
onChange={(event) => setMinLevel(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Sort
|
||||
<select value={sort} onChange={(event) => setSort(event.target.value as SortKey)}>
|
||||
{kind === "cartridge" ? <option value="set">set</option> : <option value="shape">shape</option>}
|
||||
<option value="level">level</option>
|
||||
<option value="itemId">id</option>
|
||||
</select>
|
||||
</label>
|
||||
<span className="count">
|
||||
{rows.length} of {items.filter((item) => item.kind === kind).length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<table className="items">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{kind === "cartridge" ? "Set" : "Shape"}</th>
|
||||
{kind === "module" && <th>Type</th>}
|
||||
<th>Level</th>
|
||||
<th>Main</th>
|
||||
<th>Substats</th>
|
||||
<th>Worn by</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((item) => (
|
||||
<tr
|
||||
key={item.instance}
|
||||
className={editing === item.instance ? "row--editing" : "row--clickable"}
|
||||
onClick={() => setEditing(editing === item.instance ? null : item.instance)}
|
||||
>
|
||||
<td>{kind === "cartridge" ? item.set : item.shape}</td>
|
||||
{kind === "module" && <td>{item.cells === 2 ? "II" : item.cells === 3 ? "III" : "IV"}</td>}
|
||||
{/* Level is not a filter on purpose: substats are identical at +0
|
||||
and +20, so an unlevelled item is a valid recommendation. */}
|
||||
<td>+{item.level}</td>
|
||||
<td>
|
||||
{item.mainStats.map((stat) => stat.stat).join(", ") || <span className="dim">—</span>}
|
||||
</td>
|
||||
<td className="subs">
|
||||
{item.substats.map((stat) => `${stat.stat} ${stat.value}`).join(" · ")}
|
||||
</td>
|
||||
<td>{ownerOf(item.instance) ?? <span className="dim">—</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
{editing && rows.some((item) => item.instance === editing) && (
|
||||
<tr className="editor-row">
|
||||
<td colSpan={kind === "module" ? 6 : 5}>
|
||||
<ItemEditor
|
||||
item={rows.find((item) => item.instance === editing)!}
|
||||
onDone={() => setEditing(null)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Correcting a decoded item by hand.
|
||||
*
|
||||
* Only the values a decode can plausibly get wrong are editable - level and the
|
||||
* substat numbers. Shape, kind and instance are structural: if those are wrong
|
||||
* the record was misparsed entirely and editing it would paper over a bug worth
|
||||
* reporting instead.
|
||||
*/
|
||||
function ItemEditor({
|
||||
item,
|
||||
onDone,
|
||||
}: {
|
||||
item: ItemRow;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const store = useStore();
|
||||
const [level, setLevel] = useState(item.level);
|
||||
const [substats, setSubstats] = useState(item.substats);
|
||||
|
||||
return (
|
||||
<div className="editor" onClick={(event) => event.stopPropagation()}>
|
||||
<p className="dim">
|
||||
Corrections are overwritten by the next gear import — the import is the game speaking.
|
||||
</p>
|
||||
<label>
|
||||
Level
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={20}
|
||||
value={level}
|
||||
onChange={(event) => setLevel(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
{substats.map((stat, index) => (
|
||||
<label key={stat.stat}>
|
||||
{stat.stat}
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={stat.value}
|
||||
onChange={(event) => {
|
||||
const next = [...substats];
|
||||
next[index] = { ...stat, value: Number(event.target.value) };
|
||||
setSubstats(next);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<div className="actions">
|
||||
<button
|
||||
onClick={() => {
|
||||
void store.editItem(item.instance, { level, substats });
|
||||
onDone();
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button onClick={onDone}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
328
src/ui/TeamTab.tsx
Normal file
328
src/ui/TeamTab.tsx
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
/**
|
||||
* The team tab.
|
||||
*
|
||||
* Four characters, dragged into a priority order, solved together against the
|
||||
* shared pool by one button. The drag order is the **tie-break** the solver
|
||||
* applies once the sorted score vector is already fixed - it is not a sequential
|
||||
* pass, and the whole point of the team solve is that the first character does
|
||||
* not simply take everything.
|
||||
*
|
||||
* Drag and drop is four native HTML5 events; a drag library would be a fifth
|
||||
* dependency for that.
|
||||
*/
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { isKnownStat, SLOT_COUNT, type StatId } from "../domain/statvec.ts";
|
||||
import type { StatTarget } from "../domain/scoring.ts";
|
||||
import { consoleTrait } from "../domain/stats.ts";
|
||||
import { createSolverHost, CancelledError, type SolveHandle } from "../solver/host.ts";
|
||||
import type { JobProgress, JobResult, SolveJob } from "../solver/job.ts";
|
||||
import type { TeamAssignment } from "../solver/team.ts";
|
||||
import { heldByOthers } from "../db/store.ts";
|
||||
import { esperFor, type LoadedData } from "../state/gamedata.ts";
|
||||
import { Board } from "./Board.tsx";
|
||||
import { Icon } from "./Icon.tsx";
|
||||
import { ItemCard } from "./ItemCard.tsx";
|
||||
import { useAppState, useStore } from "./useStore.ts";
|
||||
|
||||
const TEAM_SIZE = 4;
|
||||
|
||||
/**
|
||||
* Custom targets are stored with a plain string stat, because the store must
|
||||
* survive a slot being added later. Anything the model has no slot for is
|
||||
* dropped here rather than being allowed to reach the solver.
|
||||
*/
|
||||
function targetsFor(
|
||||
custom: Array<{ stat: string; target: number; weight: number }> | null | undefined,
|
||||
): StatTarget[] {
|
||||
if (custom && custom.length > 0) {
|
||||
return custom
|
||||
.filter((entry) => isKnownStat(entry.stat))
|
||||
.map((entry) => ({
|
||||
stat: entry.stat as StatId,
|
||||
target: entry.target,
|
||||
weight: entry.weight,
|
||||
}));
|
||||
}
|
||||
return [
|
||||
{ stat: "CritBase", target: 0.7, weight: 3 },
|
||||
{ stat: "CritDamageBase", target: 2.0, weight: 3 },
|
||||
];
|
||||
}
|
||||
|
||||
export function TeamTab({ data }: { data: LoadedData }) {
|
||||
const state = useAppState();
|
||||
const store = useStore();
|
||||
const db = state.data.db;
|
||||
|
||||
const [team, setTeam] = useState<string[]>([]);
|
||||
const [progress, setProgress] = useState<JobProgress | null>(null);
|
||||
const [result, setResult] = useState<JobResult | null>(null);
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
const handle = useRef<SolveHandle | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const dragged = useRef<number | null>(null);
|
||||
// R2, for the team: items worn by anyone outside the team.
|
||||
const [useEquipped, setUseEquipped] = useState(false);
|
||||
|
||||
const solve = async () => {
|
||||
setProblem(null);
|
||||
setResult(null);
|
||||
setRunning(true);
|
||||
|
||||
const job: SolveJob = {
|
||||
items: db.items.map((item) => ({
|
||||
instance: item.instance,
|
||||
kind: item.kind,
|
||||
item_id: item.itemId,
|
||||
shape: item.shape ?? undefined,
|
||||
set: item.set ?? undefined,
|
||||
level: item.level,
|
||||
rarity: item.rarity,
|
||||
main_stats: item.mainStats,
|
||||
substats: item.substats,
|
||||
owner_group: null,
|
||||
})),
|
||||
setBonuses: data.setBonuses,
|
||||
tilings: data.tilings,
|
||||
dragOrder: team,
|
||||
characters: team.flatMap((characterId) => {
|
||||
const esper = esperFor(data.gamedata, characterId);
|
||||
if (!esper) return [];
|
||||
const stored = db.characters.find((row) => row.characterId === characterId);
|
||||
return [
|
||||
{
|
||||
key: characterId,
|
||||
slots: esper.slots,
|
||||
trait: consoleTrait(esper),
|
||||
base: new Array(SLOT_COUNT).fill(0),
|
||||
scoring: { targets: targetsFor(stored?.customTargets) },
|
||||
excludedInstances: useEquipped ? [] : heldByOthers(db, team),
|
||||
},
|
||||
];
|
||||
}),
|
||||
};
|
||||
|
||||
const host = createSolverHost();
|
||||
const started = host.solve(job, setProgress);
|
||||
handle.current = started;
|
||||
try {
|
||||
setResult(await started.promise);
|
||||
} catch (error) {
|
||||
if (!(error instanceof CancelledError)) {
|
||||
setProblem(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
} finally {
|
||||
setRunning(false);
|
||||
host.dispose();
|
||||
}
|
||||
};
|
||||
|
||||
const reorder = (from: number, to: number) => {
|
||||
setTeam((current) => {
|
||||
const next = [...current];
|
||||
const [moved] = next.splice(from, 1);
|
||||
if (moved) next.splice(to, 0, moved);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2>Team</h2>
|
||||
<p className="dim">
|
||||
Drag to set priority. Order breaks ties only — it never decides who gets an item.
|
||||
</p>
|
||||
|
||||
<ol className="team">
|
||||
{team.map((characterId, index) => (
|
||||
<li
|
||||
key={characterId}
|
||||
draggable
|
||||
onDragStart={() => (dragged.current = index)}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={() => {
|
||||
if (dragged.current !== null) reorder(dragged.current, index);
|
||||
dragged.current = null;
|
||||
}}
|
||||
>
|
||||
{esperFor(data.gamedata, characterId)?.name ?? characterId}
|
||||
<button onClick={() => setTeam(team.filter((key) => key !== characterId))}>
|
||||
remove
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{team.length < TEAM_SIZE && (
|
||||
<select
|
||||
value=""
|
||||
onChange={(event) => {
|
||||
if (event.target.value) setTeam([...team, event.target.value]);
|
||||
}}
|
||||
>
|
||||
<option value="">add a character…</option>
|
||||
{db.characters
|
||||
.filter((row) => !team.includes(row.characterId))
|
||||
.map((row) => (
|
||||
<option key={row.characterId} value={row.characterId}>
|
||||
{esperFor(data.gamedata, row.characterId)?.name ?? row.characterId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<label className="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useEquipped}
|
||||
onChange={(event) => setUseEquipped(event.target.checked)}
|
||||
/>
|
||||
Use items characters outside the team are wearing
|
||||
</label>
|
||||
|
||||
<div className="actions">
|
||||
<button disabled={team.length === 0 || running} onClick={() => void solve()}>
|
||||
Optimize team
|
||||
</button>
|
||||
{/* Stop always yields something usable: the solve is anytime. */}
|
||||
<button disabled={!running} onClick={() => handle.current?.cancel()}>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{running && progress && <Progress progress={progress} />}
|
||||
{problem && <p className="problem">{problem}</p>}
|
||||
{result && <Result result={result} data={data} store={store} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Progress({ progress }: { progress: JobProgress }) {
|
||||
if (progress.phase === "portfolio") {
|
||||
return (
|
||||
<p className="dim">
|
||||
{progress.character}: {progress.done} / {progress.total} packings
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p className="dim">
|
||||
round {progress.round} / {progress.rounds} — [
|
||||
{progress.sorted.map((value) => value.toFixed(3)).join(", ")}]
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function Result({
|
||||
result,
|
||||
data,
|
||||
store,
|
||||
}: {
|
||||
result: JobResult;
|
||||
data: LoadedData;
|
||||
store: ReturnType<typeof useStore>;
|
||||
}) {
|
||||
if (result.infeasible) {
|
||||
return <p className="warning">No conflict-free team could be built from this pool.</p>;
|
||||
}
|
||||
return (
|
||||
<div className="result">
|
||||
<p>
|
||||
Scores, worst first: [{result.sorted.map((value) => value.toFixed(4)).join(", ")}]
|
||||
</p>
|
||||
{result.unbuildable.length > 0 && (
|
||||
<p className="warning">
|
||||
No valid full-set build for: {result.unbuildable.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
<div className="boards">
|
||||
{result.assignment.map((entry) => (
|
||||
<AssignedBuild key={entry.key} entry={entry} data={data} />
|
||||
))}
|
||||
</div>
|
||||
<p className="dim">
|
||||
Equipping writes an “app” row you still have to apply in game.{" "}
|
||||
<button onClick={() => void store.undo()}>Undo last equip</button>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One character's share of a team solve, drawn.
|
||||
*
|
||||
* The board is the deliverable, not the score: the player reads a block, finds
|
||||
* that module in their inventory by its stats, and puts it in the cell shown.
|
||||
*/
|
||||
function AssignedBuild({
|
||||
entry,
|
||||
data,
|
||||
}: {
|
||||
entry: TeamAssignment;
|
||||
data: LoadedData;
|
||||
}) {
|
||||
const state = useAppState();
|
||||
const db = state.data.db;
|
||||
const [hovered, setHovered] = useState<number | null>(null);
|
||||
const store = useStore();
|
||||
const build = entry.build;
|
||||
|
||||
const itemFor = (piece: number) => {
|
||||
const index = build.modules[piece];
|
||||
return index === undefined ? undefined : db.items[index];
|
||||
};
|
||||
|
||||
const equip = () => {
|
||||
const instances = [build.cartridge, ...build.modules]
|
||||
.map((index) => db.items[index]?.instance)
|
||||
.filter((instance) => instance !== undefined);
|
||||
const cells: Record<string, number[]> = {};
|
||||
build.modules.forEach((index, piece) => {
|
||||
const instance = db.items[index]?.instance;
|
||||
if (!instance) return;
|
||||
cells[instance] = build.tiling.cells.filter(
|
||||
(_cell, position) => build.tiling.placement[position] === piece,
|
||||
);
|
||||
});
|
||||
void store.equip(entry.key, instances, { cells });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="assignment">
|
||||
<div className="card-head">
|
||||
<Icon entry={`esper:${entry.key}`} alt={entry.key} size={36} />
|
||||
<strong>{esperFor(data.gamedata, entry.key)?.name ?? entry.key}</strong>
|
||||
<span className="dim">
|
||||
{build.score.toFixed(4)} · {build.modules.length} modules ·{" "}
|
||||
{build.proven ? "optimal for this packing and cartridge" : "best found"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{build.unknownTiers.length > 0 && (
|
||||
<p className="warning">
|
||||
Set bonus tier {build.unknownTiers.join(" and ")} is active but unmeasured, so it
|
||||
contributes nothing to this score.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Board
|
||||
cells={build.tiling.cells}
|
||||
placement={build.tiling.placement}
|
||||
pieces={build.tiling.pieces.length}
|
||||
size={26}
|
||||
onHover={setHovered}
|
||||
labelFor={(piece) => {
|
||||
const item = itemFor(piece);
|
||||
return item ? `${item.shape ?? item.set} +${item.level}` : "";
|
||||
}}
|
||||
/>
|
||||
|
||||
{hovered !== null && hovered >= 0 && <ItemCard item={itemFor(hovered)} db={db} />}
|
||||
|
||||
<div className="actions">
|
||||
<button onClick={equip}>Equip on {entry.key}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
src/ui/router.ts
Normal file
43
src/ui/router.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* Hash routing.
|
||||
*
|
||||
* About forty lines instead of a router dependency, and it is what makes the
|
||||
* single-file `file://` build work without a second code path - a history
|
||||
* router would need a server rewrite that a local file has no way to provide.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const TABS = ["cartridges", "modules", "characters", "team"] as const;
|
||||
export type Tab = (typeof TABS)[number];
|
||||
|
||||
export interface Route {
|
||||
tab: Tab;
|
||||
/** The character a detail view is open on, if any. */
|
||||
id: string | null;
|
||||
}
|
||||
|
||||
export function parseHash(hash: string): Route {
|
||||
const parts = hash.replace(/^#\/?/, "").split("/").filter(Boolean);
|
||||
const tab = (TABS as readonly string[]).includes(parts[0] ?? "")
|
||||
? (parts[0] as Tab)
|
||||
: "characters";
|
||||
return { tab, id: parts[1] ? decodeURIComponent(parts[1]) : null };
|
||||
}
|
||||
|
||||
export function hashFor(tab: Tab, id?: string | null): string {
|
||||
return id ? `#/${tab}/${encodeURIComponent(id)}` : `#/${tab}`;
|
||||
}
|
||||
|
||||
export function navigate(tab: Tab, id?: string | null): void {
|
||||
window.location.hash = hashFor(tab, id);
|
||||
}
|
||||
|
||||
export function useRoute(): Route {
|
||||
const [route, setRoute] = useState<Route>(() => parseHash(window.location.hash));
|
||||
useEffect(() => {
|
||||
const onChange = () => setRoute(parseHash(window.location.hash));
|
||||
window.addEventListener("hashchange", onChange);
|
||||
return () => window.removeEventListener("hashchange", onChange);
|
||||
}, []);
|
||||
return route;
|
||||
}
|
||||
166
src/ui/styles.css
Normal file
166
src/ui/styles.css
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #14161a;
|
||||
--panel: #1c1f26;
|
||||
--line: #2c313b;
|
||||
--text: #e6e8ec;
|
||||
--dim: #9aa2b1;
|
||||
--accent: #7aa2f7;
|
||||
--warn: #e0af68;
|
||||
--bad: #f7768e;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.app { max-width: 1100px; margin: 0 auto; padding: 1.5rem 1rem 4rem; }
|
||||
|
||||
header { display: flex; align-items: baseline; gap: 1.5rem; flex-wrap: wrap; }
|
||||
h1 { font-size: 1.1rem; margin: 0 0 1rem; letter-spacing: 0.02em; }
|
||||
nav { display: flex; gap: 0.25rem; }
|
||||
nav a {
|
||||
color: var(--dim);
|
||||
text-decoration: none;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: 6px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
nav a:hover { background: var(--panel); color: var(--text); }
|
||||
nav a.active { background: var(--panel); color: var(--accent); }
|
||||
|
||||
.dim { color: var(--dim); }
|
||||
.warning { color: var(--warn); }
|
||||
.problem { color: var(--bad); }
|
||||
.empty { color: var(--dim); padding: 2rem 0; }
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
padding: 0.75rem 0 1rem;
|
||||
}
|
||||
.filters label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.8rem; color: var(--dim); }
|
||||
.filters .count { margin-left: auto; font-size: 0.8rem; color: var(--dim); }
|
||||
|
||||
select, input, button {
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
button { cursor: pointer; }
|
||||
button:disabled { opacity: 0.45; cursor: default; }
|
||||
|
||||
table.items { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
table.items th {
|
||||
text-align: left;
|
||||
color: var(--dim);
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 0.4rem 0.5rem;
|
||||
}
|
||||
table.items td { padding: 0.35rem 0.5rem; border-bottom: 1px solid #23262e; }
|
||||
table.items .subs { color: var(--dim); font-size: 0.78rem; }
|
||||
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 0.6rem; }
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
padding: 0.7rem 0.8rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.card:hover { border-color: var(--accent); }
|
||||
|
||||
.board { display: grid; gap: 0; margin: 1rem 0; width: max-content; }
|
||||
.board-cell {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-style: solid;
|
||||
border-color: #0d0f13;
|
||||
border-width: 0;
|
||||
}
|
||||
.board-cell--blocked { background: #0f1115; border: 1px solid #191c22; }
|
||||
|
||||
.tooltip {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.8rem;
|
||||
font-size: 0.82rem;
|
||||
width: max-content;
|
||||
}
|
||||
.tooltip ul { margin: 0.3rem 0 0; padding-left: 1.1rem; color: var(--dim); }
|
||||
|
||||
.team { list-style: decimal; padding-left: 1.4rem; }
|
||||
.team li { padding: 0.3rem 0; display: flex; gap: 0.6rem; align-items: center; cursor: grab; }
|
||||
.actions { display: flex; gap: 0.5rem; margin: 1rem 0; }
|
||||
|
||||
.assignment { padding: 0.35rem 0; border-bottom: 1px solid #23262e; font-size: 0.9rem; }
|
||||
.unidentified { margin-bottom: 1.5rem; }
|
||||
.group { display: flex; gap: 0.8rem; align-items: center; padding: 0.3rem 0; font-size: 0.85rem; }
|
||||
.todo { margin: 0.5rem 0 1rem; color: var(--dim); font-size: 0.85rem; }
|
||||
|
||||
.data { margin: 1rem 0 2rem; padding: 0.9rem 1rem; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
|
||||
.data h2 { font-size: 0.9rem; margin: 0 0 0.6rem; color: var(--dim); font-weight: 500; }
|
||||
.review { margin-top: 0.8rem; padding: 0.8rem; border: 1px solid var(--line); border-radius: 6px; font-size: 0.85rem; }
|
||||
.review ul { margin: 0.4rem 0; padding-left: 1.1rem; font-size: 0.8rem; }
|
||||
|
||||
.targets { margin: 0.8rem 0; font-size: 0.85rem; }
|
||||
.targets summary { cursor: pointer; color: var(--dim); }
|
||||
.target-row { display: flex; gap: 0.4rem; align-items: center; padding: 0.2rem 0; }
|
||||
.target-row input { width: 6rem; }
|
||||
.arc { display: inline-flex; flex-direction: column; gap: 0.25rem; font-size: 0.8rem; color: var(--dim); margin-top: 0.6rem; }
|
||||
|
||||
.bar { width: 140px; height: 8px; background: #23262e; border-radius: 4px; overflow: hidden; }
|
||||
.bar div { height: 100%; background: var(--accent); }
|
||||
.result { margin-top: 1rem; }
|
||||
|
||||
.icon { border-radius: 6px; object-fit: cover; background: #0f1115; flex: none; }
|
||||
.icon--fallback {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--dim);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.card-head { display: flex; align-items: center; gap: 0.6rem; }
|
||||
|
||||
.arc-row { display: flex; gap: 1rem; align-items: flex-end; margin-top: 0.6rem; }
|
||||
.arc-row input { width: 5rem; }
|
||||
|
||||
.toggle { display: flex; gap: 0.5rem; align-items: center; font-size: 0.85rem; margin: 0.8rem 0 0; }
|
||||
|
||||
.row--clickable { cursor: pointer; }
|
||||
.row--clickable:hover td { background: #1a1d24; }
|
||||
.row--editing td { background: #1a1d24; }
|
||||
.editor { display: flex; flex-wrap: wrap; gap: 0.8rem; align-items: flex-end; padding: 0.6rem 0; }
|
||||
.editor label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.75rem; color: var(--dim); }
|
||||
.editor input { width: 6rem; }
|
||||
.editor p { flex-basis: 100%; margin: 0 0 0.2rem; font-size: 0.78rem; }
|
||||
|
||||
.sheet table { max-width: 560px; }
|
||||
.sheet input { width: 7rem; }
|
||||
.row--drifted td { background: #2a2318; }
|
||||
|
||||
.source-toggle { display: flex; gap: 0.4rem; align-items: center; margin-top: 0.8rem; }
|
||||
.source-toggle button.active { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
.boards { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 1rem; margin-top: 1rem; }
|
||||
.boards .assignment { border: 1px solid var(--line); border-radius: 8px; padding: 0.7rem 0.8rem; background: var(--panel); }
|
||||
22
src/ui/useStore.ts
Normal file
22
src/ui/useStore.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* React's view of the store.
|
||||
*
|
||||
* `useSyncExternalStore` rather than context plus state, so the store stays a
|
||||
* plain object that Node tests can drive without a renderer.
|
||||
*/
|
||||
import { createContext, useContext, useSyncExternalStore } from "react";
|
||||
|
||||
import type { AppState, Store } from "../state/store.ts";
|
||||
|
||||
export const StoreContext = createContext<Store | null>(null);
|
||||
|
||||
export function useStore(): Store {
|
||||
const store = useContext(StoreContext);
|
||||
if (!store) throw new Error("no store in context");
|
||||
return store;
|
||||
}
|
||||
|
||||
export function useAppState(): AppState {
|
||||
const store = useStore();
|
||||
return useSyncExternalStore(store.subscribe, store.getState, store.getState);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue