diff --git a/components/translator.tsx b/components/translator.tsx index 01f248d..7913ef2 100644 --- a/components/translator.tsx +++ b/components/translator.tsx @@ -14,6 +14,7 @@ import { Separator } from "@/components/ui/separator"; 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 { 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; @@ -61,8 +62,14 @@ function StatSelect({ value, values, onChange }: { value: MainStatKey; values: M return ; } +function BaseStats({ stats }: { stats: CatalogItem["baseStats"] }) { + if (!stats.length) return Level 90 base stats unavailable.; + return
{stats.map((stat) => {formatBaseStat(stat)})}
; +} + function BuildEditor({ build, catalog, onChange, onRemove, canRemove }: { build: Build; catalog: Catalog; onChange: (build: Build) => void; onRemove: () => void; canRemove: boolean }) { const selectedCharacter = catalog.characters.find((item) => item.id === build.characterId); + const selectedWeapon = catalog.weapons.find((item) => item.id === build.weaponId); const weapons = catalog.weapons.filter((item) => !selectedCharacter?.weaponType || item.weaponType === selectedCharacter.weaponType); const update = (key: K, value: Build[K]) => onChange({ ...build, [key]: value }); const chooseCharacter = (item: CatalogItem) => { @@ -72,10 +79,10 @@ function BuildEditor({ build, catalog, onChange, onRemove, canRemove }: { build: const calculated = calculateSubstats(build); const totalRolls = Object.values(build.rolls).reduce((sum, value) => sum + value, 0); return
{build.characterName}{build.characterKey} ยท {totalRolls} total rolls
- Character + 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]} />)}
-
Weapon onChange({ ...build, weaponId: item.id, weaponKey: item.key, weaponName: item.name })} placeholder="Choose weapon" />Level v <= build.weaponMaxLevel)} onChange={(v) => update("weaponLevel", v)} />Ascension cap onChange({ ...build, weaponMaxLevel: v, weaponLevel: Math.min(build.weaponLevel, v) })} />Refinement update("refinement", v)} />
+
Weapon onChange({ ...build, weaponId: item.id, weaponKey: item.key, weaponName: item.name })} placeholder="Choose weapon" />Level v <= build.weaponMaxLevel)} onChange={(v) => update("weaponLevel", v)} />Ascension cap onChange({ ...build, weaponMaxLevel: v, weaponLevel: Math.min(build.weaponLevel, v) })} />Refinement update("refinement", v)} />
Artifact sets and main stats
Set layoutFirst set onChange({ ...build, artifactOneId: item.id, artifactOneKey: item.key, artifactOneName: item.name })} placeholder="Choose artifact" />{build.artifactMode === "2+2" ? Second set item.id !== build.artifactOneId)} value={build.artifactTwoId} onChange={(item) => onChange({ ...build, artifactTwoId: item.id, artifactTwoKey: item.key, artifactTwoName: item.name })} placeholder="Choose artifact" /> : null}Sands update("sands", v as Build["sands"])} />Goblet update("goblet", v as Build["goblet"])} />Circlet update("circlet", v as Build["circlet"])} />
diff --git a/db/schema.ts b/db/schema.ts index 97420c1..ebac181 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -1,9 +1,10 @@ -import { integer, pgSchema, text, varchar } from "drizzle-orm/pg-core"; +import { integer, jsonb, pgSchema, text, varchar } from "drizzle-orm/pg-core"; +import type { CatalogBaseStat } from "@/lib/translator/types"; export const catalogSchema = pgSchema("catalog"); export const catalogCharacters = catalogSchema.table("character", { - key: varchar("key", { length: 64 }).primaryKey(), name: text("name").notNull(), element: varchar("element", { length: 48 }), rarity: integer("rarity"), weaponType: varchar("weapon_type", { length: 64 }), imageKey: text("image_key").notNull(), + key: varchar("key", { length: 64 }).primaryKey(), name: text("name").notNull(), element: varchar("element", { length: 48 }), rarity: integer("rarity"), weaponType: varchar("weapon_type", { length: 64 }), imageKey: text("image_key").notNull(), baseStats: jsonb("base_stats").$type().default([]).notNull(), }); export const catalogWeapons = catalogSchema.table("weapon", { key: varchar("key", { length: 64 }).primaryKey(), name: text("name").notNull(), rarity: integer("rarity"), weaponType: varchar("weapon_type", { length: 64 }), imageKey: text("image_key").notNull(), diff --git a/lib/catalog-stats.test.ts b/lib/catalog-stats.test.ts new file mode 100644 index 0000000..077a719 --- /dev/null +++ b/lib/catalog-stats.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { parseLunarisVersion, weaponStatsFromLunaris } from "./catalog-stats"; +import { formatBaseStat } from "./translator/stat-format"; + +describe("Lunaris catalog stats", () => { + it("reads the current version", () => { + expect(parseLunarisVersion({ version: "7.0.54.2", timestamp: 123 })).toBe("7.0.54.2"); + }); + + it("maps level 90 weapon ascension stats by catalog ID", () => { + const stats = weaponStatsFromLunaris({ + "11401": { enName: "Favonius Sword", ascensionStats: { "Energy Recharge%": 61.3, atk: 454 } }, + "11509": { enName: "Mistsplitter Reforged", ascensionStats: { "CRIT DMG%": "44.1", atk: 674 } }, + }); + expect(stats.get("11401")).toEqual([ + { key: "atk", value: 454 }, + { key: "Energy Recharge%", value: 61.3 }, + ]); + expect(stats.get("11509")).toEqual([ + { key: "atk", value: 674 }, + { key: "CRIT DMG%", value: "44.1" }, + ]); + }); + + it("formats flat and percentage stats", () => { + expect(formatBaseStat({ key: "hp", value: 12397 })).toBe("HP 12,397"); + expect(formatBaseStat({ key: "ER", value: "26.7" })).toBe("ER 26.7%"); + expect(formatBaseStat({ key: "CRIT DMG%", value: "38.4" })).toBe("CRIT DMG 38.4%"); + }); +}); diff --git a/lib/catalog-stats.ts b/lib/catalog-stats.ts new file mode 100644 index 0000000..eb0149e --- /dev/null +++ b/lib/catalog-stats.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; +import type { CatalogBaseStat } from "@/lib/translator/types"; + +const statValueSchema = z.union([z.number(), z.string()]); +const lunarisVersionSchema = z.object({ version: z.string().min(1) }); +const lunarisWeaponListSchema = z.record(z.string(), z.object({ + ascensionStats: z.record(z.string(), statValueSchema).optional(), +}).passthrough()); + +export function parseLunarisVersion(value: unknown): string { + return lunarisVersionSchema.parse(value).version; +} + +export function weaponStatsFromLunaris(value: unknown): Map { + const weapons = lunarisWeaponListSchema.parse(value); + return new Map(Object.entries(weapons).map(([id, weapon]) => [ + id, + Object.entries(weapon.ascensionStats ?? {}) + .map(([key, stat]) => ({ key, value: stat })) + .sort((left, right) => Number(right.key.toLowerCase() === "atk") - Number(left.key.toLowerCase() === "atk")), + ])); +} diff --git a/lib/catalog.ts b/lib/catalog.ts index 444e73e..8e01420 100644 --- a/lib/catalog.ts +++ b/lib/catalog.ts @@ -1,26 +1,28 @@ import "server-only"; import { asc } from "drizzle-orm"; +import { connection } from "next/server"; import { getDb } from "@/db/client"; import { catalogArtifacts, catalogCharacters, catalogWeapons } from "@/db/schema"; +import { parseLunarisVersion, weaponStatsFromLunaris } from "@/lib/catalog-stats"; import { gcsimKey } from "@/lib/translator/keys"; import type { Catalog, CatalogItem } from "@/lib/translator/types"; const fallback: Catalog = { version: "fallback", characters: [ - { id: "10000002", key: "kamisatoayaka", name: "Kamisato Ayaka", imageUrl: "", rarity: 5, weaponType: "WEAPON_SWORD_ONE_HAND", element: "Cryo", supported: true }, - { id: "10000032", key: "bennett", name: "Bennett", imageUrl: "", rarity: 4, weaponType: "WEAPON_SWORD_ONE_HAND", element: "Pyro", supported: true }, - { id: "10000089", key: "furina", name: "Furina", imageUrl: "", rarity: 5, weaponType: "WEAPON_SWORD_ONE_HAND", element: "Hydro", supported: true }, - { id: "10000047", key: "kaedeharakazuha", name: "Kaedehara Kazuha", imageUrl: "", rarity: 5, weaponType: "WEAPON_SWORD_ONE_HAND", element: "Anemo", supported: true }, + { id: "10000002", key: "kamisatoayaka", name: "Kamisato Ayaka", imageUrl: "", rarity: 5, weaponType: "WEAPON_SWORD_ONE_HAND", element: "Cryo", supported: true, baseStats: [] }, + { id: "10000032", key: "bennett", name: "Bennett", imageUrl: "", rarity: 4, weaponType: "WEAPON_SWORD_ONE_HAND", element: "Pyro", supported: true, baseStats: [] }, + { id: "10000089", key: "furina", name: "Furina", imageUrl: "", rarity: 5, weaponType: "WEAPON_SWORD_ONE_HAND", element: "Hydro", supported: true, baseStats: [] }, + { id: "10000047", key: "kaedeharakazuha", name: "Kaedehara Kazuha", imageUrl: "", rarity: 5, weaponType: "WEAPON_SWORD_ONE_HAND", element: "Anemo", supported: true, baseStats: [] }, ], weapons: [ - { id: "11401", key: "favoniussword", name: "Favonius Sword", imageUrl: "", rarity: 4, weaponType: "WEAPON_SWORD_ONE_HAND", supported: true }, - { id: "11509", key: "mistsplitterreforged", name: "Mistsplitter Reforged", imageUrl: "", rarity: 5, weaponType: "WEAPON_SWORD_ONE_HAND", supported: true }, + { id: "11401", key: "favoniussword", name: "Favonius Sword", imageUrl: "", rarity: 4, weaponType: "WEAPON_SWORD_ONE_HAND", supported: true, baseStats: [] }, + { id: "11509", key: "mistsplitterreforged", name: "Mistsplitter Reforged", imageUrl: "", rarity: 5, weaponType: "WEAPON_SWORD_ONE_HAND", supported: true, baseStats: [] }, ], artifacts: [ - { id: "15007", key: "noblesseoblige", name: "Noblesse Oblige", imageUrl: "", rarity: 5, supported: true }, - { id: "15020", key: "blizzardstrayer", name: "Blizzard Strayer", imageUrl: "", rarity: 5, supported: true }, - { id: "15032", key: "goldentroupe", name: "Golden Troupe", imageUrl: "", rarity: 5, supported: true }, + { id: "15007", key: "noblesseoblige", name: "Noblesse Oblige", imageUrl: "", rarity: 5, supported: true, baseStats: [] }, + { id: "15020", key: "blizzardstrayer", name: "Blizzard Strayer", imageUrl: "", rarity: 5, supported: true, baseStats: [] }, + { id: "15032", key: "goldentroupe", name: "Golden Troupe", imageUrl: "", rarity: 5, supported: true, baseStats: [] }, ], }; @@ -29,8 +31,29 @@ function publicUrl(key: string) { return `${base}/${key.split("/").map(encodeURIComponent).join("/")}`; } +async function getLunarisWeaponStats() { + const root = "https://api.lunaris.moe/data"; + try { + const versionResponse = await fetch(`${root}/version.json`, { next: { revalidate: 3600 } }); + if (!versionResponse.ok) throw new Error(`Version request returned ${versionResponse.status}`); + const version = parseLunarisVersion(await versionResponse.json()); + const weaponsResponse = await fetch(`${root}/${encodeURIComponent(version)}/weaponlist.json`, { next: { revalidate: 3600 } }); + if (!weaponsResponse.ok) throw new Error(`Weapon request returned ${weaponsResponse.status}`); + return weaponStatsFromLunaris(await weaponsResponse.json()); + } catch (error) { + console.error("Lunaris weapon stats unavailable", error); + return new Map(); + } +} + +function withWeaponStats(catalog: Catalog, stats: Awaited>): Catalog { + return { ...catalog, weapons: catalog.weapons.map((weapon) => ({ ...weapon, baseStats: stats.get(weapon.id) ?? [] })) }; +} + export async function getCatalog(): Promise { - if (!process.env.DATABASE_URL) return fallback; + await connection(); + const weaponStats = await getLunarisWeaponStats(); + if (!process.env.DATABASE_URL) return withWeaponStats(fallback, weaponStats); try { const db = getDb(); const [characters, weapons, artifacts] = await Promise.all([ @@ -38,15 +61,15 @@ export async function getCatalog(): Promise { db.select().from(catalogWeapons).orderBy(asc(catalogWeapons.name)), db.select().from(catalogArtifacts).orderBy(asc(catalogArtifacts.name)), ]); - if (!characters.length || !weapons.length || !artifacts.length) return fallback; + if (!characters.length || !weapons.length || !artifacts.length) return withWeaponStats(fallback, weaponStats); const map = (kind: "character" | "weapon" | "artifact", rows: typeof characters | typeof weapons | typeof artifacts): CatalogItem[] => rows.map((row) => { const character = "element" in row ? row : null; const key = gcsimKey(kind, row.name, character?.element, row.key); - return { id: row.key, key, name: row.name, imageUrl: publicUrl(row.imageKey), rarity: row.rarity, weaponType: "weaponType" in row ? row.weaponType : null, element: character?.element ?? null, supported: Boolean(key) }; + return { id: row.key, key, name: row.name, imageUrl: publicUrl(row.imageKey), rarity: row.rarity, weaponType: "weaponType" in row ? row.weaponType : null, element: character?.element ?? null, supported: Boolean(key), baseStats: character?.baseStats ?? [] }; }); - return { version: "buzz-current", characters: map("character", characters), weapons: map("weapon", weapons), artifacts: map("artifact", artifacts) }; + return withWeaponStats({ version: "buzz-current", characters: map("character", characters), weapons: map("weapon", weapons), artifacts: map("artifact", artifacts) }, weaponStats); } catch (error) { console.error("Catalog unavailable; using fallback catalog", error); - return fallback; + return withWeaponStats(fallback, weaponStats); } } diff --git a/lib/translator/stat-format.ts b/lib/translator/stat-format.ts new file mode 100644 index 0000000..2f55da9 --- /dev/null +++ b/lib/translator/stat-format.ts @@ -0,0 +1,11 @@ +import type { CatalogBaseStat } from "./types"; + +export function formatBaseStat({ key, value }: CatalogBaseStat): string { + const normalizedKey = key.toLowerCase(); + const rawLabel = key.endsWith("%") ? key.slice(0, -1) : key; + const label = ["hp", "atk", "def", "em", "er"].includes(normalizedKey) ? normalizedKey.toUpperCase() : rawLabel; + const numericValue = typeof value === "number" ? value : Number(value); + const formattedValue = Number.isFinite(numericValue) ? numericValue.toLocaleString("en-US", { maximumFractionDigits: 2 }) : value; + const suffix = key.endsWith("%") || normalizedKey === "er" ? "%" : ""; + return `${label} ${formattedValue}${suffix}`; +} diff --git a/lib/translator/types.ts b/lib/translator/types.ts index 1221a29..d669206 100644 --- a/lib/translator/types.ts +++ b/lib/translator/types.ts @@ -65,6 +65,11 @@ export type Build = z.infer; export type Team = z.infer; export type Translation = z.infer; +export interface CatalogBaseStat { + key: string; + value: number | string; +} + export interface CatalogItem { id: string; key: string; @@ -74,6 +79,7 @@ export interface CatalogItem { weaponType?: string | null; element?: string | null; supported: boolean; + baseStats: CatalogBaseStat[]; } export interface Catalog {