Skip to content
7 changes: 7 additions & 0 deletions src/lib/build-stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ export const SUBSTAT_ROLL_VALUE: Record<string, number> = {
critDMG_: 0.0662,
};

/** Artifact substat keys only (excludes elemental DMG / heal / physical mains). */
export const ARTIFACT_SUBSTAT_KEYS = new Set(Object.keys(SUBSTAT_ROLL_VALUE));

export function isArtifactSubstatKey(key: string): boolean {
return ARTIFACT_SUBSTAT_KEYS.has(key);
}

const WEAPON_PROP_TO_GOOD: Record<string, string> = {
FIGHT_PROP_HP_PERCENT: "hp_",
FIGHT_PROP_ATTACK_PERCENT: "atk_",
Expand Down
84 changes: 80 additions & 4 deletions src/lib/types/investment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export interface InvestmentTeam {
results: InvestmentSim[];
}

export type SimKind = "baseline" | "f2p" | "vertical";
export type SimKind = "baseline" | "f2p" | "vertical" | "talent";

export interface InvestmentSim {
/** Stable key: characters sorted, Char~C{cons}~{weapon}, joined by __ */
Expand All @@ -43,7 +43,8 @@ export interface InvestmentSim {
/**
* baseline = canonical starting build;
* f2p = floor-cost alternative (free weapon / 4★ budget cons, etc.);
* vertical = limited-pull upgrades (extra cost above floor).
* vertical = limited-pull upgrades (extra cost above floor);
* talent = one-step talent drop from baseline (single char, one talent at 1).
*/
kind: SimKind;
/** Total cost in limited5 copies (baseline + upgrades). */
Expand Down Expand Up @@ -98,8 +99,9 @@ export interface CharacterIndexFile {
export interface CharacterIndex {
key: string;
/**
* Weapons ranked by how many teams they appear on across all sim kinds
* (includes signature weapons from vertical). `teams` = distinct team_key count.
* Weapons ranked by distinct team count. Baseline weapons always count;
* F2P weapon alts only count teams where that f2p config beats baseline DPS;
* vertical/sig weapons still count every appearance.
*/
weapons: CharacterWeaponRank[];
/**
Expand All @@ -121,10 +123,84 @@ export interface CharacterIndex {
* within a team, then average those team-means across teams.
*/
substat_rolls_liquid: CharacterLiquidSubstats;
/**
* How much DPS drops when each talent is lowered to 1 (others stay baseline),
* aggregated across teams. Character level is tracked separately.
*/
talent_importance?: CharacterTalentImportance;
/**
* How much DPS drops when the character runs at level 80 (talents stay
* baseline), aggregated across teams.
*/
level_importance?: CharacterLevelImportance;
/**
* One-step vertical gains: constellations are stepwise vs the previous
* constellation (C2 vs C1); sig weapons are vs baseline. Combined cons+sig
* and multi-char verticals are excluded.
*/
vertical_importance?: CharacterVerticalImportance;
/** Optional editorial blurb from hand-authored guide (merge-time). */
notes?: string;
}

export type TalentSlot = "auto" | "skill" | "burst";

export interface CharacterTalentSlotImportance {
/** Average % DPS drop vs baseline across teams. */
mean_pct_drop: number;
/** Median % DPS drop vs baseline across teams. */
median_pct_drop: number;
/** Smallest % DPS drop on any contributing team. */
min_pct_drop: number;
/** Largest % DPS drop on any contributing team. */
max_pct_drop: number;
}

export interface CharacterTalentImportance {
/** Teams with baseline + all three talent drops for this character. */
teams: number;
auto: CharacterTalentSlotImportance;
skill: CharacterTalentSlotImportance;
burst: CharacterTalentSlotImportance;
/** Talent slots ranked by mean % drop (highest first). */
priority: TalentSlot[];
}

export interface CharacterLevelImportance extends CharacterTalentSlotImportance {
/** Teams with a level-80 drop sample for this character. */
teams: number;
}

export interface CharacterVerticalGain {
/** Teams that contributed a one-step vertical sample for this entry. */
teams: number;
/** Average % DPS gain vs that team's baseline. */
mean_pct_gain: number;
/** Median % DPS gain vs that team's baseline. */
median_pct_gain: number;
/** Smallest % DPS gain on any contributing team. */
min_pct_gain: number;
/** Largest % DPS gain on any contributing team. */
max_pct_gain: number;
}

export interface CharacterConsGain extends CharacterVerticalGain {
/** Absolute constellation level reached (e.g. 1 for C1). Gain is vs C{cons-1}. */
cons: number;
}

export interface CharacterSigGain extends CharacterVerticalGain {
/** Signature weapon GOOD key. */
key: string;
}

export interface CharacterVerticalImportance {
/** Cons-only upgrades, sorted by cons ascending. */
constellations: CharacterConsGain[];
/** Sig-weapon-only upgrades, sorted by mean gain descending. */
sig_weapons: CharacterSigGain[];
}

export interface CharacterWeaponRank {
key: string;
teams: number;
Expand Down
20 changes: 10 additions & 10 deletions src/lib/ui/components/ArtifactTooltip.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,19 @@
let resolved = $derived(
set ?? (setKey ? (artifactSetByKey.get(setKey) ?? null) : null),
);

let title = $derived(
resolved
? pieceCount != null
? `${resolved.name} · ${pieceCount}pc`
: resolved.name
: "",
);
</script>

{#if resolved}
<HoverTooltip
class="max-w-64"
label={pieceCount != null
? `${resolved.name} · ${pieceCount}pc`
: resolved.name}
>
<div class="tip-detail-text font-medium">
{resolved.name}{#if pieceCount != null}
{" "}· {pieceCount}pc{/if}
</div>
<HoverTooltip class="max-w-64" label={title}>
<div class="tip-detail-text font-medium">{title}</div>
{#each resolved.bonuses as bonus}
{#if pieceCount == null || bonus.needCount <= pieceCount}
<div class="mt-1.5">
Expand Down
62 changes: 54 additions & 8 deletions src/lib/ui/components/HoverTooltip.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
let tipEl: HTMLDivElement | undefined = $state();
let sheetRootEl: HTMLDivElement | undefined = $state();
let sheetEl: HTMLDivElement | undefined = $state();
let tipTriggerEl: HTMLElement | null = null;
let activeTriggerEl: HTMLElement | null = null;
let open = $state(false);
let detailOpen = $state(false);
Expand Down Expand Up @@ -130,7 +131,9 @@

function showTip(trigger: HTMLElement) {
if (detailOpen) return;
tipTriggerEl = trigger;
open = true;
updateTriggerDescription(trigger, true);
requestAnimationFrame(() => {
place(trigger);
measureTruncation();
Expand All @@ -141,14 +144,38 @@

function hideTip() {
open = false;
if (!detailOpen) updateTriggerDescription(tipTriggerEl, false);
tipTriggerEl = null;
}

function isInteractiveDescendant(
target: EventTarget | null,
trigger: HTMLElement,
): boolean {
if (!(target instanceof Element)) return false;
const interactive = target.closest(
'a[href], button, input, select, textarea, summary, [role="button"]',
);
return Boolean(
interactive && interactive !== trigger && trigger.contains(interactive),
);
}

async function openDetail(event: Event) {
const trigger = event.currentTarget as HTMLElement;
if (isInteractiveDescendant(event.target, trigger)) return;

// Measure truncation even if the hover tip never opened (e.g. tap).
place(trigger);
measureTruncation();
if (!truncated) return;

event.preventDefault();
event.stopPropagation();
activeTriggerEl = event.currentTarget as HTMLElement | null;
updateTriggerDescription(activeTriggerEl, true);
tipTriggerEl = null;
activeTriggerEl = trigger;
open = false;
updateTriggerDescription(trigger, false);
detailOpen = true;
await tick();
if (sheetRootEl && sheetRootEl.parentElement !== document.body) {
Expand All @@ -159,7 +186,6 @@

async function closeDetail() {
const trigger = activeTriggerEl;
updateTriggerDescription(trigger, false);
detailOpen = false;
await tick();
trigger?.focus();
Expand Down Expand Up @@ -221,7 +247,9 @@
window.addEventListener("keydown", onKey);

return () => {
updateTriggerDescription(tipTriggerEl, false);
updateTriggerDescription(activeTriggerEl, false);
tipTriggerEl = null;
trigger.removeEventListener("pointerenter", onEnter);
trigger.removeEventListener("pointerleave", onLeave);
trigger.removeEventListener("focusin", onEnter);
Expand Down Expand Up @@ -300,6 +328,9 @@

<style>
.hover-tooltip {
/* Fixed px — tip chrome stays compact; rem utilities would track root/body. */
font-size: 11px;
line-height: 1.35;
white-space: normal;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
opacity: 0;
Expand Down Expand Up @@ -340,13 +371,28 @@
right: 0.65rem;
bottom: 0.35rem;
z-index: 1;
font-size: 0.6rem;
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),
.hover-tooltip :global(.text-\[0\.65rem\]),
.hover-tooltip :global(.tip-detail-text) {
font-size: 11px;
line-height: 1.35;
}

.hover-tooltip :global(.text-\[0\.65rem\]),
.hover-tooltip :global(.tip-detail-text--small) {
font-size: 10px;
line-height: 1.4;
}

.tip-sheet-root {
position: fixed;
inset: 0;
Expand Down Expand Up @@ -390,21 +436,21 @@
flex: 1 1 auto;
}

/* Readable sizes in the sheet regardless of caller utility classes. */
/* Readable sheet sizes in px — larger than hover tip, not rem-scaled. */
.tip-sheet-body :global(.tip-detail-text) {
font-size: 0.9rem;
font-size: 14px;
line-height: 1.35;
}

.tip-sheet-body :global(.tip-detail-text.tip-detail-text--small) {
font-size: 0.8rem;
font-size: 13px;
line-height: 1.45;
}

.tip-sheet-close {
align-self: flex-end;
flex-shrink: 0;
font-size: var(--text-xs);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
Expand Down
83 changes: 83 additions & 0 deletions src/lib/upgrade-priority.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* Qualitative upgrade tiers from a median % DPS impact
* (gain for cons/sig, drop for talent/level).
*
* Each Builds section passes its own cutoffs + labels via
* {@link UpgradeImpactConfig}.
*/

export type UpgradeTier =
| "highly_recommended"
| "recommended"
| "inconsequential";

export interface UpgradeImpactConfig {
/** |median %| ≥ this → highly_recommended */
highPct: number;
/** |median %| ≥ this → recommended (else inconsequential) */
recommendedPct: number;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
labels: Record<UpgradeTier, string>;
}

export interface UpgradeImpact {
tier: UpgradeTier;
label: string;
}

/** Default upgrade wording (talents / cons / signature). */
export const DEFAULT_UPGRADE_LABELS: Record<UpgradeTier, string> = {
highly_recommended: "Highly recommended to upgrade",
recommended: "Recommended to upgrade",
inconsequential: "Largely inconsequential",
};

export const TALENT_UPGRADE: UpgradeImpactConfig = {
highPct: 10,
recommendedPct: 4,
labels: DEFAULT_UPGRADE_LABELS,
};

export const CONSTELLATION_UPGRADE: UpgradeImpactConfig = {
highPct: 20,
recommendedPct: 8,
labels: {
highly_recommended: "High impact constellation",
recommended: "Mid impact constellation",
inconsequential: "Low impact constellation",
},
};

export const SIGNATURE_UPGRADE: UpgradeImpactConfig = {
highPct: 20,
recommendedPct: 8,
labels: {
highly_recommended: "High impact weapon",
recommended: "Mid impact weapon",
inconsequential: "Low impact weapon",
},
};

export const LEVEL_UPGRADE: UpgradeImpactConfig = {
highPct: 5,
recommendedPct: 2,
labels: {
highly_recommended: "Highly recommended to level to 90",
recommended: "Recommended to level to 90",
inconsequential: "Leveling to 90 is inconsequential",
},
};

/** Classify |pct| into a tier + label using section-specific cutoffs. */
export function classifyUpgradeImpact(
pct: number,
config: UpgradeImpactConfig,
): UpgradeImpact {
const abs = Math.abs(pct);
const tier: UpgradeTier =
abs >= config.highPct
? "highly_recommended"
: abs >= config.recommendedPct
? "recommended"
: "inconsequential";
return { tier, label: config.labels[tier] };
}
2 changes: 1 addition & 1 deletion src/routes/characters/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
<PageShell class="gap-6 {$animationsEnabled ? '' : 'no-page-anim'}">
<header class="page-head">
<h1 class="page-title">Characters</h1>
<p class="page-hint">Tap a character for details</p>
<p class="page-hint">View character build guides</p>
</header>

<CharacterFilterBar
Expand Down
Loading
Loading