nte-optimizer/src/ui/router.ts
goober 5ce255fa6f 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>
2026-08-21 11:38:17 +03:00

43 lines
1.4 KiB
TypeScript

/**
* 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;
}