From 4159630b0ecf0183b16a293e9defad66868bd25c Mon Sep 17 00:00:00 2001 From: gunshiz Date: Wed, 9 Sep 2026 20:42:39 +0700 Subject: [PATCH] feat: show final character stats --- components/translator.tsx | 6 ++- lib/translator/final-stats.test.ts | 27 +++++++++++ lib/translator/final-stats.ts | 72 ++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 lib/translator/final-stats.test.ts create mode 100644 lib/translator/final-stats.ts diff --git a/components/translator.tsx b/components/translator.tsx index 7913ef2..bd8f890 100644 --- a/components/translator.tsx +++ b/components/translator.tsx @@ -15,6 +15,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { calculateSubstats, generateTeam, SUBSTAT_SCALARS } from "@/lib/translator/generate"; import { formatBaseStat } from "@/lib/translator/stat-format"; +import { calculateFinalStats, formatFinalStat } from "@/lib/translator/final-stats"; import { rollKeys, translationSchema, type Build, type Catalog, type CatalogItem, type MainStatKey, type RollKey, type Team, type Translation } from "@/lib/translator/types"; const emptyRolls = Object.fromEntries(rollKeys.map((key) => [key, 0])) as Record; @@ -77,8 +78,11 @@ function BuildEditor({ build, catalog, onChange, onRemove, canRemove }: { build: onChange({ ...build, characterId: item.id, characterKey: item.key, characterName: item.name, characterImageUrl: item.imageUrl, weaponId: weapon.id, weaponKey: weapon.key, weaponName: weapon.name }); }; const calculated = calculateSubstats(build); + const finalStats = selectedCharacter?.baseStats.length && selectedWeapon?.baseStats.length + ? calculateFinalStats(build, selectedCharacter.baseStats, selectedWeapon.baseStats) + : []; const totalRolls = Object.values(build.rolls).reduce((sum, value) => sum + value, 0); - return
{build.characterName}{build.characterKey} · {totalRolls} total rolls
+ return
{build.characterName}{build.characterKey} · {totalRolls} total rolls
{finalStats.length ? finalStats.map((stat) => {formatFinalStat(stat)}) : Final stats unavailable}
Character
Level v <= build.maxLevel)} onChange={(v) => update("level", v)} />Ascension cap onChange({ ...build, maxLevel: v, level: Math.min(build.level, v) })} />Constellation update("constellation", v)} />Talents NA / Skill / Burst
{build.talents.map((value, index) => { const next = [...build.talents] as Build["talents"]; next[index] = Number(event.target.value); update("talents", next); }} aria-label={["Normal attack", "Skill", "Burst"][index]} />)}
diff --git a/lib/translator/final-stats.test.ts b/lib/translator/final-stats.test.ts new file mode 100644 index 0000000..6999b47 --- /dev/null +++ b/lib/translator/final-stats.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { calculateFinalStats, formatFinalStat } from "./final-stats"; +import type { Build, CatalogBaseStat } from "./types"; + +const build: Build = { + id: "test", characterId: "10000032", characterKey: "bennett", characterName: "Bennett", characterImageUrl: "", level: 90, maxLevel: 90, constellation: 0, talents: [9, 9, 9], weaponId: "11401", weaponKey: "favoniussword", weaponName: "Favonius Sword", weaponLevel: 90, weaponMaxLevel: 90, refinement: 1, artifactMode: "4pc", artifactOneId: "15007", artifactOneKey: "noblesseoblige", artifactOneName: "Noblesse Oblige", artifactTwoId: "", artifactTwoKey: "", artifactTwoName: "", sands: "er", goblet: "pyro%", circlet: "cr", rolls: { hp: 2, atk: 1, def: 0, em: 2, er: 3, "hp%": 0, "atk%": 4, "def%": 1, cr: 5, cd: 6 }, +}; +const character: CatalogBaseStat[] = [{ key: "hp", value: 12397 }, { key: "atk", value: 191 }, { key: "def", value: 771 }, { key: "ER", value: "26.7" }]; +const weapon: CatalogBaseStat[] = [{ key: "atk", value: 454 }, { key: "Energy Recharge%", value: 61.3 }]; + +describe("final character stats", () => { + it("combines raw level 90 character, weapon, and artifact stats", () => { + const stats = calculateFinalStats(build, character, weapon); + expect(stats.find((stat) => stat.key === "HP")?.value).toBeCloseTo(17684.88); + expect(stats.find((stat) => stat.key === "ATK")?.value).toBeCloseTo(1100.508); + expect(stats.find((stat) => stat.key === "DEF")?.value).toBeCloseTo(818.802); + expect(stats.find((stat) => stat.key === "ER")?.value).toBeCloseTo(2.5633); + expect(stats.find((stat) => stat.key === "CR")?.value).toBeCloseTo(0.5265); + expect(stats.find((stat) => stat.key === "CD")?.value).toBeCloseTo(0.8972); + expect(stats.find((stat) => stat.key === "pyro")?.value).toBeCloseTo(0.466); + }); + + it("formats final flat and percentage values", () => { + expect(formatFinalStat({ key: "HP", value: 17684.88 })).toBe("HP 17,685"); + expect(formatFinalStat({ key: "ER", value: 2.6503, percent: true })).toBe("ER 265.03%"); + }); +}); diff --git a/lib/translator/final-stats.ts b/lib/translator/final-stats.ts new file mode 100644 index 0000000..7f19db8 --- /dev/null +++ b/lib/translator/final-stats.ts @@ -0,0 +1,72 @@ +import { calculateSubstats, MAIN_STATS } from "./generate"; +import type { Build, CatalogBaseStat } from "./types"; + +export interface FinalStat { + key: string; + value: number; + percent?: boolean; +} + +type Bonuses = Record; + +function addCatalogStats(stats: CatalogBaseStat[], bonuses: Bonuses) { + for (const stat of stats) { + const value = Number(stat.value); + if (!Number.isFinite(value)) continue; + const key = stat.key.toLowerCase().replaceAll(" ", ""); + if (key === "em") bonuses.em += value; + else if (key === "er" || key === "energyrecharge%") bonuses.er += value / 100; + else if (key === "cr" || key === "critrate%") bonuses.cr += value / 100; + else if (key === "cd" || key === "critdmg%") bonuses.cd += value / 100; + else if (key === "hp%") bonuses["hp%"] += value / 100; + else if (key === "atk%") bonuses["atk%"] += value / 100; + else if (key === "def%") bonuses["def%"] += value / 100; + else if (key.endsWith("%")) { + const bonusKey = key.slice(0, -1); + bonuses[bonusKey] = (bonuses[bonusKey] ?? 0) + value / 100; + } + } +} + +function baseValue(stats: CatalogBaseStat[], key: string): number { + return Number(stats.find((stat) => stat.key.toLowerCase() === key)?.value ?? 0); +} + +export function calculateFinalStats(build: Build, characterStats: CatalogBaseStat[], weaponStats: CatalogBaseStat[]): FinalStat[] { + const bonuses: Bonuses = { em: 0, er: 1, cr: 0.05, cd: 0.5, "hp%": 0, "atk%": 0, "def%": 0 }; + addCatalogStats(characterStats.filter((stat) => !["hp", "atk", "def"].includes(stat.key.toLowerCase())), bonuses); + addCatalogStats(weaponStats.filter((stat) => stat.key.toLowerCase() !== "atk"), bonuses); + + const artifactStats: Bonuses = { hp: 4780, atk: 311, def: 0, em: 0, er: 0, cr: 0, cd: 0, "hp%": 0, "atk%": 0, "def%": 0 }; + for (const key of [build.sands, build.goblet, build.circlet]) { + const bonusKey = key.endsWith("%") && !["hp%", "atk%", "def%"].includes(key) ? key.slice(0, -1) : key; + artifactStats[bonusKey] = (artifactStats[bonusKey] ?? 0) + MAIN_STATS[key]; + } + const substats = calculateSubstats(build); + for (const [key, value] of Object.entries(substats)) artifactStats[key] = (artifactStats[key] ?? 0) + value; + for (const [key, value] of Object.entries(artifactStats)) bonuses[key] = (bonuses[key] ?? 0) + value; + + const characterAtk = baseValue(characterStats, "atk"); + const weaponAtk = baseValue(weaponStats, "atk"); + const core: FinalStat[] = [ + { key: "HP", value: baseValue(characterStats, "hp") * (1 + bonuses["hp%"]) + bonuses.hp }, + { key: "ATK", value: (characterAtk + weaponAtk) * (1 + bonuses["atk%"]) + bonuses.atk }, + { key: "DEF", value: baseValue(characterStats, "def") * (1 + bonuses["def%"]) + bonuses.def }, + { key: "EM", value: bonuses.em }, + { key: "ER", value: bonuses.er, percent: true }, + { key: "CR", value: bonuses.cr, percent: true }, + { key: "CD", value: bonuses.cd, percent: true }, + ]; + const known = new Set(["hp", "atk", "def", "em", "er", "cr", "cd", "hp%", "atk%", "def%"]); + const extras = Object.entries(bonuses) + .filter(([key, value]) => !known.has(key) && value !== 0) + .map(([key, value]) => ({ key, value, percent: true })); + return [...core, ...extras]; +} + +export function formatFinalStat(stat: FinalStat): string { + const value = stat.percent + ? `${(stat.value * 100).toLocaleString("en-US", { maximumFractionDigits: 2 })}%` + : Math.round(stat.value).toLocaleString("en-US"); + return `${stat.key.toUpperCase()} ${value}`; +}