From 7581a6c1c8687c0a6a016b37f65d0fdb9f6d5461 Mon Sep 17 00:00:00 2001 From: An Lou Date: Sun, 9 Aug 2026 15:09:37 -0500 Subject: [PATCH 1/8] derived tiers + ui --- src/lib/character-builds.test.ts | 46 +++++++ src/lib/character-builds.ts | 30 ++++- src/lib/investment-teams.test.ts | 19 ++- src/lib/investment-teams.ts | 26 ++-- src/lib/types/investment.ts | 73 ++++++++++- src/lib/ui/components/CostPopover.svelte | 3 + src/lib/ui/components/HoverTooltip.svelte | 152 +++++++++------------- src/lib/upgrade-priority.test.ts | 26 ++++ src/lib/upgrade-priority.ts | 44 ++++++- src/routes/teams/[slug]/+page.svelte | 8 +- 10 files changed, 318 insertions(+), 109 deletions(-) diff --git a/src/lib/character-builds.test.ts b/src/lib/character-builds.test.ts index 1e062e1..7d75f00 100644 --- a/src/lib/character-builds.test.ts +++ b/src/lib/character-builds.test.ts @@ -190,6 +190,52 @@ describe("rankSigWeaponsByGain", () => { ["big", "small", "infinite", "nan"], ); }); + + it("prefers merge-stamped tiers over the fixed ladder", () => { + const ranked = rankSigWeaponsByGain( + [ + { + key: "small_but_stamped", + teams: 1, + mean_pct_gain: 1, + median_pct_gain: 1, + min_pct_gain: 1, + max_pct_gain: 1, + tier: "exceptional", + }, + { + key: "big_unstamped", + teams: 1, + mean_pct_gain: 24, + median_pct_gain: 24, + min_pct_gain: 24, + max_pct_gain: 24, + }, + ], + { + floors: { + exceptional: 7.83, + high: 4.11, + solid: 0.79, + negligible: 0, + }, + labels: { + exceptional: "Exceptional impact", + high: "High impact", + solid: "Solid impact", + modest: "Modest impact", + negligible: "Negligible impact", + }, + }, + ); + assert.deepEqual( + ranked.map((w) => [w.key, w.priority, w.priorityLabel]), + [ + ["big_unstamped", "exceptional", "Exceptional impact"], + ["small_but_stamped", "exceptional", "Exceptional impact"], + ], + ); + }); }); describe("constellationImpactRows", () => { diff --git a/src/lib/character-builds.ts b/src/lib/character-builds.ts index 9c0641c..cc1e30c 100644 --- a/src/lib/character-builds.ts +++ b/src/lib/character-builds.ts @@ -13,6 +13,7 @@ import { classifyUpgradeImpact, impactForTier, primaryUpgradePct, + resolveUpgradeImpact, type UpgradeImpactLadder, type UpgradeTier, } from "$lib/upgrade-priority"; @@ -24,6 +25,7 @@ import type { CharacterTalentImportance, CharacterVerticalGain, CharacterWeaponRank, + ImpactTierScale, TalentSlot, } from "$lib/types/investment"; @@ -137,9 +139,10 @@ export type VerticalImpactRow = T & { function attachImpact( row: T, ladder: UpgradeImpactLadder, + scale?: ImpactTierScale | null, ): VerticalImpactRow { const pct = primaryUpgradePct(row.mean_pct_gain, row.median_pct_gain); - const impact = classifyUpgradeImpact(pct, ladder); + const impact = resolveUpgradeImpact(row.tier, pct, ladder, scale); return { ...row, pct, @@ -159,21 +162,32 @@ function compareByImpact(a: { pct: number }, b: { pct: number }): number { return b.pct - a.pct; } +function impactScale( + builds: CharacterIndex | null | undefined, + key: "talents" | "constellations" | "sig_weapons", +): ImpactTierScale | null | undefined { + return builds?.impact_tiers?.[key]; +} + /** Constellations in source order with their display-ready impact. */ export function constellationImpactRows( constellations: CharacterConsGain[] | null | undefined, + scale?: ImpactTierScale | null, ): VerticalImpactRow[] { if (!constellations?.length) return []; - return constellations.map((row) => attachImpact(row, CONSTELLATION_UPGRADE)); + return constellations.map((row) => + attachImpact(row, CONSTELLATION_UPGRADE, scale), + ); } /** Signature weapons ranked by primary gain, with display-ready impact. */ export function rankSigWeaponsByGain( sigWeapons: CharacterSigGain[] | null | undefined, + scale?: ImpactTierScale | null, ): VerticalImpactRow[] { if (!sigWeapons?.length) return []; return sigWeapons - .map((row) => attachImpact(row, SIGNATURE_UPGRADE)) + .map((row) => attachImpact(row, SIGNATURE_UPGRADE, scale)) .sort((a, b) => compareByImpact(a, b) || a.key.localeCompare(b.key)); } @@ -192,12 +206,13 @@ export type TalentImportanceRow = { }; /** - * Talent priority rows from measured simulation data. Qualitative labels from - * max(mean, median) % DPS drop when that talent is at 1. + * Talent priority rows from measured simulation data. Prefer merge-stamped + * impact tiers; fall back to the talent ladder when `tier` is absent. */ export function talentImportanceRows( talentImportance: CharacterTalentImportance | null | undefined, resolveSkillIcon: (kitType: string) => string | null, + scale?: ImpactTierScale | null, ): TalentImportanceRow[] { if (!talentImportance || talentImportance.teams <= 0) return []; @@ -206,7 +221,7 @@ export function talentImportanceRows( const stats = talentImportance[slot]; if (!stats) return []; const pct = primaryUpgradePct(stats.mean_pct_drop, stats.median_pct_drop); - const impact = classifyUpgradeImpact(pct, TALENT_UPGRADE); + const impact = resolveUpgradeImpact(stats.tier, pct, TALENT_UPGRADE, scale); return [ { slot, @@ -316,6 +331,7 @@ export function talentPrioritySection( const simRows = talentImportanceRows( builds?.talent_importance, resolveSkillIcon, + impactScale(builds, "talents"), ); const guideSlots = (guide?.talent_priority ?? []).filter( (slot): slot is TalentSlot => @@ -383,6 +399,7 @@ export function constellationPrioritySection( const guide = builds?.guide_priority; const simRows = constellationImpactRows( builds?.vertical_importance?.constellations, + impactScale(builds, "constellations"), ); const guideCons = guide?.constellations ?? []; const preferGuide = useGuideSection( @@ -417,6 +434,7 @@ export function sigWeaponPrioritySection( const guide = builds?.guide_priority; const simRows = rankSigWeaponsByGain( builds?.vertical_importance?.sig_weapons, + impactScale(builds, "sig_weapons"), ); const guideSigs = guide?.sig_weapons ?? []; const preferGuide = useGuideSection( diff --git a/src/lib/investment-teams.test.ts b/src/lib/investment-teams.test.ts index 9fbd95a..ad3d632 100644 --- a/src/lib/investment-teams.test.ts +++ b/src/lib/investment-teams.test.ts @@ -68,12 +68,13 @@ describe("baselineSim / simAtExactCost", () => { }); describe("baselineVariants / groupVerticalSimsByCost", () => { - it("lists floor variants by DPS and groups verticals by cost", () => { + it("lists floor + owned variants by DPS and groups verticals by cost", () => { const t = team( ["A"], [ sim({ cost: 2, dps: 100, kind: "f2p" }), sim({ cost: 2, dps: 150, kind: "baseline" }), + sim({ cost: 3, dps: 180, kind: "owned" }), sim({ cost: 4, dps: 200, kind: "vertical" }), sim({ cost: 4, dps: 250, kind: "vertical" }), sim({ cost: 6, dps: 300, kind: "vertical" }), @@ -81,7 +82,7 @@ describe("baselineVariants / groupVerticalSimsByCost", () => { ); assert.deepEqual( baselineVariants(t).map((r) => r.dps), - [150, 100], + [180, 150, 100], ); const groups = groupVerticalSimsByCost(t); assert.deepEqual( @@ -110,6 +111,20 @@ describe("exactCostDps", () => { assert.equal(exactCostDps(team(["A"], []), 4), 0); }); + it("skips owned sims so they do not win cost-filter peaks", () => { + const t = team( + ["A"], + [ + sim({ cost: 2, dps: 100, kind: "baseline" }), + sim({ cost: 3, dps: 400, kind: "owned" }), + sim({ cost: 3, dps: 250, kind: "vertical" }), + ], + ); + assert.equal(exactCostDps(t, 3), 250); + assert.equal(simAtExactCost(t, 3)?.kind, "vertical"); + assert.equal(simAtExactCost(t, 3)?.dps, 250); + }); + it("returns the best exact-cost result and never falls back", () => { const t = team( ["A"], diff --git a/src/lib/investment-teams.ts b/src/lib/investment-teams.ts index aa6775a..2128a93 100644 --- a/src/lib/investment-teams.ts +++ b/src/lib/investment-teams.ts @@ -26,10 +26,12 @@ export function baselineSim(team: InvestmentTeam): InvestmentSim | null { return team.results.find((r) => r.kind === "baseline") ?? null; } -/** Floor-cost alternatives (baseline + f2p), highest DPS first. */ +/** Floor + owned alternatives (baseline / f2p / owned), highest DPS first. */ export function baselineVariants(team: InvestmentTeam): InvestmentSim[] { return team.results - .filter((r) => r.kind === "baseline" || r.kind === "f2p") + .filter( + (r) => r.kind === "baseline" || r.kind === "f2p" || r.kind === "owned", + ) .slice() .sort((a, b) => b.dps - a.dps); } @@ -58,18 +60,21 @@ export function groupVerticalSimsByCost( .map(([cost, sims]) => ({ cost, sims })); } -/** First sim at exactly `cost`, or null. */ +/** First non-owned sim at exactly `cost`, or null. */ export function simAtExactCost( team: InvestmentTeam, cost: number, ): InvestmentSim | null { - return team.results.find((r) => r.cost === cost) ?? null; + return ( + team.results.find((r) => r.cost === cost && r.kind !== "owned") ?? null + ); } -/** Best DPS at exactly `cost`, or 0 when no result matches. */ +/** Best DPS at exactly `cost` (skips owned), or 0 when no result matches. */ export function exactCostDps(team: InvestmentTeam, cost: number): number { let bestDps = 0; for (const result of team.results) { + if (result.kind === "owned") continue; if (result.cost === cost) { bestDps = Math.max(bestDps, result.dps); } @@ -112,12 +117,14 @@ export function teamsMatchingTags( return teams.filter((t) => tags.every((tag) => t.characters.includes(tag))); } -/** Teams that have at least one sim at exactly `cost`. */ +/** Teams that have at least one non-owned sim at exactly `cost`. */ export function teamsWithExactCost( teams: InvestmentTeam[], cost: number, ): InvestmentTeam[] { - return teams.filter((t) => t.results.some((r) => r.cost === cost)); + return teams.filter((t) => + t.results.some((r) => r.cost === cost && r.kind !== "owned"), + ); } /** Unique costs across teams (prefer merge-time list; else scan). */ @@ -127,7 +134,10 @@ export function availableInvestmentCosts( if (data.available_costs?.length) return data.available_costs; const set = new Set(); for (const t of data.teams) { - for (const r of t.results) set.add(r.cost); + for (const r of t.results) { + if (r.kind === "owned") continue; + set.add(r.cost); + } } return [...set].sort((a, b) => a - b); } diff --git a/src/lib/types/investment.ts b/src/lib/types/investment.ts index 0aeed66..db89074 100644 --- a/src/lib/types/investment.ts +++ b/src/lib/types/investment.ts @@ -29,7 +29,7 @@ export interface InvestmentTeam { results: InvestmentSim[]; } -export type SimKind = "baseline" | "f2p" | "vertical" | "talent"; +export type SimKind = "baseline" | "f2p" | "owned" | "vertical" | "talent"; export interface InvestmentSim { /** Stable key: characters sorted, Char~C{cons}~{weapon}, joined by __ */ @@ -37,12 +37,13 @@ export interface InvestmentSim { /** * Human-readable label. * - baseline: full config (every char C / weapon / R) - * - f2p / vertical: diffs from baseline only (e.g. "Flins C1", "Aino C2") + * - f2p / owned / vertical: diffs from baseline only (e.g. "Flins C1", "Aino C2") */ label: string; /** * baseline = canonical starting build; * f2p = floor-cost alternative (free weapon / 4★ budget cons, etc.); + * owned = already-owned 5★ weapon alt (+1 cost, vs baseline — not a pull rung); * vertical = limited-pull upgrades (extra cost above floor); * talent = one-step talent drop from baseline (single char, one talent at 1). */ @@ -91,16 +92,49 @@ export interface CharacterBuild { substat_rolls_liquid?: Record; } +/** Windowed dropoff impact bands (same vocabulary as Builds upgrade tiers). */ +export type ImportanceImpactTier = + | "exceptional" + | "high" + | "solid" + | "modest" + | "negligible"; + /** Shape of `output/characters.json` (aggregate character summaries). */ export interface CharacterIndexFile { characters: Record; + /** Roster-level impact bucket floors + labels (also copied onto each character). */ + impact_tiers?: ImpactTiersMeta; +} + +/** One impact scale: inclusive % floors and display labels (k may be 3–5). */ +export interface ImpactTierScale { + bucket_count?: number | null; + floors: Partial>; + labels: Partial>; +} + +/** + * Merge-time impact buckets uploaded with character summaries. + * Each upgrade axis has its own floors (talents vs cons vs sigs vs artifacts). + */ +export interface ImpactTiersMeta { + talents: ImpactTierScale; + constellations: ImpactTierScale; + sig_weapons: ImpactTierScale; + artifacts: ImpactTierScale; } export interface CharacterIndex { key: string; + /** + * Roster impact bucket definitions (embedded on per-character CDN JSON so + * Builds can label stamped tiers without fetching the full index). + */ + impact_tiers?: ImpactTiersMeta; /** * Weapons ranked by distinct team count. Baseline weapons always count; - * F2P weapon alts only count teams where that f2p config beats baseline DPS; + * F2P / owned weapon alts only count teams where that config beats baseline DPS; * vertical/sig weapons still count every appearance. */ weapons: CharacterWeaponRank[]; @@ -140,6 +174,12 @@ export interface CharacterIndex { * excluded. */ vertical_importance?: CharacterVerticalImportance; + /** + * One-hot high-invest artifact gain vs mid baseline (30/14 on this character, + * others at pipeline mid OptimFull). Aggregated across teams at merge from + * `artifact_importance.py` reports. Negative per-team gains are floored to 0. + */ + artifact_importance?: CharacterArtifactImportance; /** * Editorial upgrade recommendations from a hand-authored guide. * Published separately from measured `*_importance` statistics; the Builds @@ -179,6 +219,11 @@ export interface CharacterTalentSlotImportance { min_pct_drop: number; /** Largest % DPS drop on any contributing team. */ max_pct_drop: number; + /** + * Merge-time impact bucket from the joint talent+cons pool cutoffs. + * Null when this slot was not scored. + */ + tier?: ImportanceImpactTier | null; } export interface CharacterTalentImportance { @@ -207,6 +252,11 @@ export interface CharacterVerticalGain { min_pct_gain: number; /** Largest % DPS gain on any contributing team. */ max_pct_gain: number; + /** + * Merge-time impact bucket from shared talent+cons cutoff floors. + * Null when this entry was not scored. + */ + tier?: ImportanceImpactTier | null; } export interface CharacterConsGain extends CharacterVerticalGain { @@ -226,6 +276,23 @@ export interface CharacterVerticalImportance { sig_weapons: CharacterSigGain[]; } +/** Gain from juicing this character's artifacts (high OptimFull) while teammates stay mid. */ +export interface CharacterArtifactImportance { + teams: number; + mean_pct_gain: number; + median_pct_gain: number; + min_pct_gain: number; + max_pct_gain: number; + /** + * Merge-time impact bucket from successive largest-dropoff cuts on mean_pct_gain. + * Null when no samples. + */ + tier?: ImportanceImpactTier | null; +} + +/** @deprecated Use {@link ImportanceImpactTier}. */ +export type ArtifactImportanceTier = ImportanceImpactTier; + export interface CharacterWeaponRank { key: string; teams: number; diff --git a/src/lib/ui/components/CostPopover.svelte b/src/lib/ui/components/CostPopover.svelte index f8582ca..51a17f6 100644 --- a/src/lib/ui/components/CostPopover.svelte +++ b/src/lib/ui/components/CostPopover.svelte @@ -15,4 +15,7 @@ Every limited copy of a five star character or weapon adds 1 cost to the team. Standard characters are assigned 0-cost at C2, and characters in the constellation selector get their C1 for free, so they are 1-cost at C1. + Owned weapon alternatives on team pages (other signatures, standard 5★ + weapons you already have) are also counted as +1 for comparison — they are + not pull recommendations. diff --git a/src/lib/ui/components/HoverTooltip.svelte b/src/lib/ui/components/HoverTooltip.svelte index 8627c4a..a117cde 100644 --- a/src/lib/ui/components/HoverTooltip.svelte +++ b/src/lib/ui/components/HoverTooltip.svelte @@ -16,7 +16,6 @@ const EDGE = 8; const GAP = 8; - const LEAVE_DELAY_MS = 120; const tooltipId = $props.id(); let tipEl: HTMLDivElement | undefined = $state(); @@ -26,25 +25,7 @@ let activeTriggerEl: HTMLElement | null = null; let open = $state(false); let detailOpen = $state(false); - let tipScrollable = $state(false); - - let triggerHovered = false; - let tipHovered = false; - let leaveTimer: ReturnType | null = null; - - function clearLeaveTimer() { - if (leaveTimer == null) return; - clearTimeout(leaveTimer); - leaveTimer = null; - } - - function scheduleHide() { - clearLeaveTimer(); - leaveTimer = setTimeout(() => { - leaveTimer = null; - if (!triggerHovered && !tipHovered) hideTip(); - }, LEAVE_DELAY_MS); - } + let truncated = $state(false); function updateTriggerDescription(trigger: HTMLElement | null, add: boolean) { if (!trigger) return; @@ -123,22 +104,10 @@ const tip = tipEl; if (!tip) return; - tip.style.maxHeight = ""; - tip.style.overflow = ""; - tipScrollable = false; - const t = trigger.getBoundingClientRect(); + const r = tip.getBoundingClientRect(); const vw = window.innerWidth; const vh = window.innerHeight; - const maxH = Math.max(0, vh - EDGE * 2); - - let r = tip.getBoundingClientRect(); - if (r.height > maxH) { - tip.style.maxHeight = `${maxH}px`; - tip.style.overflow = "auto"; - tipScrollable = true; - r = tip.getBoundingClientRect(); - } const aboveTop = t.top - r.height - GAP; const belowTop = t.bottom + GAP; @@ -158,6 +127,12 @@ tip.style.left = `${left}px`; } + function measureTruncation() { + const tip = tipEl; + if (!tip) return; + truncated = tip.scrollHeight > tip.clientHeight + 2; + } + function showTip(trigger: HTMLElement) { if (detailOpen) return; tipTriggerEl = trigger; @@ -165,15 +140,15 @@ updateTriggerDescription(trigger, true); requestAnimationFrame(() => { place(trigger); + measureTruncation(); + // Truncation adds a footer; re-place once layout settles. + if (truncated) requestAnimationFrame(() => place(trigger)); }); } function hideTip() { - clearLeaveTimer(); - triggerHovered = false; - tipHovered = false; - tipScrollable = false; open = false; + truncated = false; if (!detailOpen) updateTriggerDescription(tipTriggerEl, false); tipTriggerEl = null; } @@ -195,17 +170,14 @@ const trigger = event.currentTarget as HTMLElement; if (isInteractiveDescendant(event.target, trigger)) return; - // Every tip opens its sheet on tap / click so touch users get the same - // affordance everywhere. + // Every tip opens its sheet on tap / click, truncated or not, so touch + // users get the same affordance everywhere. event.preventDefault(); event.stopPropagation(); - clearLeaveTimer(); tipTriggerEl = null; activeTriggerEl = trigger; - triggerHovered = false; - tipHovered = false; open = false; - tipScrollable = false; + truncated = false; updateTriggerDescription(trigger, false); detailOpen = true; await tick(); @@ -231,28 +203,13 @@ const trigger = tip.parentElement; if (!trigger) return; - // Portal to body so `position: fixed` is viewport-relative. Transformed - // ancestors (e.g. TeamCardHand fan cards) otherwise become the containing - // block and viewport coords from getBoundingClientRect land in the wrong place. + // Portal to body so `position: fixed` is viewport-relative. Transformed / + // overflow:hidden ancestors otherwise clip the tip or become the containing + // block so viewport coords from getBoundingClientRect land wrong. document.body.appendChild(tip); - const onTriggerEnter = () => { - triggerHovered = true; - clearLeaveTimer(); - showTip(trigger); - }; - const onTriggerLeave = () => { - triggerHovered = false; - scheduleHide(); - }; - const onTipEnter = () => { - tipHovered = true; - clearLeaveTimer(); - }; - const onTipLeave = () => { - tipHovered = false; - scheduleHide(); - }; + const onEnter = () => showTip(trigger); + const onLeave = () => hideTip(); const onClick = (event: Event) => { void openDetail(event); }; @@ -282,12 +239,10 @@ } }; - trigger.addEventListener("pointerenter", onTriggerEnter); - trigger.addEventListener("pointerleave", onTriggerLeave); - tip.addEventListener("pointerenter", onTipEnter); - tip.addEventListener("pointerleave", onTipLeave); - trigger.addEventListener("focusin", onTriggerEnter); - trigger.addEventListener("focusout", onTriggerLeave); + trigger.addEventListener("pointerenter", onEnter); + trigger.addEventListener("pointerleave", onLeave); + trigger.addEventListener("focusin", onEnter); + trigger.addEventListener("focusout", onLeave); trigger.addEventListener("click", onClick); window.addEventListener("scroll", reposition, true); window.addEventListener("resize", reposition); @@ -295,16 +250,13 @@ window.addEventListener("keydown", onKey); return () => { - clearLeaveTimer(); updateTriggerDescription(tipTriggerEl, false); updateTriggerDescription(activeTriggerEl, false); tipTriggerEl = null; - trigger.removeEventListener("pointerenter", onTriggerEnter); - trigger.removeEventListener("pointerleave", onTriggerLeave); - tip.removeEventListener("pointerenter", onTipEnter); - tip.removeEventListener("pointerleave", onTipLeave); - trigger.removeEventListener("focusin", onTriggerEnter); - trigger.removeEventListener("focusout", onTriggerLeave); + trigger.removeEventListener("pointerenter", onEnter); + trigger.removeEventListener("pointerleave", onLeave); + trigger.removeEventListener("focusin", onEnter); + trigger.removeEventListener("focusout", onLeave); trigger.removeEventListener("click", onClick); window.removeEventListener("scroll", reposition, true); window.removeEventListener("resize", reposition); @@ -327,24 +279,26 @@ @@ -390,7 +344,8 @@ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); opacity: 0; visibility: hidden; - pointer-events: none; + max-height: min(11rem, 40dvh); + overflow: hidden; transition: opacity 0.15s ease, visibility 0.15s ease; @@ -401,14 +356,37 @@ visibility: visible; } - .hover-tooltip-interactive { - pointer-events: auto; - } - .hover-tooltip-body { display: block; } + .hover-tooltip-truncated { + padding-bottom: 1.35rem; + } + + .hover-tooltip-fade { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 2.75rem; + pointer-events: none; + background: linear-gradient(to top, var(--foreground-mid) 35%, transparent); + } + + .hover-tooltip-more { + position: absolute; + left: 0.65rem; + right: 0.65rem; + bottom: 0.35rem; + z-index: 1; + font-size: 9px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: color-mix(in srgb, var(--background-color) 72%, transparent); + } + /* Callers may still pass rem utilities; keep hover tip from scaling up. */ .hover-tooltip :global(.text-sm), .hover-tooltip :global(.text-xs), diff --git a/src/lib/upgrade-priority.test.ts b/src/lib/upgrade-priority.test.ts index 105f549..0d1c654 100644 --- a/src/lib/upgrade-priority.test.ts +++ b/src/lib/upgrade-priority.test.ts @@ -7,6 +7,7 @@ import { TALENT_UPGRADE, classifyUpgradeImpact, primaryUpgradePct, + resolveUpgradeImpact, type UpgradeImpactLadder, } from "./upgrade-priority.ts"; @@ -63,6 +64,31 @@ describe("upgrade priority", () => { ); }); + it("prefers stamped CDN tiers over the ladder", () => { + assert.deepEqual( + resolveUpgradeImpact("solid", 30, ladder, { + floors: { + exceptional: 20, + high: 10, + solid: 5, + negligible: 0, + }, + labels: { + exceptional: "Exceptional impact", + high: "High impact", + solid: "Solid impact", + modest: "Modest impact", + negligible: "Negligible impact", + }, + }), + { tier: "solid", label: "Solid impact" }, + ); + assert.deepEqual(resolveUpgradeImpact(null, 31, ladder), { + tier: "exceptional", + label: "Exceptional", + }); + }); + it("ships ladders that descend and cover zero", () => { const shipped = { TALENT_UPGRADE, diff --git a/src/lib/upgrade-priority.ts b/src/lib/upgrade-priority.ts index bfa653b..d3b6833 100644 --- a/src/lib/upgrade-priority.ts +++ b/src/lib/upgrade-priority.ts @@ -5,12 +5,25 @@ * Call sites pass {@link primaryUpgradePct}(mean, median) so a skewed high * mean still surfaces when the median alone would understate the upgrade. * + * Measured talent / cons / sig / artifact rows prefer merge-stamped + * {@link ImportanceImpactTier} from CDN JSON; these ladders remain the + * fallback when `tier` is missing (stale payload, guide rows, level-90). + * * Each Builds section passes its own ordered threshold ladder, so both the * number of bands and their labels remain section-specific. */ +import type { + ImpactTierScale, + ImportanceImpactTier, +} from "$lib/types/investment"; + export type UpgradeTier = - "exceptional" | "high" | "solid" | "modest" | "negligible"; + | "exceptional" + | "high" + | "solid" + | "modest" + | "negligible"; export interface UpgradeImpactBand { /** Inclusive lower bound for this band. */ @@ -26,6 +39,15 @@ export interface UpgradeImpact { export type UpgradeImpactLadder = readonly UpgradeImpactBand[]; +/** Default labels for merge-stamped impact tiers (3–5 bands). */ +export const MERGED_IMPACT_LABELS: Record = { + exceptional: "Exceptional impact", + high: "High impact", + solid: "Solid impact", + modest: "Modest impact", + negligible: "Negligible impact", +}; + export const TALENT_UPGRADE: UpgradeImpactLadder = [ { minPct: 10, tier: "exceptional", label: "Essential to upgrade" }, { minPct: 7.5, tier: "high", label: "Highly recommended" }, @@ -91,3 +113,23 @@ export function classifyUpgradeImpact( } return { tier: band.tier, label: band.label }; } + +/** + * Prefer merge-stamped CDN tiers (+ roster labels/floors); fall back to the + * section ladder when the payload has no tier yet. + */ +export function resolveUpgradeImpact( + stamped: ImportanceImpactTier | null | undefined, + pct: number, + ladder: UpgradeImpactLadder, + scale?: ImpactTierScale | null, +): UpgradeImpact { + if (stamped) { + return { + tier: stamped, + label: + scale?.labels?.[stamped] ?? MERGED_IMPACT_LABELS[stamped] ?? stamped, + }; + } + return classifyUpgradeImpact(pct, ladder); +} diff --git a/src/routes/teams/[slug]/+page.svelte b/src/routes/teams/[slug]/+page.svelte index c7e530e..bbd9db8 100644 --- a/src/routes/teams/[slug]/+page.svelte +++ b/src/routes/teams/[slug]/+page.svelte @@ -250,8 +250,8 @@

Baseline variants

- Baseline-cost options, ranked by DPS. Highlighted rows are within 2.5% - DPS of the best baseline-cost variant. + Floor-cost options and owned +1 weapons, ranked by DPS vs baseline. + Highlighted rows are within 2.5% DPS of the best variant in this list.

{:else} - + {/if} diff --git a/src/routes/characters/[slug]/+page.svelte b/src/routes/characters/[slug]/+page.svelte index 6ff68b9..30c711a 100644 --- a/src/routes/characters/[slug]/+page.svelte +++ b/src/routes/characters/[slug]/+page.svelte @@ -67,13 +67,21 @@ } from "$lib/equipment-data"; import { MAIN_STAT_SLOTS, + buildExamples, constellationPrioritySection, + exampleFeaturedAndMates, + exampleHasHighConfig, + exampleRelevantSheetRows, + exampleTeamKeys, levelPrioritySection, rankWeaponsByRarityAndTeams, recommendedSubstatsFromBuilds, sigWeaponPrioritySection, talentPrioritySection, } from "$lib/character-builds"; + import { formatSheetStat } from "$lib/build-stats"; + import StatRow from "$lib/ui/components/StatRow.svelte"; + import IconCog from "$lib/ui/icons/IconCog.svelte"; import { artifactIconUrl, skillIconUrl, @@ -480,12 +488,13 @@ return kitLinkIds.has(ref) ? `#kit-${ref}` : null; } - /** Weapons: higher rarity first, then team usage (stable within ties). */ + /** Weapons: rarity → BT strength → teams → measured sigs → name. */ let rankedWeapons = $derived.by(() => { $equipmentVersion; return rankWeaponsByRarityAndTeams( builds?.weapons, (key) => weaponByKey.get(key)?.stars ?? 0, + builds?.vertical_importance?.sig_weapons?.map((s) => s.key), ); }); @@ -504,6 +513,32 @@ let consSection = $derived(constellationPrioritySection(builds)); let sigSection = $derived(sigWeaponPrioritySection(builds)); + let exampleBuilds = $derived(buildExamples(builds)); + /** User pick among diversified examples; empty → highest-DPS default. */ + let examplePick = $state(""); + let exampleMenuOpen = $state(false); + let activeExample = $derived.by(() => { + const list = exampleBuilds; + if (!list.length) return null; + return list.find((e) => e.state_key === examplePick) ?? list[0] ?? null; + }); + let exampleAlts = $derived( + exampleBuilds.filter((e) => e.state_key !== activeExample?.state_key), + ); + let exampleShowHigh = $derived( + activeExample ? exampleHasHighConfig(activeExample) : false, + ); + let exampleSheetRowsMid = $derived.by(() => { + $equipmentVersion; + return activeExample + ? exampleRelevantSheetRows(activeExample, "mid") + : []; + }); + let exampleSheetRowsHigh = $derived.by(() => { + $equipmentVersion; + if (!activeExample || !exampleShowHigh) return []; + return exampleRelevantSheetRows(activeExample, "high"); + }); {#snippet descriptionBlock( @@ -1450,6 +1485,263 @@ {/if} + {#if activeExample} + {@const activeSplit = exampleFeaturedAndMates( + exampleTeamKeys(activeExample), + goodKey, + )} +
+

Build example

+

+ Highest-DPS sim config for this character. Sheet stats shown + for mains and assigned OptimFull rolls only. +

+
+
+
+
+ + {#each activeSplit.mates as mateKey, i (mateKey ?? `active-empty-${i}`)} +
+ {#if mateKey} + {@const mate = goodKeyMap.get(mateKey)} + {#if mate} + + {/if} + {/if} +
+ {/each} +
+ + {#if exampleAlts.length > 0} +
+
+
    + {#each exampleAlts as example, ti (example.state_key)} + {@const split = exampleFeaturedAndMates( + exampleTeamKeys(example), + goodKey, + )} +
  1. + +
  2. + {/each} +
+
+
+ {/if} +
+ {#if exampleAlts.length > 0} + + {/if} +
+ +
+
+
+ +
+
+

+ +

+

+ R{activeExample.weapon.refinement} +

+
+ +
+ {#key $equipmentVersion} + {@const set = artifactSetByKey.get( + activeExample.set.key, + )} + {@const setIcon = set + ? artifactIconUrl(set.icon) + : null} +
+
+ {#if setIcon} + + {/if} +
+
+

+ {set?.name ?? activeExample.set.key} +

+

+ {activeExample.set.count}pc +

+
+ +
+ {#if activeExample.set2} + {@const set2 = artifactSetByKey.get( + activeExample.set2, + )} + {@const set2Icon = set2 + ? artifactIconUrl(set2.icon) + : null} + {@const set2Count = activeExample.set2_count ?? 2} +
+
+ {#if set2Icon} + + {/if} +
+
+

+ {set2?.name ?? activeExample.set2} +

+

{set2Count}pc

+
+ +
+ {/if} + {/key} +
+ + + {#if exampleSheetRowsMid.length > 0} +
+ {#if exampleShowHigh} +

Baseline

+ {/if} +
+ {#each exampleSheetRowsMid as row (row.key)} + + {/each} +
+
+ {/if} + {#if exampleShowHigh && exampleSheetRowsHigh.length > 0} +
+

High artifacts

+
+ {#each exampleSheetRowsHigh as row (row.key)} + + {/each} +
+
+ {/if} +
+
+ {/if} + {#if builds.notes}

Notes

@@ -1978,6 +2270,260 @@ } } + .example-build { + display: flex; + flex-direction: column; + gap: var(--space-3); + max-width: 22rem; + } + + .example-picker-primary { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + column-gap: 0.5rem; + align-items: start; + } + + .example-picker-main { + display: contents; + } + + .example-picker-grid--selected { + grid-column: 1; + grid-row: 1; + } + + .example-picker-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.3rem; + width: 100%; + } + + .example-picker-slot { + position: relative; + min-width: 0; + overflow: hidden; + border-radius: var(--radius-md); + border: var(--border-width) solid rgba(255, 255, 255, 0.14); + background: var(--background-mid); + } + + .example-picker-slot--featured { + border-color: color-mix( + in srgb, + var(--accent-1) 55%, + rgba(255, 255, 255, 0.2) + ); + } + + .example-picker-slot--spacer { + visibility: hidden; + border: none; + background: transparent; + } + + .example-picker-gear { + grid-column: 2; + grid-row: 1; + align-self: center; + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + border-radius: var(--radius-md); + border: var(--border-width) solid rgba(255, 255, 255, 0.14); + background: transparent; + color: var(--foreground-mid); + cursor: pointer; + transition: + color 280ms ease, + border-color 280ms ease; + } + + .example-picker-gear :global(svg) { + transition: transform 480ms cubic-bezier(0.22, 1, 0.36, 1); + transform-origin: center; + } + + .example-picker-gear:hover, + .example-picker-gear.open { + color: var(--accent-1); + border-color: color-mix( + in srgb, + var(--accent-1) 45%, + rgba(255, 255, 255, 0.14) + ); + } + + .example-picker-gear.open :global(svg) { + transform: rotate(90deg); + } + + .example-picker-alts-shell { + grid-column: 1; + grid-row: 2; + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 520ms cubic-bezier(0.22, 1, 0.36, 1); + } + + .example-picker-alts-shell.open { + grid-template-rows: 1fr; + } + + .example-picker-alts-clip { + overflow: hidden; + min-height: 0; + } + + .example-picker-alts { + display: flex; + flex-direction: column; + gap: 0.45rem; + margin: 0; + padding: 0.45rem 0 0.15rem; + list-style: none; + } + + .example-picker-alt-wrap { + transform: translateY(-0.65rem); + opacity: 0.25; + transition: + transform 520ms cubic-bezier(0.22, 1, 0.36, 1), + opacity 420ms ease; + transition-delay: calc( + (var(--alt-count, 1) - 1 - var(--i, 0)) * 45ms + ); + } + + .example-picker-alts-shell.open .example-picker-alt-wrap { + transform: translateY(0); + opacity: 1; + transition-delay: calc(var(--i, 0) * 55ms); + } + + .example-picker-alt { + display: block; + width: 100%; + padding: 0; + border: none; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; + } + + .example-picker-mate-row { + grid-column: 2 / -1; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.3rem; + padding: 0.28rem; + border-radius: var(--radius-md); + border: var(--border-width) solid rgba(255, 255, 255, 0.16); + background: color-mix(in srgb, var(--background-mid) 88%, transparent); + } + + .example-picker-alt:hover .example-picker-mate-row, + .example-picker-alt:focus-visible .example-picker-mate-row { + border-color: color-mix( + in srgb, + var(--accent-1) 50%, + rgba(255, 255, 255, 0.2) + ); + } + + .example-equip { + display: flex; + flex-direction: column; + gap: 0.4rem; + } + + .example-equip-row { + display: flex; + align-items: center; + gap: 0.55rem; + min-width: 0; + } + + .example-equip-icon-wrap { + flex: 0 0 auto; + width: 2.25rem; + height: 2.25rem; + border-radius: var(--radius-md); + overflow: hidden; + background: var(--background-mid); + } + + .example-equip-icon { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + } + + .example-equip-copy { + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.05rem; + } + + .example-equip-copy .meta-name { + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .example-equip-copy .meta-sub { + margin: 0; + } + + .example-build-meta { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.35rem 0.75rem; + min-width: 0; + } + + @media (prefers-reduced-motion: reduce) { + .example-picker-gear :global(svg), + .example-picker-alts-shell, + .example-picker-alt-wrap { + transition: none; + } + } + + .example-team-link { + color: var(--accent-1); + font-size: var(--text-sm); + text-decoration: none; + } + + .example-team-link:hover { + text-decoration: underline; + } + + .example-stat-block { + display: flex; + flex-direction: column; + gap: 0.35rem; + } + + .example-stat-block .meta-sub { + margin: 0; + } + + .example-stat-list { + display: flex; + flex-direction: column; + gap: 0.2rem; + } + .kit-list { display: flex; flex-direction: column; diff --git a/src/routes/dev/ui/+page.svelte b/src/routes/dev/ui/+page.svelte index 59d7277..b2cefe6 100644 --- a/src/routes/dev/ui/+page.svelte +++ b/src/routes/dev/ui/+page.svelte @@ -32,6 +32,7 @@ import ArtifactTooltip from "$lib/ui/components/ArtifactTooltip.svelte"; import HoverTooltip from "$lib/ui/components/HoverTooltip.svelte"; import UsageSeriesChart from "$lib/ui/components/UsageSeriesChart.svelte"; + import { exampleFeaturedAndMates } from "$lib/character-builds"; import IconInfo from "$lib/ui/icons/IconInfo.svelte"; import IconFilter from "$lib/ui/icons/IconFilter.svelte"; import IconCog from "$lib/ui/icons/IconCog.svelte"; @@ -230,6 +231,107 @@ } as AbyssTeam; }); + // ── Build-example team picker (row + gear expand) ───────────────────── + type DemoExampleTeam = { + id: string; + members: string[]; + high: boolean; + stats: { key: string; label: string; value: string; iconKey: string }[]; + }; + + let examplePickId = $state(""); + let exampleMenuOpen = $state(false); + let examplePickerPool = $derived($charactersOwned.slice(0, 12)); + let examplePickerMap = $derived( + new Map(examplePickerPool.map((c) => [c.name_id ?? c.name, c])), + ); + let examplePickerFeaturedId = $derived( + examplePickerPool[0]?.name_id ?? examplePickerPool[0]?.name ?? "", + ); + let demoExampleTeams = $derived.by((): DemoExampleTeam[] => { + const pool = examplePickerPool; + if (pool.length < 7) return []; + const id = (c: (typeof pool)[number]) => c.name_id ?? c.name; + const featured = id(pool[0]!); + const teams: DemoExampleTeam[] = [ + { + id: "ex-a", + members: [featured, id(pool[1]!), id(pool[2]!), id(pool[3]!)], + high: true, + stats: [ + { key: "atk", label: "ATK", value: "2,184", iconKey: "atk" }, + { + key: "critRate", + label: "CRIT Rate", + value: "72.4%", + iconKey: "critRate_", + }, + { + key: "critDMG", + label: "CRIT DMG", + value: "168.2%", + iconKey: "critDMG_", + }, + { + key: "pyro_dmg_", + label: "Pyro DMG Bonus", + value: "46.6%", + iconKey: "pyro_dmg_", + }, + ], + }, + { + id: "ex-b", + members: [featured, id(pool[4]!), id(pool[5]!), id(pool[2]!)], + high: false, + stats: [ + { key: "atk", label: "ATK", value: "1,942", iconKey: "atk" }, + { + key: "enerRech", + label: "Energy Recharge", + value: "148.3%", + iconKey: "enerRech_", + }, + { + key: "critRate", + label: "CRIT Rate", + value: "61.1%", + iconKey: "critRate_", + }, + ], + }, + { + id: "ex-c", + members: [featured, id(pool[6]!), id(pool[3]!), id(pool[5]!)], + high: false, + stats: [ + { + key: "eleMas", + label: "Elemental Mastery", + value: "612", + iconKey: "eleMas", + }, + { key: "atk", label: "ATK", value: "1,510", iconKey: "atk" }, + { + key: "enerRech", + label: "Energy Recharge", + value: "132.0%", + iconKey: "enerRech_", + }, + ], + }, + ]; + return teams; + }); + let activeDemoExample = $derived.by(() => { + const list = demoExampleTeams; + if (!list.length) return null; + return list.find((t) => t.id === examplePickId) ?? list[0] ?? null; + }); + let demoExampleAlts = $derived( + demoExampleTeams.filter((t) => t.id !== activeDemoExample?.id), + ); + let segment = $state<"roster" | "meta">("roster"); let selectDemo = $state("stygian"); let chipOn = $state(true); @@ -884,6 +986,159 @@
+ + +