feat: real artwork, readable stats, and a build you can read at a glance

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>
This commit is contained in:
goober 2026-08-21 13:35:25 +03:00
parent 0334ab1037
commit 24999a30ba
187 changed files with 995 additions and 225 deletions

55
tests/statlabels.test.ts Normal file
View file

@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { formatStat, formatStatValue, statLabel } from "../src/domain/statlabels.ts";
import gamedata from "../src/generated/gamedata.json" with { type: "json" };
import { STAT_SLOTS } from "../src/domain/statvec.ts";
const harvested = gamedata.statNames as Record<string, { name: string; percent: boolean }>;
describe("stat labels", () => {
it("prefers the game's own wording", () => {
const label = statLabel(harvested, "CritBase");
expect(label.name).toBe("CRIT Rate");
expect(label.source).toBe("game");
});
it("labels every stat the app can hold a value for", () => {
// A slot with no label would render a raw id at the player, which is the
// thing this module exists to prevent.
const unlabelled = STAT_SLOTS.filter((id) => statLabel(harvested, id).name === id);
expect(unlabelled).toEqual([]);
});
it("marks derived names so the UI can flag them", () => {
const derived = statLabel(harvested, "MagBase");
expect(derived.source).toBe("derived");
expect(derived.note).toBeTruthy();
});
it("distinguishes a flat bonus from a percentage one", () => {
// everness names both `HPMaxUp` and nothing at all for `HPMaxAdd`; rendering
// them identically would make "HP 200" and "HP 2.5%" indistinguishable.
expect(formatStat(harvested, "HPMaxUp", 0.025)).toBe("HP 2.5%");
expect(formatStat(harvested, "HPMaxAdd", 200)).toBe("HP 200");
});
it("renders percentages from fractions and drops noise decimals", () => {
const percent = statLabel(harvested, "CritBase");
expect(formatStatValue(percent, 0.5)).toBe("50%");
expect(formatStatValue(percent, 0.125)).toBe("12.5%");
const flat = statLabel(harvested, "UnbalIntensityBase");
expect(formatStatValue(flat, 180)).toBe("180");
});
it("keeps Break and Cycle apart", () => {
// everness names `Mag` "Cycle Intensity" and `UnbalIntensity` "Break
// Intensity", which is what settles these two being different stats.
expect(statLabel(harvested, "UnbalIntensityBase").name).toBe("Break Intensity");
expect(statLabel(harvested, "MagBase").name).toBe("Cycle Intensity");
});
it("shows an unknown id raw rather than inventing wording", () => {
const label = statLabel(harvested, "SomeStatNobodyKnows");
expect(label.name).toBe("SomeStatNobodyKnows");
});
});