fix: reachable undo, nameable groups, and no favicon request

Found by running the app in a browser for the first time.

- Undo was only reachable from the team tab, and only after a team solve had
  finished, so an equip made from the character view had no way back. Both
  views now show it whenever there is something to undo.
- The owner-group naming dropdown listed all 23 espers from the game database
  rather than the characters the capture says you own. Two espers are both
  published as "Zero", so the list held two identical entries and picking the
  wrong one misattributed a whole loadout; conversely a character the game
  database has never heard of had no entry at all and its group could not be
  named. Options now come from the captured roster, labelled with the esper
  name and disambiguated by codename when two owned characters share one.
- An inline SVG favicon, so the page stops requesting one that does not exist.
  Inline rather than a file because the single-file build must make no
  requests at all.

Verified headless against the real 819-item export: import writes 819 items,
20 characters and 99 equipment rows to IndexedDB; a solve runs in a real
Worker and scores 0.6690 for Zankou, matching the Node bench; equip writes 8
app rows and displaces 7; undo restores all 99 imported rows; state survives
a reload. The file:// build renders, imports, solves inline and issues no
network request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goober 2026-08-21 12:30:56 +03:00
parent 91952b1745
commit f6466881f4
4 changed files with 37 additions and 4 deletions

View file

@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%230d1117'/><rect x='6' y='6' width='9' height='9' fill='%23e5534b'/><rect x='17' y='6' width='9' height='9' fill='%23d9b125'/><rect x='6' y='17' width='9' height='9' fill='%234c8ed9'/><rect x='17' y='17' width='9' height='9' fill='%232ea44f'/></svg>" />
<title>NTE gear optimizer</title>
</head>
<body>

View file

@ -351,6 +351,9 @@ export function CharacterView({
Equip this build
</button>
)}
{state.data.undo.length > 0 && (
<button onClick={() => void store.undo()}>Undo last equip</button>
)}
</div>
{running && progress && (

View file

@ -10,6 +10,7 @@ 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 type { CharacterRow } from "../db/schema.ts";
import { hashFor, type Route } from "./router.ts";
import { useAppState, useStore } from "./useStore.ts";
@ -60,6 +61,31 @@ export function CharactersTab({
);
}
/**
* What to call a character in the naming dropdown.
*
* Two espers are both published as "Zero", so a bare display name can send a
* whole loadout to the wrong character; where a name is shared the codename
* disambiguates it. Seven of the captured roster have no everness record at
* all, so their codename is all there is - which is also why the options come
* from the capture rather than from the esper list: a character the game says
* you own must be nameable even when the game database has never heard of it.
*/
function ownerLabel(
data: LoadedData,
characters: CharacterRow[],
characterId: string,
): string {
const esper = esperFor(data.gamedata, characterId);
if (!esper) return characterId;
const shared = characters.some(
(other) =>
other.characterId !== characterId &&
esperFor(data.gamedata, other.characterId)?.name === esper.name,
);
return shared ? `${esper.name} (${characterId})` : esper.name;
}
/**
* The owner groups nobody has named.
*
@ -97,9 +123,9 @@ function UnidentifiedGroups({ groups, data }: { groups: string[]; data: LoadedDa
}}
>
<option value="">name this group…</option>
{data.gamedata.espers.map((esper) => (
<option key={esper.abilityKey} value={esper.abilityKey}>
{esper.name}
{db.characters.map((character) => (
<option key={character.characterId} value={character.characterId}>
{ownerLabel(data, db.characters, character.characterId)}
</option>
))}
</select>

View file

@ -223,6 +223,7 @@ function Result({
data: LoadedData;
store: ReturnType<typeof useStore>;
}) {
const state = useAppState();
if (result.infeasible) {
return <p className="warning">No conflict-free team could be built from this pool.</p>;
}
@ -243,7 +244,9 @@ function Result({
</div>
<p className="dim">
Equipping writes an “app” row you still have to apply in game.{" "}
{state.data.undo.length > 0 && (
<button onClick={() => void store.undo()}>Undo last equip</button>
)}
</p>
</div>
);