Six things, all found by using the deployed page.
**The icons were never images.** `everness.info/...` answers 200 with
`content-type: image/webp` for paths that do not exist, returning the SPA's
HTML shell, so all 72 downloaded "icons" were HTML documents named .webp and
every character silently rendered as initials. The assets live on
`api.everness.info/data/assets` instead, and the fetcher now verifies RIFF/WEBP
magic bytes and fails the build rather than warning. 95 files: 23 avatars at
256px, 23 portraits, 49 Arc icons.
**Stats read as the game writes them.** `prune-gamedata` harvests every
`{ id_stats, name, bShowPercent }` pair out of the mirror, so "CRIT Rate" is
the game's own wording rather than someone's typing, and values render as 12.5%
instead of 0.125. Ids the mirror never names are filled in by
`domain/statlabels.ts`, each with its reason and marked derived in the UI.
Two of everness's own percent flags are overridden: it marks `HealUp` and
`DamageUpNatureBase` flat while shipping them as fractions in exactly the range
of the bonuses it does mark as percentages, which printed "Healing Bonus 0.1".
**The build is legible without hovering.** Each piece carries its legend number
and colour, and the list beside the board states shape, type, level and every
stat outright; the cartridge sits in its own box below, since it occupies no
cells. Hover-only tooltips meant the information existed but could not be
scanned or compared. Shapes are drawn from the same cell lists the solver packs
with, so an icon cannot drift from its geometry.
**Owner groups have numbers, not hashes.** "group 0000000088" told the player
nothing; groups are now numbered stably and an unnamed one links to where it
gets named.
Also: import/export moved to its own Data tab with a library summary and the
to-do list, and the layout is denser throughout.
152 tests, tsc clean, both build targets clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
149 lines
5 KiB
TypeScript
149 lines
5 KiB
TypeScript
/**
|
|
* 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 type { CharacterRow } from "../db/schema.ts";
|
|
import { hashFor, type Route } from "./router.ts";
|
|
import { useAppState, useStore } from "./useStore.ts";
|
|
|
|
export function CharactersTab({
|
|
data,
|
|
route,
|
|
unnamedGroups,
|
|
groupNumbers,
|
|
}: {
|
|
data: LoadedData;
|
|
route: Route;
|
|
unnamedGroups: string[];
|
|
groupNumbers: Map<string, number>;
|
|
}) {
|
|
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} groupNumbers={groupNumbers} />
|
|
)}
|
|
<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>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* 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,
|
|
groupNumbers,
|
|
}: {
|
|
groups: string[];
|
|
data: LoadedData;
|
|
groupNumbers: Map<string, number>;
|
|
}) {
|
|
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">
|
|
<strong className="group-number" title={group}>
|
|
Group {groupNumbers.get(group) ?? "?"}
|
|
</strong>
|
|
<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>
|
|
{db.characters.map((character) => (
|
|
<option key={character.characterId} value={character.characterId}>
|
|
{ownerLabel(data, db.characters, character.characterId)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|