From 194e4666b8d175385dc838af458baf469ffd2252 Mon Sep 17 00:00:00 2001 From: An Lou Date: Wed, 12 Aug 2026 14:02:51 -0500 Subject: [PATCH 01/11] stat goals --- src/lib/build-stats.ts | 62 +- src/lib/character-builds.test.ts | 134 ++++ src/lib/character-builds.ts | 242 ++++++- src/lib/equipment-data.ts | 10 + src/lib/types/database.types.ts | 49 ++ src/lib/types/investment.ts | 61 ++ .../ui/components/InvestmentBuildCard.svelte | 51 +- src/lib/ui/icons/IconFileSearch.svelte | 22 + src/routes/characters/[slug]/+page.svelte | 409 +++++++++++ src/routes/dev/ui/+page.svelte | 650 +++++++++++++++++- src/routes/teams/[slug]/+page.svelte | 7 +- src/routes/teams/configs/[slug]/+page.svelte | 4 +- 12 files changed, 1689 insertions(+), 12 deletions(-) create mode 100644 src/lib/ui/icons/IconFileSearch.svelte diff --git a/src/lib/build-stats.ts b/src/lib/build-stats.ts index 99e093b..cc6c30d 100644 --- a/src/lib/build-stats.ts +++ b/src/lib/build-stats.ts @@ -69,10 +69,68 @@ export const SUBSTAT_ROLL_VALUE: Record = { /** Artifact substat keys only (excludes elemental DMG / heal / physical mains). */ export const ARTIFACT_SUBSTAT_KEYS = new Set(Object.keys(SUBSTAT_ROLL_VALUE)); +/** + * Max rolls of one substat on one piece for goal display. OptimFull ignores + * piece mains and can over-allocate — UI clamps to 4 × eligible pieces + * (e.g. EM/EM/EM → 8 EM on flower+plume; ATK/ATK/CR → 16 CR max). + */ +export const MAX_SUBSTAT_ROLLS_PER_PIECE = 4; + export function isArtifactSubstatKey(key: string): boolean { return ARTIFACT_SUBSTAT_KEYS.has(key); } +export type MainStatSlots = { + sands?: string; + goblet?: string; + circlet?: string; +}; + +/** + * Pieces that can hold ``stat`` as a substat given flower/plume mains and the + * three selectable mains (EM/EM/EM → only flower + plume for EM). + */ +export function eligibleSubstatPieceCount( + stat: string, + mainStats: MainStatSlots | null | undefined, +): number { + let n = 0; + // Flower main is flat HP; plume main is flat ATK. + if (stat !== "hp") n += 1; + if (stat !== "atk") n += 1; + for (const slot of ["sands", "goblet", "circlet"] as const) { + if (mainStats?.[slot] !== stat) n += 1; + } + return n; +} + +export function maxSubstatRolls( + stat: string, + mainStats: MainStatSlots | null | undefined, +): number { + return MAX_SUBSTAT_ROLLS_PER_PIECE * eligibleSubstatPieceCount(stat, mainStats); +} + +/** Cap each OptimFull roll count to what artifact pieces can actually hold. */ +export function clampSubstatRolls( + rolls: Record | null | undefined, + mainStats: MainStatSlots | null | undefined, +): Record { + if (!rolls) return {}; + const out: Record = {}; + for (const [key, raw] of Object.entries(rolls)) { + if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) continue; + const n = Math.floor(raw); + if (!ARTIFACT_SUBSTAT_KEYS.has(key)) { + out[key] = n; + continue; + } + const capped = Math.min(n, maxSubstatRolls(key, mainStats)); + if (capped > 0) out[key] = capped; + } + return out; +} + const WEAPON_PROP_TO_GOOD: Record = { FIGHT_PROP_HP_PERCENT: "hp_", FIGHT_PROP_ATTACK_PERCENT: "atk_", @@ -186,7 +244,9 @@ export function computeBuildSheetStats( else if (key.endsWith("_dmg_")) add(dmgBonus, key, v); } - for (const [key, count] of Object.entries(build.substat_rolls ?? {})) { + for (const [key, count] of Object.entries( + clampSubstatRolls(build.substat_rolls, build.main_stats), + )) { const per = SUBSTAT_ROLL_VALUE[key]; if (per == null || !count) continue; const v = per * count; diff --git a/src/lib/character-builds.test.ts b/src/lib/character-builds.test.ts index 55eabcf..35debb0 100644 --- a/src/lib/character-builds.test.ts +++ b/src/lib/character-builds.test.ts @@ -8,6 +8,9 @@ import { describe, it } from "node:test"; import { constellationImpactRows, constellationPrioritySection, + characterBuildFromExample, + exampleRelevantGoodKeys, + exampleUsesFavonius, formatReactionFingerprint, formatReactionName, levelImportanceFromBuilds, @@ -22,6 +25,7 @@ import { useGuideSection, } from "./character-builds.ts"; import type { + CharacterBuildExample, CharacterIndex, CharacterTalentImportance, } from "./types/investment.ts"; @@ -679,3 +683,133 @@ describe("reaction helpers", () => { ); }); }); + +describe("exampleRelevantGoodKeys display rules", () => { + const base = { + team_key: "t", + team_name: "T", + characters: ["Xilonen", "A", "B", "C"], + state_key: "Xilonen~C0~FavoniusSword~R5", + reactions: { + rps: null, + metric: "damage" as const, + list: [], + primary: null, + fingerprint: null, + }, + artifact_pct_gain: 0.4, + key: "Xilonen", + cons: 0, + level: 90, + talents: { auto: 1, skill: 9, burst: 9 }, + weapon: { key: "FavoniusSword", refinement: 5, level: 90 }, + set: { key: "ScrollsOfTheHearthfire", count: 4 }, + main_stats: { + sands: "enerRech_", + goblet: "geo_dmg_", + circlet: "critRate_", + }, + substat_rolls: { enerRech_: 12, critRate_: 10, critDMG_: 4 }, + substat_rolls_liquid: { enerRech_: 10, critRate_: 8, critDMG_: 2 }, + }; + + it("mid-only on Fav → ER + CR", () => { + const example: CharacterBuildExample = { + ...base, + invest: "mid", + }; + assert.deepEqual( + [...exampleRelevantGoodKeys(example)].sort(), + ["critRate_", "enerRech_"], + ); + }); + + it("mid-only without Fav → ER only (no CR)", () => { + const example: CharacterBuildExample = { + ...base, + invest: "mid", + state_key: "Mona~C2~ThrillingTalesOfDragonSlayers~R5", + weapon: { + key: "ThrillingTalesOfDragonSlayers", + refinement: 5, + level: 90, + }, + }; + assert.deepEqual([...exampleRelevantGoodKeys(example)].sort(), [ + "enerRech_", + ]); + }); + + it("high invest → mains + high liquids (not mid leftovers)", () => { + const example: CharacterBuildExample = { + ...base, + key: "RaidenShogun", + invest: "high", + main_stats: { + sands: "eleMas", + goblet: "eleMas", + circlet: "eleMas", + }, + substat_rolls_liquid: { atk_: 10, critDMG_: 2 }, + high_substat_rolls: { eleMas: 16, enerRech_: 16 }, + high_substat_rolls_liquid: { eleMas: 15, enerRech_: 15 }, + }; + const keys = exampleRelevantGoodKeys(example); + assert.equal(keys.has("eleMas"), true); + assert.equal(keys.has("enerRech_"), true); + assert.equal(keys.has("atk_"), false); + assert.equal(keys.has("critDMG_"), false); + }); + + it("clamps EM liquid to flower+plume when mains are EM/EM/EM", () => { + const example: CharacterBuildExample = { + ...base, + key: "RaidenShogun", + invest: "high", + main_stats: { + sands: "eleMas", + goblet: "eleMas", + circlet: "eleMas", + }, + high_substat_rolls: { eleMas: 16, enerRech_: 16 }, + high_substat_rolls_liquid: { eleMas: 15, enerRech_: 15 }, + }; + const build = characterBuildFromExample(example, "high"); + assert.equal(build.substat_rolls_liquid.eleMas, 8); + assert.equal(build.substat_rolls.eleMas, 8); + assert.equal(build.substat_rolls_liquid.enerRech_, 15); + }); + + it("clamps CR to 16 when circlet is CR (4 pieces × 4)", () => { + const example: CharacterBuildExample = { + ...base, + key: "RaidenShogun", + invest: "high", + main_stats: { + sands: "atk_", + goblet: "atk_", + circlet: "critRate_", + }, + high_substat_rolls: { critRate_: 18, critDMG_: 19 }, + high_substat_rolls_liquid: { critRate_: 17, critDMG_: 18 }, + }; + const build = characterBuildFromExample(example, "high"); + assert.equal(build.substat_rolls_liquid.critRate_, 16); + assert.equal(build.substat_rolls.critRate_, 16); + assert.equal(build.substat_rolls_liquid.critDMG_, 18); + }); + + it("maps 5pc→4pc and drops 1pc set2", () => { + const example: CharacterBuildExample = { + ...base, + invest: "mid", + set: { key: "EmblemOfSeveredFate", count: 5 }, + set2: "NoblesseOblige", + set2_count: 1, + }; + const build = characterBuildFromExample(example, "mid"); + assert.equal(build.set.count, 4); + assert.equal(build.set2, undefined); + assert.equal(build.set2_count, undefined); + }); +}); diff --git a/src/lib/character-builds.ts b/src/lib/character-builds.ts index 45eedc3..b797ec0 100644 --- a/src/lib/character-builds.ts +++ b/src/lib/character-builds.ts @@ -4,7 +4,11 @@ * Pure helpers so the character page stays props → $derived → markup. */ -import { isArtifactSubstatKey } from "$lib/build-stats"; +import { + clampSubstatRolls, + computeBuildSheetStats, + isArtifactSubstatKey, +} from "$lib/build-stats"; import { translateStatKey } from "$lib/utils"; import { CONSTELLATION_UPGRADE, @@ -19,6 +23,8 @@ import { type UpgradeTier, } from "$lib/upgrade-priority"; import type { + CharacterBuild, + CharacterBuildExample, CharacterConsGain, CharacterGuidePriority, CharacterIndex, @@ -536,3 +542,237 @@ export function formatReactionFingerprint( .map((part) => formatReactionName(part.trim())) .join(" + "); } + +export type LiquidRollChip = { key: string; rolls: number }; + +/** Non-zero liquid rolls, highest first (for build-example chips). */ +export function liquidRollChips( + rolls: Record | null | undefined, + limit = 6, +): LiquidRollChip[] { + if (!rolls) return []; + return Object.entries(rolls) + .filter(([, n]) => typeof n === "number" && n > 0) + .map(([key, n]) => ({ key, rolls: n })) + .sort((a, b) => b.rolls - a.rolls || a.key.localeCompare(b.key)) + .slice(0, limit); +} + +export type ExampleSheetRow = { + /** Sheet / format key (e.g. ``critRate``, ``pyro_dmg_``). */ + key: string; + /** GOOD key for ``statIconUrl``. */ + iconKey: string; + label: string; + value: number; +}; + +export type ExampleRollTier = "mid" | "high"; + +function sheetIconKey(stat: string): string { + if (stat === "critRate") return "critRate_"; + if (stat === "critDMG") return "critDMG_"; + if (stat === "enerRech") return "enerRech_"; + if (stat === "heal") return "heal_"; + return stat; +} + +/** True when the example carries a distinct high-invest roll sheet. */ +export function exampleHasHighConfig(example: CharacterBuildExample): boolean { + const rolls = example.high_substat_rolls; + if (!rolls || typeof rolls !== "object") return false; + return Object.keys(rolls).length > 0; +} + +/** True when the example's baseline weapon is a Favonius piece. */ +export function exampleUsesFavonius(example: CharacterBuildExample): boolean { + const key = example.weapon?.key; + return typeof key === "string" && key.toLowerCase().startsWith("favonius"); +} + +/** + * Which sheet lines to show for a build example. + * + * - Mid-only / negligible (`invest: mid`, no high rolls): baseline **ER**, + * plus **CR** only when that team's baseline weapon is Fav + * - High invest: **mains + high OptimFull liquids** (not mid leftover rolls) + */ +export function exampleRelevantGoodKeys( + example: CharacterBuildExample, +): Set { + if (!exampleHasHighConfig(example)) { + const keys = new Set(["enerRech_"]); + if (exampleUsesFavonius(example)) keys.add("critRate_"); + return keys; + } + const keys = new Set(); + for (const slot of MAIN_STAT_SLOTS) { + const k = example.main_stats?.[slot.key]; + if (typeof k === "string" && k) keys.add(k); + } + const highLiquid = clampSubstatRolls( + example.high_substat_rolls_liquid, + example.main_stats, + ); + for (const [k, n] of Object.entries(highLiquid)) { + if (n > 0) keys.add(k); + } + return keys; +} + +/** + * Numerical sheet lines for mains + assigned substats only (not the full + * default sheet). Flat/percent pairs collapse to one total (ATK, HP, DEF). + */ +export function exampleRelevantSheetRows( + example: CharacterBuildExample, + tier: ExampleRollTier = "mid", +): ExampleSheetRow[] { + const relevant = exampleRelevantGoodKeys(example); + if (relevant.size === 0) return []; + + const sheet = computeBuildSheetStats( + characterBuildFromExample(example, tier), + ); + if (!sheet) return []; + + const rows: ExampleSheetRow[] = []; + const push = (key: string, label: string, value: number) => { + rows.push({ key, iconKey: sheetIconKey(key), label, value }); + }; + + if (relevant.has("hp") || relevant.has("hp_")) { + push("hp", "HP", sheet.hp); + } + if (relevant.has("atk") || relevant.has("atk_")) { + push("atk", "ATK", sheet.atk); + } + if (relevant.has("def") || relevant.has("def_")) { + push("def", "DEF", sheet.def); + } + if (relevant.has("eleMas")) { + push("eleMas", "Elemental Mastery", sheet.eleMas); + } + if (relevant.has("critRate_")) { + push("critRate", "CRIT Rate", sheet.critRate); + } + if (relevant.has("critDMG_")) { + push("critDMG", "CRIT DMG", sheet.critDMG); + } + if (relevant.has("enerRech_")) { + push("enerRech", "Energy Recharge", sheet.enerRech); + } + if (relevant.has("heal_")) { + push("heal", translateStatKey("heal_"), sheet.heal); + } + + for (const [key, value] of Object.entries(sheet.dmgBonus) + .filter(([k, v]) => relevant.has(k) && v > 0) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))) { + push(key, translateStatKey(key), value); + } + + return rows; +} + +function isCompleteBuildExample(example: CharacterBuildExample): boolean { + return ( + typeof example.key === "string" && + example.key.length > 0 && + typeof example.cons === "number" && + typeof example.level === "number" && + !!example.talents && + typeof example.talents.auto === "number" && + typeof example.talents.skill === "number" && + typeof example.talents.burst === "number" + ); +} + +/** Examples that carry a full CharacterBuild payload (skip stale CDN rows). */ +export function buildExamples( + builds: CharacterIndex | null | undefined, +): CharacterBuildExample[] { + const list = builds?.build_examples; + if (!Array.isArray(list)) return []; + return list.filter(isCompleteBuildExample); +} + +/** + * Party GOOD keys for an example. Prefer the stamped list; fall back to + * parsing ``state_key`` (``Char~C0~Weapon~R1__…``) for older CDN rows. + */ +export function exampleTeamKeys(example: CharacterBuildExample): string[] { + if (Array.isArray(example.characters) && example.characters.length > 0) { + return example.characters; + } + if (!example.state_key) return []; + return example.state_key + .split("__") + .map((part) => part.split("~")[0]?.trim() ?? "") + .filter(Boolean); +} + +/** + * Pulls-style split: featured character first, then up to ``mateSlots`` mates + * (null-padded so the strip stays fixed-width). + */ +export function exampleFeaturedAndMates( + keys: readonly string[], + featuredKey: string, + mateSlots = 3, +): { featured: string | null; mates: (string | null)[] } { + const featured = keys.find((k) => k === featuredKey) ?? keys[0] ?? null; + const rest = featured ? keys.filter((k) => k !== featured) : [...keys]; + const mates: (string | null)[] = []; + for (let i = 0; i < mateSlots; i++) { + mates.push(rest[i] ?? null); + } + return { featured, mates }; +} + +/** + * Sim configs sometimes stamp 5pc (on-set flower) or 3pc/1pc leftovers. + * Real bonuses are only 2pc / 4pc — map 5→4, 3→2; drop 1pc. + */ +export function normalizeSetPieceCount( + count: number | null | undefined, +): 2 | 4 | null { + if (count == null || !Number.isFinite(count)) return null; + const n = Math.trunc(count); + if (n >= 4) return 4; + if (n >= 2) return 2; + return null; +} + +/** Strip example metadata down to the shared InvestmentBuildCard shape. */ +export function characterBuildFromExample( + example: CharacterBuildExample, + tier: ExampleRollTier = "mid", +): CharacterBuild { + const useHigh = tier === "high" && exampleHasHighConfig(example); + const mains = example.main_stats; + const totals = useHigh + ? (example.high_substat_rolls ?? {}) + : example.substat_rolls; + const liquid = useHigh + ? (example.high_substat_rolls_liquid ?? {}) + : example.substat_rolls_liquid; + const setCount = normalizeSetPieceCount(example.set.count) ?? 4; + const set2Count = example.set2 + ? normalizeSetPieceCount(example.set2_count ?? 2) + : null; + return { + key: example.key, + cons: example.cons, + level: example.level, + talents: example.talents, + weapon: example.weapon, + set: { key: example.set.key, count: setCount }, + set2: set2Count != null ? example.set2 : undefined, + set2_count: set2Count ?? undefined, + main_stats: mains, + // OptimFull can over-allocate onto mains; clamp for sheet/goals display. + substat_rolls: clampSubstatRolls(totals, mains), + substat_rolls_liquid: clampSubstatRolls(liquid, mains), + }; +} diff --git a/src/lib/equipment-data.ts b/src/lib/equipment-data.ts index cef3e77..286cc2e 100644 --- a/src/lib/equipment-data.ts +++ b/src/lib/equipment-data.ts @@ -145,15 +145,25 @@ export function formatInvestmentCR( * Replace GOOD weapon keys in an investment sim label with display names. * When `characterByKey` is provided, character GOOD keys are replaced too * (longest-first so compound keys win). + * + * ``fiveStarWeaponsAs: "R1"`` — limited/signature 5★ keys become ``R1`` + * instead of the weapon name (vertical upgrades; non-sig 5★s live under + * ``owned`` and should keep ``"name"``). */ export function humanizeInvestmentLabel( label: string, characterByKey?: Map, + opts?: { fiveStarWeaponsAs?: "name" | "R1" }, ): string { if (!label) return label; + const fiveStarAs = opts?.fiveStarWeaponsAs ?? "name"; let out = label; for (const key of weaponKeysByLength) { if (!out.includes(key)) continue; + if (fiveStarAs === "R1" && isFiveStarWeapon(key)) { + out = out.split(key).join("R1"); + continue; + } const name = weaponByKey.get(key)?.name; if (!name) continue; out = out.split(key).join(name); diff --git a/src/lib/types/database.types.ts b/src/lib/types/database.types.ts index 62aa8ea..476a3bc 100644 --- a/src/lib/types/database.types.ts +++ b/src/lib/types/database.types.ts @@ -390,6 +390,55 @@ export type Database = { }, ] } + stygian_team_clear_videos: { + Row: { + char_names: string[] + clear_key: string + cost: number | null + difficulty: string + enemy_id: number + team_key: string + time_s: number | null + updated_at: string + video_url: string + } + Insert: { + char_names?: string[] + clear_key: string + cost?: number | null + difficulty: string + enemy_id: number + team_key: string + time_s?: number | null + updated_at?: string + video_url: string + } + Update: { + char_names?: string[] + clear_key?: string + cost?: number | null + difficulty?: string + enemy_id?: number + team_key?: string + time_s?: number | null + updated_at?: string + video_url?: string + } + Relationships: [ + { + foreignKeyName: "stygian_team_clear_videos_enemy_id_fkey" + columns: ["enemy_id"] + referencedRelation: "enemies" + referencedColumns: ["id"] + }, + { + foreignKeyName: "stygian_team_clear_videos_team_key_fkey" + columns: ["team_key"] + referencedRelation: "teams" + referencedColumns: ["team_key"] + }, + ] + } stygian_version_enemies: { Row: { enemy_id: number diff --git a/src/lib/types/investment.ts b/src/lib/types/investment.ts index 1b55d23..cf018b0 100644 --- a/src/lib/types/investment.ts +++ b/src/lib/types/investment.ts @@ -54,6 +54,8 @@ export interface InvestmentSim { dps: number; /** Per-character build snapshot for this simulation. */ characters: CharacterBuild[]; + /** Baseline reaction profile (merge pass-through from summary). */ + reactions?: TeamReactionProfile; } export interface CharacterBuild { @@ -182,6 +184,19 @@ export interface CharacterIndex { * `artifact_importance.py` reports. Negative per-team gains are floored to 0. */ artifact_importance?: CharacterArtifactImportance; + /** + * Stat goals from mid→high OptimFull allocation deltas (+ ER if burst, CR if Fav). + * Negligible artifact impact collapses to the ER/Fav checklist only. + * Character-level summary — prefer ``build_examples`` for team/archetype views. + */ + stat_recommendations?: CharacterStatRecommendations; + /** + * One example per reaction fingerprint (highest baseline DPS), capped. + * `invest: mid` (negligible) → baseline ER (+ CR if that example uses Fav); + * `high` → high config + mains. + * Shown on the character Builds tab. + */ + build_examples?: CharacterBuildExample[]; /** * Editorial upgrade recommendations from a hand-authored guide. * Published separately from measured `*_importance` statistics; the Builds @@ -310,6 +325,52 @@ export interface CharacterArtifactImportance { tier?: ImportanceImpactTier | null; } +/** + * Derived farm targets: mid→high liquid movers, plus conditional ER/Fav overlays. + * `checklist` mode (negligible artifact impact) omits delta_stats. + */ +export interface CharacterStatRecommendations { + mode: "delta" | "checklist"; + delta_stats: Array<{ key: string; mean_delta: number; teams_positive: number }>; + enerRech_if_burst: boolean; + critRate_if_fav: boolean; + teams: number; + burst_teams: number; + fav_teams: number; +} + +/** One concrete team build example for a character (CDN character index). */ +export interface CharacterBuildExample { + team_key: string; + team_name: string; + /** GOOD keys for the full party (order matches the sim). */ + characters: string[]; + state_key: string; + reactions: TeamReactionProfile; + /** + * ``mid`` = negligible artifact impact → UI shows baseline ER (+ CR if Fav). + * ``high`` = otherwise → UI shows high OptimFull sheet from mains + high rolls. + */ + invest: "mid" | "high"; + artifact_pct_gain: number; + /** Featured character GOOD key (same shape as ``CharacterBuild``). */ + key: string; + cons: number; + level: number; + talents: CharacterBuild["talents"]; + weapon: CharacterBuild["weapon"]; + set: CharacterBuild["set"]; + set2?: string; + set2_count?: number; + main_stats: CharacterBuild["main_stats"]; + /** Baseline (mid-invest) OptimFull total rolls. */ + substat_rolls: Record; + substat_rolls_liquid: Record; + /** High-invest OptimFull totals when ``invest === "high"``. */ + high_substat_rolls?: Record; + high_substat_rolls_liquid?: Record; +} + /** @deprecated Use {@link ImportanceImpactTier}. */ export type ArtifactImportanceTier = ImportanceImpactTier; diff --git a/src/lib/ui/components/InvestmentBuildCard.svelte b/src/lib/ui/components/InvestmentBuildCard.svelte index 1300770..221a2b5 100644 --- a/src/lib/ui/components/InvestmentBuildCard.svelte +++ b/src/lib/ui/components/InvestmentBuildCard.svelte @@ -21,6 +21,7 @@ formatSheetStat, type SheetStatBag, } from "$lib/build-stats"; + import { normalizeSetPieceCount } from "$lib/character-builds"; import type { CharacterBuild } from "$lib/types/investment"; import type { Character } from "$lib/definitions"; import type { InvestmentBuildKitIcons } from "$lib/investment-build-card"; @@ -29,11 +30,17 @@ build, character = null, kit = null, + /** + * When set, only these GOOD sheet keys are listed (flat/percent pairs + * collapse: `atk_` → ATK row). Omit for the full team-config sheet. + */ + relevantKeys = null, class: className = "", }: { build: CharacterBuild; character?: Character | null; kit?: InvestmentBuildKitIcons | null; + relevantKeys?: Iterable | null; class?: string; } = $props(); @@ -51,10 +58,20 @@ $equipmentVersion; return build.set2 ? (artifactSetByKey.get(build.set2) ?? null) : null; }); + let setCount = $derived(normalizeSetPieceCount(build.set.count) ?? 4); + let set2Count = $derived( + build.set2 ? normalizeSetPieceCount(build.set2_count ?? 2) : null, + ); let sheet = $derived(computeBuildSheetStats(build)); let wIcon = $derived(weapon ? weaponIconUrl(weapon.awakenIcon) : null); let sIcon = $derived(set ? artifactIconUrl(set.icon) : null); let s2Icon = $derived(set2 ? artifactIconUrl(set2.icon) : null); + let relevantKeySet = $derived.by(() => { + if (relevantKeys == null) return null; + return relevantKeys instanceof Set + ? relevantKeys + : new Set(relevantKeys); + }); function dmgBonusEntries(bag: SheetStatBag) { return Object.entries(bag.dmgBonus) @@ -70,12 +87,27 @@ if (stat === "critRate") return "critRate_"; if (stat === "critDMG") return "critDMG_"; if (stat === "enerRech") return "enerRech_"; + if (stat === "heal") return "heal_"; return stat; } + /** Sheet row key matches a GOOD key from ``relevantKeys``. */ + function sheetRowIsRelevant(rowKey: string, rel: Set): boolean { + if (rel.has(rowKey)) return true; + if (rowKey === "hp") return rel.has("hp") || rel.has("hp_"); + if (rowKey === "atk") return rel.has("atk") || rel.has("atk_"); + if (rowKey === "def") return rel.has("def") || rel.has("def_"); + if (rowKey === "critRate") return rel.has("critRate_"); + if (rowKey === "critDMG") return rel.has("critDMG_"); + if (rowKey === "enerRech") return rel.has("enerRech_"); + if (rowKey === "heal") return rel.has("heal_"); + if (rowKey === "eleMas") return rel.has("eleMas"); + return false; + } + let coreStats = $derived.by(() => { if (!sheet) return []; - return [ + const rows = [ { key: "hp", label: "HP", value: sheet.hp }, { key: "atk", label: "ATK", value: sheet.atk }, { key: "def", label: "DEF", value: sheet.def }, @@ -83,12 +115,19 @@ { key: "critRate", label: "CRIT Rate", value: sheet.critRate }, { key: "critDMG", label: "CRIT DMG", value: sheet.critDMG }, { key: "enerRech", label: "Energy Recharge", value: sheet.enerRech }, + { key: "heal", label: translateStatKey("heal_"), value: sheet.heal }, ...dmgBonusEntries(sheet).map(([key, value]) => ({ key, label: translateStatKey(key), value, })), ]; + const rel = relevantKeySet; + if (!rel) { + // Team-config default: full sheet without heal (usually 0 / unused). + return rows.filter((row) => row.key !== "heal"); + } + return rows.filter((row) => sheetRowIsRelevant(row.key, rel)); }); type TalentRow = { @@ -257,13 +296,13 @@ {/if}

{set?.name ?? build.set.key}

- {build.set.count} + {setCount} - {#if build.set2} + {#if build.set2 && set2Count != null}
{#if s2Icon} {/if}

{set2?.name ?? build.set2}

- {build.set2_count ?? 2} + {set2Count}
{/if} diff --git a/src/lib/ui/icons/IconFileSearch.svelte b/src/lib/ui/icons/IconFileSearch.svelte new file mode 100644 index 0000000..7f45a38 --- /dev/null +++ b/src/lib/ui/icons/IconFileSearch.svelte @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/src/routes/characters/[slug]/+page.svelte b/src/routes/characters/[slug]/+page.svelte index 472021d..a843f97 100644 --- a/src/routes/characters/[slug]/+page.svelte +++ b/src/routes/characters/[slug]/+page.svelte @@ -67,15 +67,24 @@ } from "$lib/equipment-data"; import { MAIN_STAT_SLOTS, + buildExamples, + characterBuildFromExample, constellationPrioritySection, ascensionPrioritySection, + exampleFeaturedAndMates, + exampleHasHighConfig, + exampleRelevantGoodKeys, + exampleTeamKeys, levelPrioritySection, rankWeaponsByRarityAndTeams, recommendedSubstatsFromBuilds, sigWeaponPrioritySection, talentPrioritySection, } from "$lib/character-builds"; + import { kitIconsFromCharacterKit } from "$lib/investment-build-card"; + import InvestmentBuildCard from "$lib/ui/components/InvestmentBuildCard.svelte"; import IconCog from "$lib/ui/icons/IconCog.svelte"; + import IconFileSearch from "$lib/ui/icons/IconFileSearch.svelte"; import { artifactIconUrl, skillIconUrl, @@ -510,6 +519,30 @@ 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 exampleCardBuild = $derived.by(() => { + if (!activeExample) return null; + const tier = exampleHasHighConfig(activeExample) + ? ("high" as const) + : ("mid" as const); + return characterBuildFromExample(activeExample, tier); + }); + let exampleCardRelevantKeys = $derived( + activeExample ? exampleRelevantGoodKeys(activeExample) : null, + ); + let exampleCardKit = $derived(kitIconsFromCharacterKit(kit)); + let exampleCardCharacter = $derived(goodKeyMap.get(goodKey) ?? null); {#snippet descriptionBlock( @@ -1481,6 +1514,160 @@ {/if} + {#if activeExample} + {@const activeSplit = exampleFeaturedAndMates( + exampleTeamKeys(activeExample), + goodKey, + )} +
+

Stat goals

+

+ DPS stat goals assume very high artifact investment. A lot + of supports only care about ER (and Crit Rate if running a + Favonius weapon) +

+
+
+
+
+
+ + {#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} +
+
+
+ + {#if exampleCardBuild} + + {/if} +
+
+ {/if} + {#if builds.notes}

Notes

@@ -1918,6 +2105,228 @@ white-space: pre-wrap; } + .example-build { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); + column-gap: var(--space-5); + row-gap: var(--space-4); + align-items: start; + } + + .example-build-context { + display: flex; + flex-direction: column; + gap: var(--space-3); + min-width: 0; + } + + .example-build :global(.example-build-card) { + min-width: 0; + } + + .example-build :global(.example-build-card .build-grid) { + min-height: 16.5rem; + } + + .example-build :global(.example-build-card .build-art--portrait) { + width: 10rem; + } + + .example-build :global(.example-build-card .build-art--enka) { + width: 9.25rem; + } + + @media (max-width: 640px) { + .example-build :global(.example-build-card .build-grid) { + min-height: 14rem; + } + + .example-build :global(.example-build-card .build-art--portrait) { + width: 7rem; + } + + .example-build :global(.example-build-card .build-art--enka) { + width: 6.5rem; + } + } + + .example-picker-primary { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + column-gap: 0.5rem; + align-items: start; + } + + @media (max-width: 840px) { + .example-build { + grid-template-columns: minmax(0, 1fr); + } + } + + .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-actions { + grid-column: 2; + grid-row: 1; + align-self: center; + display: flex; + flex-direction: column; + gap: 0.35rem; + } + + .example-picker-action { + 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; + text-decoration: none; + transition: + color 280ms ease, + border-color 280ms ease; + } + + .example-picker-action :global(svg) { + transition: transform 480ms cubic-bezier(0.22, 1, 0.36, 1); + transform-origin: center; + } + + .example-picker-action:hover, + .example-picker-action.open { + color: var(--accent-1); + border-color: color-mix( + in srgb, + var(--accent-1) 45%, + rgba(255, 255, 255, 0.14) + ); + } + + .example-picker-action.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) + ); + } + + @media (prefers-reduced-motion: reduce) { + .example-picker-action :global(svg), + .example-picker-alts-shell, + .example-picker-alt-wrap { + transition: none; + } + } + .stat-name { color: var(--foreground-color); font-size: var(--text-xs); diff --git a/src/routes/dev/ui/+page.svelte b/src/routes/dev/ui/+page.svelte index f03bfc9..cac4e44 100644 --- a/src/routes/dev/ui/+page.svelte +++ b/src/routes/dev/ui/+page.svelte @@ -465,6 +465,63 @@ TYPE_PAIRINGS.find((p) => p.id === typePairingId) ?? TYPE_PAIRINGS[0], ); + // ── Stat goals layout study ──────────────────────────────────────────── + type StatGoalDemoArchetype = { + id: string; + label: string; + invest: "mid" | "high"; + weapon: string; + set: string; + stats: { key: string; label: string; value: string }[]; + }; + + const STAT_GOAL_ARCHETYPES: StatGoalDemoArchetype[] = [ + { + id: "freeze", + label: "Freeze", + invest: "mid", + weapon: "Thrilling Tales", + set: "Noblesse 4pc", + stats: [ + { key: "enerRech_", label: "Energy Recharge", value: "186.5%" }, + ], + }, + { + id: "hyperbloom", + label: "Hyperbloom", + invest: "high", + weapon: "Dragon's Bane", + set: "Flower of Paradise Lost 4pc", + stats: [ + { key: "eleMas", label: "Elemental Mastery", value: "812" }, + { key: "enerRech_", label: "Energy Recharge", value: "148.2%" }, + ], + }, + { + id: "vape", + label: "Vape", + invest: "high", + weapon: "Favonius Codex", + set: "Emblem 4pc", + stats: [ + { key: "enerRech_", label: "Energy Recharge", value: "221.0%" }, + { key: "critRate_", label: "CRIT Rate", value: "62.4%" }, + { key: "critDMG_", label: "CRIT DMG", value: "142.8%" }, + ], + }, + ]; + + let statGoalArchetypeId = $state("hyperbloom"); + let statGoalMenuOpen = $state(false); + let statGoalArchetype = $derived( + STAT_GOAL_ARCHETYPES.find((a) => a.id === statGoalArchetypeId) ?? + STAT_GOAL_ARCHETYPES[1], + ); + let statGoalTeam = $derived($charactersOwned.slice(0, 4)); + let statGoalAlts = $derived( + STAT_GOAL_ARCHETYPES.filter((a) => a.id !== statGoalArchetypeId), + ); + const TIP_TONE_OPTIONS = [ { id: "current", @@ -940,7 +997,256 @@
- + +