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:
parent
71071cff94
commit
bddc0817ad
187 changed files with 995 additions and 225 deletions
147
src/domain/statlabels.ts
Normal file
147
src/domain/statlabels.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
/**
|
||||
* Human-readable names for the stat ids the capture speaks in.
|
||||
*
|
||||
* The game's own labels are harvested into `gamedata.statNames` by
|
||||
* `tools/prune-gamedata.ts`, so `CritBase` reads "CRIT Rate" because everness
|
||||
* says it does, not because someone typed it here.
|
||||
*
|
||||
* The mirror does not name every id the capture carries. The gaps are filled
|
||||
* below, each with its reason, and each marked so the UI can say the label is
|
||||
* derived rather than official. Nothing here invents a *number* - only a name
|
||||
* for a stat whose value comes from the capture either way.
|
||||
*
|
||||
* Pure: no DOM, no imports from `ui/`.
|
||||
*/
|
||||
|
||||
export interface StatLabel {
|
||||
/** What to show. */
|
||||
name: string;
|
||||
/** Render the value as a percentage. */
|
||||
percent: boolean;
|
||||
/**
|
||||
* Where the name came from. `game` is everness's own label; `derived` is
|
||||
* completed here from a sibling id and is not the game's wording.
|
||||
*/
|
||||
source: "game" | "derived";
|
||||
/** Set on `derived` labels: why this is the name. */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
type Harvested = Record<string, { name: string; percent: boolean } | undefined>;
|
||||
|
||||
/**
|
||||
* Ids everness never labels.
|
||||
*
|
||||
* The flat/percent pairs are the interesting case: everness names only the
|
||||
* percentage member of each pair (`HPMaxUp` is "HP"), which would make a flat
|
||||
* and a percentage bonus render identically. They are distinguished here.
|
||||
*/
|
||||
const FALLBACKS: Record<string, StatLabel> = {
|
||||
HPMaxAdd: {
|
||||
name: "HP",
|
||||
percent: false,
|
||||
source: "derived",
|
||||
note: "flat counterpart of HPMaxUp, which everness names 'HP'",
|
||||
},
|
||||
AtkAdd: {
|
||||
name: "ATK",
|
||||
percent: false,
|
||||
source: "derived",
|
||||
note: "flat counterpart of AtkUp, which everness names 'ATK'",
|
||||
},
|
||||
DefAdd: {
|
||||
name: "DEF",
|
||||
percent: false,
|
||||
source: "derived",
|
||||
note: "flat counterpart of DefUp, which everness names 'DEF'",
|
||||
},
|
||||
MagBase: {
|
||||
name: "Cycle Intensity",
|
||||
percent: false,
|
||||
source: "derived",
|
||||
note:
|
||||
"everness names `Mag` 'Cycle Intensity', and `UnbalIntensity`/" +
|
||||
"`UnbalIntensityBase` are both 'Break Intensity', so the Base suffix is " +
|
||||
"the same stat. Note this contradicts the older guess that MagBase was Break.",
|
||||
},
|
||||
DamageUpGeneralBase: {
|
||||
name: "DMG Bonus",
|
||||
percent: true,
|
||||
source: "derived",
|
||||
note: "the elemental ids are '<Element> DMG Bonus'; this one names no element",
|
||||
},
|
||||
DamageUpPsycheBase: {
|
||||
name: "Psyche DMG Bonus",
|
||||
percent: true,
|
||||
source: "derived",
|
||||
note: "pattern completed from Cosmos/Chaos/Incantation, which everness does name",
|
||||
},
|
||||
DamageUpLakshanaBase: {
|
||||
name: "Lakshana DMG Bonus",
|
||||
percent: true,
|
||||
source: "derived",
|
||||
note: "pattern completed from Cosmos/Chaos/Incantation, which everness does name",
|
||||
},
|
||||
DamageUpPsychicallyBase: {
|
||||
name: "Psychically DMG Bonus",
|
||||
percent: true,
|
||||
source: "derived",
|
||||
note:
|
||||
"a seventh damage-bonus id with no matching element; appears on 19 real " +
|
||||
"items. Named from the id itself because nothing else describes it.",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Ids where the game data's own percent flag contradicts the values it ships.
|
||||
*
|
||||
* everness marks these `bShowPercent: false`, but the capture carries them as
|
||||
* fractions in exactly the range of the stats it *does* mark as percentages -
|
||||
* `HealUp` spans 0.069..0.345 and `DamageUpNatureBase` spans 0.075..0.375, the
|
||||
* same range as Chaos/Cosmos/Incantation DMG Bonus. Rendering them raw printed
|
||||
* "Healing Bonus 0.1" where the game shows 10%.
|
||||
*
|
||||
* The name is still theirs; only the flag is overridden.
|
||||
*/
|
||||
const PERCENT_OVERRIDES: Record<string, boolean> = {
|
||||
HealUp: true,
|
||||
DamageUpNatureBase: true,
|
||||
};
|
||||
|
||||
export function statLabel(harvested: Harvested, id: string): StatLabel {
|
||||
const fromGame = harvested[id];
|
||||
if (fromGame) {
|
||||
return {
|
||||
name: fromGame.name,
|
||||
percent: PERCENT_OVERRIDES[id] ?? fromGame.percent,
|
||||
source: "game",
|
||||
};
|
||||
}
|
||||
const fallback = FALLBACKS[id];
|
||||
if (fallback) return fallback;
|
||||
// An id nobody names: show it raw rather than inventing wording for it, so a
|
||||
// decoder change that introduces a new stat is visible instead of disguised.
|
||||
return { name: id, percent: false, source: "derived", note: "no known label" };
|
||||
}
|
||||
|
||||
/**
|
||||
* A stat's value as the game would show it.
|
||||
*
|
||||
* Percentages are carried as fractions everywhere in the app, so 0.125 is
|
||||
* 12.5%. Flat values are integers often enough that a trailing `.0` is noise.
|
||||
*/
|
||||
export function formatStatValue(label: StatLabel, value: number): string {
|
||||
if (label.percent) {
|
||||
const percent = value * 100;
|
||||
const rounded = Math.abs(percent - Math.round(percent)) < 0.05;
|
||||
return `${rounded ? Math.round(percent) : percent.toFixed(1)}%`;
|
||||
}
|
||||
if (Number.isInteger(value)) return String(value);
|
||||
return value.toFixed(1);
|
||||
}
|
||||
|
||||
/** `CRIT Rate 12.5%`, for one-line rendering. */
|
||||
export function formatStat(harvested: Harvested, id: string, value: number): string {
|
||||
const label = statLabel(harvested, id);
|
||||
return `${label.name} ${formatStatValue(label, value)}`;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -2,77 +2,100 @@
|
|||
"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"
|
||||
"esper:Sagiri": "player_003_256-ba24235a.webp",
|
||||
"esper:Lacrimosa": "player_004_256-d5cd921b.webp",
|
||||
"esper:Skia": "player_008_256-4ac5f4b2.webp",
|
||||
"esper:Nanally": "player_010_256-2a913121.webp",
|
||||
"esper:Mint019": "player_019_256-5f9a1d33.webp",
|
||||
"esper:Haniel": "player_020_256-892d03c2.webp",
|
||||
"esper:Edgar": "player_021_256-6edc3273.webp",
|
||||
"esper:Cang": "player_028_256-95e0096e.webp",
|
||||
"esper:Hathor": "player_012_256-92bab3fd.webp",
|
||||
"esper:Adler": "player_015_256-977b2890.webp",
|
||||
"esper:Zankou": "player_canhong_256-cbd7b595.webp",
|
||||
"esper:Fadia": "player_027_256-fd7d9656.webp",
|
||||
"esper:Female046": "player_046_256-ef227961.webp",
|
||||
"esper:Female051": "player_009_256-b5096c40.webp",
|
||||
"esper:Jin": "player_013_256-29c3fc17.webp",
|
||||
"esper:Daffodill": "player_005_256-fb2c4a10.webp",
|
||||
"esper:Kuhara": "player_104_256-b91dc7ed.webp",
|
||||
"esper:Mitsuki": "player_haiyue_256-00081c21.webp",
|
||||
"esper:Radio072": "player_lingke_256-755e08e0.webp",
|
||||
"esper:Chiichan073": "player_073_256-92896562.webp",
|
||||
"esper:Oneiroi": "player_yiluoyi_256-2cc43e28.webp",
|
||||
"esper:Chaos071": "player_kaesi1_256-9e74ac98.webp",
|
||||
"esper:Shinku": "player_zhenhong_256-b3f90b1e.webp",
|
||||
"portrait:Sagiri": "YH_lihui_character_zaowu-75eb4a80.webp",
|
||||
"portrait:Lacrimosa": "YH_lihui_character_anhunqu-cdc625ba.webp",
|
||||
"portrait:Skia": "YH_lihui_character_langren-fda901d5.webp",
|
||||
"portrait:Nanally": "YH_lihui_character_nanali-54fe0562.webp",
|
||||
"portrait:Mint019": "YH_lihui_character_bohe-0b1d4e6f.webp",
|
||||
"portrait:Haniel": "YH_lihui_character_haniya-d6f50120.webp",
|
||||
"portrait:Edgar": "YH_lihui_character_aidejia-863c666d.webp",
|
||||
"portrait:Cang": "YH_lihui_character_baizhang-634c4c79.webp",
|
||||
"portrait:Hathor": "YH_lihui_character_hahuosuoer-d0eb862f.webp",
|
||||
"portrait:Adler": "YH_lihui_character_adele-1c93d4c6.webp",
|
||||
"portrait:Zankou": "YH_lihui_fashionshop_canhong-27e1ca03.webp",
|
||||
"portrait:Fadia": "YH_lihui_character_fadiya-8e796e94.webp",
|
||||
"portrait:Female046": "YH_lihui_character_nanzhu-d5ba3e19.webp",
|
||||
"portrait:Female051": "YH_lihui_character_nvzhu-d16dc3c1.webp",
|
||||
"portrait:Jin": "YH_lihui_character_xun-5ab32493.webp",
|
||||
"portrait:Daffodill": "YH_lihui_character_dafudier-6e05ec14.webp",
|
||||
"portrait:Kuhara": "YH_lihui_character_jiuyuan-f46d2630.webp",
|
||||
"portrait:Mitsuki": "YH_lihui_fashionshop_haiyue1-081a822e.webp",
|
||||
"portrait:Radio072": "YH_lihui_fashionshop_lingke-03f05fd7.webp",
|
||||
"portrait:Chiichan073": "YH_lihui_character_xiaozhi-46c53452.webp",
|
||||
"portrait:Oneiroi": "YH_lihui_fashionshop_yiluoyi-c074e0a9.webp",
|
||||
"portrait:Chaos071": "YH_lihui_fashionshop_kaesi-166ca8cc.webp",
|
||||
"portrait:Shinku": "YH_lihui_fashionshop_zhenhong-d64fb4f5.webp",
|
||||
"arc:fork_yuren": "fork_yuren_256-600a0fb2.webp",
|
||||
"arc:fork_bopu": "fork_bopu_256-f320967a.webp",
|
||||
"arc:fork_mofeikesi": "fork_mofeikesi_256-8321dc43.webp",
|
||||
"arc:fork_jiaojuan": "fork_jiaojuan_256-5ef9bea7.webp",
|
||||
"arc:fork_wuhuakuang": "fork_wuhuakuang_256-36d9f5e5.webp",
|
||||
"arc:fork_jingmotingyuan": "fork_jingmotingyuan_256-d316d16f.webp",
|
||||
"arc:fork_oulaquantao": "fork_oulaquantao_256-bb1b0274.webp",
|
||||
"arc:fork_lingganzhongjiezhe": "fork_lingganzhongjiezhe_256-2cfe42bc.webp",
|
||||
"arc:fork_rishi": "fork_rishi_256-bd0fc893.webp",
|
||||
"arc:fork_dustbin": "fork_dustbin_256-56d579d0.webp",
|
||||
"arc:fork_nonos": "fork_nonos_256-e8b7d49b.webp",
|
||||
"arc:fork_vine": "fork_HugVine_256-408c6d47.webp",
|
||||
"arc:fork_Prokaryon": "fork_yuanheti_256-aa572f42.webp",
|
||||
"arc:fork_BitterCake": "fork_BitterCake_256-7dfa230c.webp",
|
||||
"arc:fork_appliance": "fork_fudianling_256-70ef1a4e.webp",
|
||||
"arc:fork_TigerTally": "fork_TigerTally_256-762e1c1b.webp",
|
||||
"arc:fork_BitGame": "fork_BitGame_256-60716289.webp",
|
||||
"arc:fork_BlackBook": "fork_BlackBook_256-8c5bc8ff.webp",
|
||||
"arc:fork_mamen": "fork_mamen_256-e3a00dfd.webp",
|
||||
"arc:fork_Nakupeda": "fork_Nakupeda_256-ef6a4ed6.webp",
|
||||
"arc:fork_wushoutieyu": "fork_wushoutieyu_256-b9acbc92.webp",
|
||||
"arc:fork_Arachne": "fork_Arachne_256-334bca8d.webp",
|
||||
"arc:fork_PoliceRat": "fork_PoliceRat_256-bcd60a07.webp",
|
||||
"arc:fork_tuansanlang": "fork_tuansanlang_256-37e70a8d.webp",
|
||||
"arc:fork_Kite": "fork_Kite_256-f749b27a.webp",
|
||||
"arc:fork_PaperPlane": "fork_PaperPlane_256-20eaef80.webp",
|
||||
"arc:fork_spider": "fork_spider_256-161a9ce1.webp",
|
||||
"arc:fork_yaodao": "fork_yaodao_256-6b0b7299.webp",
|
||||
"arc:fork_koinobori": "fork_koinobori_256-3d0f806c.webp",
|
||||
"arc:fork_snowman": "fork_snowman_256-e7968920.webp",
|
||||
"arc:fork_BoxingCandy": "fork_BoxingCandy_256-3403c6b4.webp",
|
||||
"arc:fork_KnightCandy": "fork_KnightCandy_256-3fb5130a.webp",
|
||||
"arc:fork_ThiefCandy": "fork_ThiefCandy_256-458b7cdd.webp",
|
||||
"arc:fork_MotorCandy": "fork_MotorCandy_256-22d591e4.webp",
|
||||
"arc:fork_BlastCandy": "fork_BlastCandy_256-23f701c1.webp",
|
||||
"arc:fork_Crowbar": "fork_Qiaoqiao_256-56594355.webp",
|
||||
"arc:fork_Castle": "fork_Castle_256-7b6d7958.webp",
|
||||
"arc:fork_Butterfly": "fork_Butterfly_256-8bb7bf88.webp",
|
||||
"arc:fork_NestBird": "fork_NestBird_256-fa9c2459.webp",
|
||||
"arc:fork_Whale": "fork_Whale_256-87b0cc06.webp",
|
||||
"arc:fork_Time": "fork_Time_256-d694a147.webp",
|
||||
"arc:fork_Rose": "fork_Rose_256-245f28c5.webp",
|
||||
"arc:fork_worldrain": "fork_worldrain_256-131bdfe6.webp",
|
||||
"arc:fork_moon": "fork_moon_256-e5a415d1.webp",
|
||||
"arc:fork_GoldWool": "fork_GoldWool_256-7f1873db.webp",
|
||||
"arc:fork_LunarPhase": "fork_LunarPhase_256-04d3a548.webp",
|
||||
"arc:fork_Door": "fork_Door_256-5c392b08.webp",
|
||||
"arc:fork_DemonBlade": "fork_DemonBlade_256-95122830.webp",
|
||||
"arc:fork_GoldRecord": "fork_GoldRecord_256-8a5c1c91.webp"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ export interface Esper {
|
|||
element: string | null;
|
||||
rarity: number;
|
||||
icon: string;
|
||||
/** 256px avatar; falls back to `icon` at fetch time. */
|
||||
iconBig: string | null;
|
||||
/** Full card art. */
|
||||
iconGacha: string | null;
|
||||
stats: Array<{ id_stats: string; name: string; bShowPercent: boolean; values: number[] }>;
|
||||
slots: number[][];
|
||||
ownerGridCount: number;
|
||||
|
|
@ -49,6 +53,8 @@ export interface GameData {
|
|||
format: string;
|
||||
format_version: number;
|
||||
generated: string;
|
||||
/** The game's own label for each stat id; see `domain/statlabels.ts`. */
|
||||
statNames: Record<string, { name: string; percent: boolean }>;
|
||||
espers: Esper[];
|
||||
arcs: Arc[];
|
||||
arcEffects: Record<string, { name: string; effects: ArcEffect[] }>;
|
||||
|
|
|
|||
|
|
@ -47,10 +47,31 @@ function Shell({ data }: { data: LoadedData }) {
|
|||
const route = useRoute();
|
||||
const db = state.data.db;
|
||||
|
||||
const ownerOf = (instance: string): string | null => {
|
||||
/**
|
||||
* Owner groups get a stable number, not their raw id.
|
||||
*
|
||||
* "group 0000000088" told the player nothing they could act on. The number is
|
||||
* the group's position in the sorted list of groups, so it is stable within an
|
||||
* import and the same group reads the same everywhere; the raw id stays in the
|
||||
* title attribute for anyone debugging a capture.
|
||||
*/
|
||||
const groupNumbers = new Map(
|
||||
[...new Set(db.equipment.map((row) => row.ownerGroup).filter((id) => id !== null))]
|
||||
.sort()
|
||||
.map((id, index) => [id, index + 1] as const),
|
||||
);
|
||||
|
||||
const ownerOf = (instance: string): { label: string; named: boolean; title?: 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 named = resolveCharacter(row, db.ownerNames);
|
||||
if (named) return { label: named, named: true };
|
||||
if (!row.ownerGroup) return null;
|
||||
return {
|
||||
label: `Unnamed group ${groupNumbers.get(row.ownerGroup) ?? "?"}`,
|
||||
named: false,
|
||||
title: row.ownerGroup,
|
||||
};
|
||||
};
|
||||
|
||||
const todo = todoInGame(db);
|
||||
|
|
@ -84,30 +105,25 @@ function Shell({ data }: { data: LoadedData }) {
|
|||
</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} />
|
||||
<ItemTable kind="cartridge" items={db.items} gamedata={data.gamedata} ownerOf={ownerOf} />
|
||||
)}
|
||||
{route.tab === "modules" && (
|
||||
<ItemTable kind="module" items={db.items} ownerOf={ownerOf} />
|
||||
<ItemTable kind="module" items={db.items} gamedata={data.gamedata} ownerOf={ownerOf} />
|
||||
)}
|
||||
{route.tab === "characters" && (
|
||||
<CharactersTab data={data} route={route} unnamedGroups={unnamed} />
|
||||
<CharactersTab
|
||||
data={data}
|
||||
route={route}
|
||||
unnamedGroups={unnamed}
|
||||
groupNumbers={groupNumbers}
|
||||
/>
|
||||
)}
|
||||
{route.tab === "team" && <TeamTab data={data} />}
|
||||
{route.tab === "data" && (
|
||||
<DataPanel gamedataVersion={data.gamedata.generated} todo={todo} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,13 @@ export interface BoardProps {
|
|||
onHover?: (piece: number | null) => void;
|
||||
labelFor?: (piece: number) => string;
|
||||
size?: number;
|
||||
/** Piece to lift out of the board, matched to the legend row under the pointer. */
|
||||
highlight?: number | null;
|
||||
/**
|
||||
* Draw each piece's legend number on it. Off for the small boards in the team
|
||||
* view, where the digit would be larger than the cell.
|
||||
*/
|
||||
numbered?: boolean;
|
||||
}
|
||||
|
||||
export function Board({
|
||||
|
|
@ -24,6 +31,8 @@ export function Board({
|
|||
onHover,
|
||||
labelFor,
|
||||
size = 34,
|
||||
highlight = null,
|
||||
numbered = true,
|
||||
}: BoardProps) {
|
||||
const render = renderBoard(cells, placement);
|
||||
const free = new Set(cells);
|
||||
|
|
@ -39,10 +48,17 @@ export function Board({
|
|||
return <div key={index} className="board-cell board-cell--blocked" />;
|
||||
}
|
||||
const cell = render.cells.find((entry) => entry.cell === index)!;
|
||||
const hot = highlight !== null && highlight === cell.piece;
|
||||
// The number goes on the piece's first cell only, so a seven-cell piece
|
||||
// is labelled once rather than seven times.
|
||||
const first =
|
||||
numbered &&
|
||||
cell.piece >= 0 &&
|
||||
render.cells.find((entry) => entry.piece === cell.piece)?.cell === index;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="board-cell"
|
||||
className={hot ? "board-cell board-cell--hot" : "board-cell"}
|
||||
title={labelFor?.(cell.piece)}
|
||||
onPointerEnter={() => onHover?.(cell.piece)}
|
||||
style={{
|
||||
|
|
@ -52,7 +68,9 @@ export function Board({
|
|||
borderBottomWidth: cell.bottom ? 2 : 0,
|
||||
borderLeftWidth: cell.left ? 2 : 0,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{first && <span className="board-index">{cell.piece + 1}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
145
src/ui/BuildLegend.tsx
Normal file
145
src/ui/BuildLegend.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
/**
|
||||
* The build, laid out so a player can act on it without hovering anything.
|
||||
*
|
||||
* Each piece on the board carries the colour and the number of its row in the
|
||||
* list beside it, and every row states the module's shape, type, level and
|
||||
* stats outright. Hover-only tooltips meant the information existed but could
|
||||
* not be read at a glance, scanned, or compared between two pieces - which is
|
||||
* the whole job here: read row 3, find that module in the game's inventory,
|
||||
* drop it in the highlighted cell.
|
||||
*
|
||||
* The cartridge sits under the board on its own, because it is not placed on
|
||||
* the grid at all - it is one choice for the whole build, and grouping it with
|
||||
* the modules implied it occupied cells.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
|
||||
import { Board } from "./Board.tsx";
|
||||
import { ShapeIcon } from "./ShapeIcon.tsx";
|
||||
import { StatPair } from "./Stat.tsx";
|
||||
import { typeName } from "./ItemCard.tsx";
|
||||
import { pieceColour } from "../domain/render.ts";
|
||||
import type { Database, ItemRow } from "../db/schema.ts";
|
||||
import { resolveCharacter } from "../db/store.ts";
|
||||
import type { GameData } from "../state/gamedata.ts";
|
||||
|
||||
export interface BuildLegendProps {
|
||||
cells: readonly number[];
|
||||
placement: readonly number[];
|
||||
pieces: number;
|
||||
gamedata: GameData;
|
||||
db: Database;
|
||||
/** The module in piece slot `piece`, if the build has one. */
|
||||
itemFor: (piece: number) => ItemRow | undefined;
|
||||
/** The cartridge chosen for this build, if any. */
|
||||
cartridge?: ItemRow | undefined;
|
||||
}
|
||||
|
||||
export function BuildLegend({
|
||||
cells,
|
||||
placement,
|
||||
pieces,
|
||||
gamedata,
|
||||
db,
|
||||
itemFor,
|
||||
cartridge,
|
||||
}: BuildLegendProps) {
|
||||
const [hovered, setHovered] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<div className="build">
|
||||
<div className="build-main">
|
||||
<Board
|
||||
cells={cells}
|
||||
placement={placement}
|
||||
pieces={pieces}
|
||||
highlight={hovered}
|
||||
onHover={setHovered}
|
||||
labelFor={(piece) => {
|
||||
const item = itemFor(piece);
|
||||
return item ? `${piece + 1}. ${item.shape ?? item.set} +${item.level}` : "";
|
||||
}}
|
||||
/>
|
||||
|
||||
<ol className="legend">
|
||||
{Array.from({ length: pieces }, (_, piece) => {
|
||||
const item = itemFor(piece);
|
||||
const colour = pieceColour(piece, pieces);
|
||||
return (
|
||||
<li
|
||||
key={piece}
|
||||
className={hovered === piece ? "legend-row legend-row--hot" : "legend-row"}
|
||||
style={{ borderLeftColor: colour }}
|
||||
onPointerEnter={() => setHovered(piece)}
|
||||
onPointerLeave={() => setHovered(null)}
|
||||
>
|
||||
<span className="legend-index" style={{ background: colour }}>
|
||||
{piece + 1}
|
||||
</span>
|
||||
{item ? (
|
||||
<ItemLine item={item} gamedata={gamedata} db={db} colour={colour} />
|
||||
) : (
|
||||
<span className="dim">empty</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{cartridge && (
|
||||
<div className="cartridge-box">
|
||||
<h4>Cartridge</h4>
|
||||
<ItemLine item={cartridge} gamedata={gamedata} db={db} colour="var(--accent)" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One item's whole identity: how to recognise it in the game's own list. */
|
||||
function ItemLine({
|
||||
item,
|
||||
gamedata,
|
||||
db,
|
||||
colour,
|
||||
}: {
|
||||
item: ItemRow;
|
||||
gamedata: GameData;
|
||||
db: Database;
|
||||
colour: string;
|
||||
}) {
|
||||
const type = typeName(item.cells);
|
||||
const row = db.equipment.find((entry) => entry.instance === item.instance);
|
||||
const owner = row ? resolveCharacter(row, db.ownerNames) : null;
|
||||
|
||||
return (
|
||||
<div className="item-line">
|
||||
<div className="item-line-head">
|
||||
{item.shape && <ShapeIcon shape={item.shape} colour={colour} title={item.shape} />}
|
||||
<strong>
|
||||
{item.kind === "cartridge" ? item.set : `Module${type ? ` · Type ${type}` : ""}`}
|
||||
</strong>
|
||||
<span className="dim">+{item.level}</span>
|
||||
{item.kind === "module" && item.set && <span className="dim">{item.set}</span>}
|
||||
{/* R2's toggle makes this the difference between "free" and "you would
|
||||
be taking it off someone". */}
|
||||
{owner && <span className="worn">worn by {owner}</span>}
|
||||
</div>
|
||||
<div className="item-line-stats">
|
||||
{item.mainStats.map((stat) =>
|
||||
stat.value === null ? null : (
|
||||
<span key={`m-${stat.stat}`} className="main">
|
||||
<StatPair gamedata={gamedata} id={stat.stat} value={stat.value} />
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
{item.substats.map((stat) => (
|
||||
<span key={`s-${stat.stat}`} className="sub">
|
||||
<StatPair gamedata={gamedata} id={stat.stat} value={stat.value} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -31,9 +31,9 @@ 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 { BuildLegend } from "./BuildLegend.tsx";
|
||||
import { Icon, Portrait } from "./Icon.tsx";
|
||||
import { StatName, useStatText } from "./Stat.tsx";
|
||||
import { useAppState, useStore } from "./useStore.ts";
|
||||
|
||||
const DEFAULT_TARGETS: StatTarget[] = [
|
||||
|
|
@ -86,7 +86,6 @@ export function CharacterView({
|
|||
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);
|
||||
|
|
@ -127,6 +126,7 @@ export function CharacterView({
|
|||
}
|
||||
|
||||
const trait = consoleTrait(esper);
|
||||
const statText = useStatText(data.gamedata);
|
||||
|
||||
const solve = async () => {
|
||||
setProblem(null);
|
||||
|
|
@ -217,19 +217,22 @@ export function CharacterView({
|
|||
|
||||
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>
|
||||
<header className="character-head">
|
||||
<Portrait entry={`portrait:${characterId}`} alt={esper.name} />
|
||||
<div className="character-head-text">
|
||||
<h2>
|
||||
<Icon entry={`esper:${characterId}`} alt={esper.name} size={44} />
|
||||
{esper.name}
|
||||
</h2>
|
||||
<p className="dim">
|
||||
level {character?.level ?? "?"} · {character?.breakthroughs ?? "?"} breakthroughs ·{" "}
|
||||
{esper.element} · console trait{" "}
|
||||
{trait
|
||||
? `${trait.name} +${trait.per} per Type ${trait.moduleCells === 2 ? "II" : "III"}`
|
||||
: "none"}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="arc-row">
|
||||
<label className="arc">
|
||||
|
|
@ -368,21 +371,16 @@ export function CharacterView({
|
|||
<p className="warning">No valid full-set build exists for this character and pool.</p>
|
||||
)}
|
||||
|
||||
<Board
|
||||
<BuildLegend
|
||||
cells={cells}
|
||||
placement={placement}
|
||||
pieces={pieces}
|
||||
onHover={setHovered}
|
||||
labelFor={(piece) => {
|
||||
const item = itemFor(piece);
|
||||
return item ? `${item.shape ?? item.set} +${item.level}` : "";
|
||||
}}
|
||||
gamedata={data.gamedata}
|
||||
db={db}
|
||||
itemFor={itemFor}
|
||||
cartridge={build ? db.items[build.cartridge] : undefined}
|
||||
/>
|
||||
|
||||
{hovered !== null && hovered >= 0 && (
|
||||
<ItemCard item={itemFor(hovered)} db={db} />
|
||||
)}
|
||||
|
||||
<SheetPanel
|
||||
sheet={sheet}
|
||||
measured={character?.measuredSheet ?? null}
|
||||
|
|
@ -408,9 +406,11 @@ export function CharacterView({
|
|||
<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>
|
||||
<StatName gamedata={data.gamedata} id={stat.stat} />
|
||||
</td>
|
||||
<td>{statText.value(stat.stat, stat.value)}</td>
|
||||
<td className="dim">/ {statText.value(stat.stat, stat.target)}</td>
|
||||
<td>
|
||||
<div className="bar">
|
||||
<div style={{ width: `${stat.attainment * 100}%` }} />
|
||||
|
|
|
|||
|
|
@ -18,10 +18,12 @@ 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;
|
||||
|
|
@ -31,7 +33,7 @@ export function CharactersTab({
|
|||
return (
|
||||
<section>
|
||||
{unnamedGroups.length > 0 && (
|
||||
<UnidentifiedGroups groups={unnamedGroups} data={data} />
|
||||
<UnidentifiedGroups groups={unnamedGroups} data={data} groupNumbers={groupNumbers} />
|
||||
)}
|
||||
<div className="cards">
|
||||
{db.characters.map((character) => {
|
||||
|
|
@ -93,7 +95,15 @@ function ownerLabel(
|
|||
* 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 }) {
|
||||
function UnidentifiedGroups({
|
||||
groups,
|
||||
data,
|
||||
groupNumbers,
|
||||
}: {
|
||||
groups: string[];
|
||||
data: LoadedData;
|
||||
groupNumbers: Map<string, number>;
|
||||
}) {
|
||||
const store = useStore();
|
||||
const state = useAppState();
|
||||
const db = state.data.db;
|
||||
|
|
@ -112,7 +122,9 @@ function UnidentifiedGroups({ groups, data }: { groups: string[]; data: LoadedDa
|
|||
.filter((item) => item !== undefined);
|
||||
return (
|
||||
<div key={group} className="group">
|
||||
<code>{group.slice(0, 12)}</code>
|
||||
<strong className="group-number" title={group}>
|
||||
Group {groupNumbers.get(group) ?? "?"}
|
||||
</strong>
|
||||
<span className="dim">
|
||||
{items.map((item) => item.shape ?? item.set).join(", ")}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -39,7 +39,14 @@ function download(name: string, contents: string): void {
|
|||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function DataPanel({ gamedataVersion }: { gamedataVersion: string | null }) {
|
||||
export function DataPanel({
|
||||
gamedataVersion,
|
||||
todo,
|
||||
}: {
|
||||
gamedataVersion: string | null;
|
||||
/** Equipped-in-app rows the player still has to apply in game. */
|
||||
todo: Map<string, unknown[]>;
|
||||
}) {
|
||||
const store = useStore();
|
||||
const state = useAppState();
|
||||
const [pending, setPending] = useState<ImportResult | null>(null);
|
||||
|
|
@ -65,10 +72,51 @@ export function DataPanel({ gamedataVersion }: { gamedataVersion: string | null
|
|||
setNote("Gear imported.");
|
||||
};
|
||||
|
||||
const db = state.data.db;
|
||||
const equipped = new Set(db.equipment.map((row) => row.instance)).size;
|
||||
|
||||
return (
|
||||
<section className="data">
|
||||
<h2>Data</h2>
|
||||
|
||||
{/* The library at a glance, so "did the import actually land" is
|
||||
answerable without opening a tab and counting rows. */}
|
||||
<dl className="library">
|
||||
<div>
|
||||
<dt>Cartridges</dt>
|
||||
<dd>{db.items.filter((item) => item.kind === "cartridge").length}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Modules</dt>
|
||||
<dd>{db.items.filter((item) => item.kind === "module").length}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Characters</dt>
|
||||
<dd>{db.characters.length}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Equipped</dt>
|
||||
<dd>{equipped}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Game data</dt>
|
||||
<dd>{gamedataVersion}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{todo.size > 0 && (
|
||||
<details className="todo" open>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="actions">
|
||||
<button onClick={() => gearInput.current?.click()}>Import gear capture</button>
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -19,6 +19,27 @@ export function iconFile(key: string): string | null {
|
|||
return FILES[key] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full card art, as a character view's banner.
|
||||
*
|
||||
* Lazy and silent on failure: it is decoration, and a missing portrait must not
|
||||
* leave a broken-image box above the build the player came for.
|
||||
*/
|
||||
export function Portrait({ entry, alt }: { entry: string; alt: string }) {
|
||||
const [broken, setBroken] = useState(false);
|
||||
const file = iconFile(entry);
|
||||
if (!file || broken) return null;
|
||||
return (
|
||||
<img
|
||||
className="portrait"
|
||||
src={`./img/${file}`}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
onError={() => setBroken(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Icon({
|
||||
entry,
|
||||
alt,
|
||||
|
|
|
|||
|
|
@ -14,17 +14,29 @@ import { useStore } from "./useStore.ts";
|
|||
|
||||
import { STAT_SLOTS } from "../domain/statvec.ts";
|
||||
import type { ItemRow } from "../db/schema.ts";
|
||||
import type { GameData } from "../state/gamedata.ts";
|
||||
import { ShapeIcon } from "./ShapeIcon.tsx";
|
||||
import { StatName, useStatText } from "./Stat.tsx";
|
||||
|
||||
export interface Owner {
|
||||
label: string;
|
||||
/** False when the capture knows the group but nobody has said whose it is. */
|
||||
named: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface ItemTableProps {
|
||||
kind: "cartridge" | "module";
|
||||
items: readonly ItemRow[];
|
||||
/** Owner group per instance, when the item is worn. */
|
||||
ownerOf: (instance: string) => string | null;
|
||||
gamedata: GameData;
|
||||
/** Who wears this item, when anyone does. */
|
||||
ownerOf: (instance: string) => Owner | null;
|
||||
}
|
||||
|
||||
type SortKey = "itemId" | "level" | "set" | "shape";
|
||||
|
||||
export function ItemTable({ kind, items, ownerOf }: ItemTableProps) {
|
||||
export function ItemTable({ kind, items, gamedata, ownerOf }: ItemTableProps) {
|
||||
const statText = useStatText(gamedata);
|
||||
const [group, setGroup] = useState("");
|
||||
const [main, setMain] = useState("");
|
||||
const [sub, setSub] = useState("");
|
||||
|
|
@ -79,7 +91,7 @@ export function ItemTable({ kind, items, ownerOf }: ItemTableProps) {
|
|||
<option value="">any</option>
|
||||
{STAT_SLOTS.map((stat) => (
|
||||
<option key={stat} value={stat}>
|
||||
{stat}
|
||||
{statText.name(stat)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
@ -90,7 +102,7 @@ export function ItemTable({ kind, items, ownerOf }: ItemTableProps) {
|
|||
<option value="">any</option>
|
||||
{STAT_SLOTS.map((stat) => (
|
||||
<option key={stat} value={stat}>
|
||||
{stat}
|
||||
{statText.name(stat)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
@ -136,18 +148,45 @@ export function ItemTable({ kind, items, ownerOf }: ItemTableProps) {
|
|||
className={editing === item.instance ? "row--editing" : "row--clickable"}
|
||||
onClick={() => setEditing(editing === item.instance ? null : item.instance)}
|
||||
>
|
||||
<td>{kind === "cartridge" ? item.set : item.shape}</td>
|
||||
<td className="shape-cell">
|
||||
{kind === "cartridge" ? (
|
||||
item.set
|
||||
) : (
|
||||
<>
|
||||
<ShapeIcon shape={item.shape} title={item.shape ?? undefined} />
|
||||
<span className="dim">{item.set}</span>
|
||||
</>
|
||||
)}
|
||||
</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>}
|
||||
{item.mainStats.length === 0 ? (
|
||||
<span className="dim">—</span>
|
||||
) : (
|
||||
item.mainStats.map((stat, index) => (
|
||||
<span key={stat.stat}>
|
||||
{index > 0 && ", "}
|
||||
<StatName gamedata={gamedata} id={stat.stat} />
|
||||
{stat.value !== null && ` ${statText.value(stat.stat, stat.value)}`}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</td>
|
||||
<td className="subs">
|
||||
{item.substats.map((stat) => `${stat.stat} ${stat.value}`).join(" · ")}
|
||||
{item.substats.map((stat, index) => (
|
||||
<span key={stat.stat}>
|
||||
{index > 0 && " · "}
|
||||
<StatName gamedata={gamedata} id={stat.stat} />{" "}
|
||||
{statText.value(stat.stat, stat.value)}
|
||||
</span>
|
||||
))}
|
||||
</td>
|
||||
<td>
|
||||
<OwnerCell owner={ownerOf(item.instance)} />
|
||||
</td>
|
||||
<td>{ownerOf(item.instance) ?? <span className="dim">—</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
{editing && rows.some((item) => item.instance === editing) && (
|
||||
|
|
@ -166,6 +205,17 @@ export function ItemTable({ kind, items, ownerOf }: ItemTableProps) {
|
|||
);
|
||||
}
|
||||
|
||||
/** Who wears it - and, when nobody has said, where to go and say so. */
|
||||
function OwnerCell({ owner }: { owner: Owner | null }) {
|
||||
if (!owner) return <span className="dim">—</span>;
|
||||
if (owner.named) return <span>{owner.label}</span>;
|
||||
return (
|
||||
<a className="unnamed" href="#/characters" title={owner.title}>
|
||||
{owner.label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Correcting a decoded item by hand.
|
||||
*
|
||||
|
|
|
|||
60
src/ui/ShapeIcon.tsx
Normal file
60
src/ui/ShapeIcon.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* A module's shape, drawn rather than named.
|
||||
*
|
||||
* `cell3_style4` tells a player nothing; the outline of the piece is what they
|
||||
* match against their inventory. Drawn from the same cell lists the solver
|
||||
* packs with, so an icon cannot drift from the geometry it stands for.
|
||||
*/
|
||||
import { cellsOf, extentOf, SHAPES, type ShapeId } from "../domain/shapes.ts";
|
||||
|
||||
function isShapeId(shape: string): shape is ShapeId {
|
||||
return Object.hasOwn(SHAPES, shape);
|
||||
}
|
||||
|
||||
export function ShapeIcon({
|
||||
shape,
|
||||
size = 6,
|
||||
colour = "currentColor",
|
||||
title,
|
||||
}: {
|
||||
shape: string | null | undefined;
|
||||
/** Pixels per cell. */
|
||||
size?: number;
|
||||
colour?: string;
|
||||
title?: string | undefined;
|
||||
}) {
|
||||
if (!shape) return null;
|
||||
// An unknown id means the decoder produced a shape this build does not know:
|
||||
// show it raw rather than drawing something that is not the piece.
|
||||
if (!isShapeId(shape)) return <span className="dim">{shape}</span>;
|
||||
|
||||
const cells = cellsOf(shape);
|
||||
const { rows, cols } = extentOf(shape);
|
||||
const gap = 1;
|
||||
const width = cols * size + (cols - 1) * gap;
|
||||
const height = rows * size + (rows - 1) * gap;
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="shape-icon"
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
role="img"
|
||||
aria-label={title ?? shape}
|
||||
>
|
||||
<title>{title ?? shape}</title>
|
||||
{cells.map(([row, col]) => (
|
||||
<rect
|
||||
key={`${row},${col}`}
|
||||
x={col * (size + gap)}
|
||||
y={row * (size + gap)}
|
||||
width={size}
|
||||
height={size}
|
||||
rx={1}
|
||||
fill={colour}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
53
src/ui/Stat.tsx
Normal file
53
src/ui/Stat.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* Stat ids rendered the way the game writes them.
|
||||
*
|
||||
* Every stat the player sees goes through here. The label comes from
|
||||
* `domain/statlabels.ts`, which reads the game's own wording out of the game
|
||||
* data and marks the few it had to derive - those get a `title` saying so, so a
|
||||
* derived label is never mistaken for the game's.
|
||||
*/
|
||||
import { formatStatValue, statLabel } from "../domain/statlabels.ts";
|
||||
import type { GameData } from "../state/gamedata.ts";
|
||||
|
||||
export function useStatText(gamedata: GameData) {
|
||||
const names = gamedata.statNames ?? {};
|
||||
return {
|
||||
label: (id: string) => statLabel(names, id),
|
||||
name: (id: string) => statLabel(names, id).name,
|
||||
value: (id: string, value: number) => formatStatValue(statLabel(names, id), value),
|
||||
full: (id: string, value: number) => {
|
||||
const label = statLabel(names, id);
|
||||
return `${label.name} ${formatStatValue(label, value)}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function StatName({ gamedata, id }: { gamedata: GameData; id: string }) {
|
||||
const label = statLabel(gamedata.statNames ?? {}, id);
|
||||
return (
|
||||
<span
|
||||
className={label.source === "derived" ? "stat stat--derived" : "stat"}
|
||||
title={label.source === "derived" ? `${id} — name derived: ${label.note}` : id}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatPair({
|
||||
gamedata,
|
||||
id,
|
||||
value,
|
||||
}: {
|
||||
gamedata: GameData;
|
||||
id: string;
|
||||
value: number;
|
||||
}) {
|
||||
const label = statLabel(gamedata.statNames ?? {}, id);
|
||||
return (
|
||||
<span className="stat-pair">
|
||||
<StatName gamedata={gamedata} id={id} />
|
||||
<b>{formatStatValue(label, value)}</b>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -20,9 +20,8 @@ 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 { BuildLegend } from "./BuildLegend.tsx";
|
||||
import { Icon } from "./Icon.tsx";
|
||||
import { ItemCard } from "./ItemCard.tsx";
|
||||
import { useAppState, useStore } from "./useStore.ts";
|
||||
|
||||
const TEAM_SIZE = 4;
|
||||
|
|
@ -267,7 +266,6 @@ function AssignedBuild({
|
|||
}) {
|
||||
const state = useAppState();
|
||||
const db = state.data.db;
|
||||
const [hovered, setHovered] = useState<number | null>(null);
|
||||
const store = useStore();
|
||||
const build = entry.build;
|
||||
|
||||
|
|
@ -309,20 +307,16 @@ function AssignedBuild({
|
|||
</p>
|
||||
)}
|
||||
|
||||
<Board
|
||||
<BuildLegend
|
||||
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}` : "";
|
||||
}}
|
||||
gamedata={data.gamedata}
|
||||
db={db}
|
||||
itemFor={itemFor}
|
||||
cartridge={db.items[build.cartridge]}
|
||||
/>
|
||||
|
||||
{hovered !== null && hovered >= 0 && <ItemCard item={itemFor(hovered)} db={db} />}
|
||||
|
||||
<div className="actions">
|
||||
<button onClick={equip}>Equip on {entry.key}</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const TABS = ["cartridges", "modules", "characters", "team"] as const;
|
||||
export const TABS = ["cartridges", "modules", "characters", "team", "data"] as const;
|
||||
export type Tab = (typeof TABS)[number];
|
||||
|
||||
export interface Route {
|
||||
|
|
|
|||
|
|
@ -164,3 +164,112 @@ table.items .subs { color: var(--dim); font-size: 0.78rem; }
|
|||
|
||||
.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); }
|
||||
|
||||
/* ---- Denser layout -------------------------------------------------------
|
||||
The app is a reference you read while alt-tabbing to the game, so vertical
|
||||
space is the scarce resource. Wider column, tighter rows, smaller base type. */
|
||||
.app { max-width: 1500px; padding: 1rem 1.25rem 3rem; font-size: 0.9rem; }
|
||||
header { margin-bottom: 0.4rem; }
|
||||
h1 { margin-bottom: 0.4rem; }
|
||||
.items td, .items th { padding: 0.25rem 0.5rem; }
|
||||
.actions { margin: 0.6rem 0; }
|
||||
.result { margin-top: 0.6rem; }
|
||||
|
||||
/* ---- Character header ---------------------------------------------------- */
|
||||
.character-head {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: var(--panel);
|
||||
}
|
||||
.character-head .portrait {
|
||||
width: 190px;
|
||||
height: 128px;
|
||||
object-fit: cover;
|
||||
/* The art is a full card: crop to the face rather than shrinking the whole
|
||||
thing to a letterbox. */
|
||||
object-position: 50% 22%;
|
||||
flex: none;
|
||||
}
|
||||
.character-head-text { display: flex; flex-direction: column; justify-content: center; gap: 0.3rem; padding: 0.6rem 0.4rem; }
|
||||
.character-head-text h2 { display: flex; align-items: center; gap: 0.6rem; margin: 0; font-size: 1.25rem; }
|
||||
.character-head-text p { margin: 0; font-size: 0.85rem; }
|
||||
|
||||
/* ---- Build: board beside its legend, cartridge underneath ---------------- */
|
||||
.build { margin: 0.6rem 0; }
|
||||
.build-main { display: flex; gap: 1rem; align-items: flex-start; flex-wrap: wrap; }
|
||||
.build .board { margin: 0; flex: none; }
|
||||
|
||||
.legend { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.25rem; flex: 1 1 380px; min-width: 320px; }
|
||||
.legend-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid var(--line);
|
||||
border-left-width: 4px;
|
||||
border-radius: 6px;
|
||||
background: #171a20;
|
||||
}
|
||||
.legend-row--hot { background: #1e222a; border-color: var(--accent); }
|
||||
.legend-index {
|
||||
flex: none;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 4px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: #10121a;
|
||||
}
|
||||
|
||||
.item-line { display: flex; flex-direction: column; gap: 0.15rem; min-width: 0; }
|
||||
.item-line-head { display: flex; align-items: center; gap: 0.45rem; flex-wrap: wrap; font-size: 0.85rem; }
|
||||
.item-line-stats { display: flex; flex-wrap: wrap; gap: 0.15rem 0.7rem; font-size: 0.8rem; color: var(--dim); }
|
||||
.item-line-stats .main { color: var(--text); }
|
||||
.item-line-stats .main .stat-pair b { color: var(--accent); }
|
||||
.stat-pair { display: inline-flex; gap: 0.3rem; }
|
||||
.stat-pair b { font-weight: 600; }
|
||||
/* A label the game does not itself provide, marked so it is never mistaken for
|
||||
the game's own wording. */
|
||||
.stat--derived { border-bottom: 1px dotted var(--line); }
|
||||
.worn { color: var(--warn); font-size: 0.75rem; }
|
||||
|
||||
.cartridge-box {
|
||||
margin: 0.6rem auto 0;
|
||||
max-width: 620px;
|
||||
padding: 0.45rem 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-left: 4px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
background: #171a20;
|
||||
}
|
||||
.cartridge-box h4 { margin: 0 0 0.2rem; font-size: 0.75rem; font-weight: 600; color: var(--dim); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
|
||||
.board-cell { position: relative; }
|
||||
.board-cell--hot { filter: brightness(1.35); }
|
||||
.board-index {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
color: #10121a;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.shape-icon { display: block; flex: none; }
|
||||
.shape-cell { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.group-number { font-size: 0.85rem; }
|
||||
.unnamed { color: var(--warn); }
|
||||
|
||||
.library { display: flex; flex-wrap: wrap; gap: 0 1.6rem; margin: 0 0 0.8rem; }
|
||||
.library div { display: flex; gap: 0.4rem; align-items: baseline; }
|
||||
.library dt { color: var(--dim); font-size: 0.8rem; }
|
||||
.library dd { margin: 0; font-weight: 600; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue