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:
goober 2026-08-21 11:38:17 +03:00
commit 5ce255fa6f
157 changed files with 92600 additions and 0 deletions

161
src/db/adapters/idb.ts Normal file
View 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();
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}

File diff suppressed because one or more lines are too long

View 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
View 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"
}
}

File diff suppressed because one or more lines are too long

13
src/main.tsx Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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,
};
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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&apos;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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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);
}