feat: show level 90 base stats
CI / verify (push) Successful in 40s
CI / build-and-deploy (push) Successful in 1m13s

This commit is contained in:
2026-09-09 20:25:20 +07:00 Unverified
parent c21996fcf0
commit 3314b2a9c6
7 changed files with 118 additions and 18 deletions
+9 -2
View File
@@ -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<RollKey, number>;
@@ -61,8 +62,14 @@ function StatSelect({ value, values, onChange }: { value: MainStatKey; values: M
return <Select value={value} onValueChange={(next) => onChange(next as MainStatKey)}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{values.map((item) => <SelectItem key={item} value={item}>{item.toUpperCase()}</SelectItem>)}</SelectGroup></SelectContent></Select>;
}
function BaseStats({ stats }: { stats: CatalogItem["baseStats"] }) {
if (!stats.length) return <FieldDescription>Level 90 base stats unavailable.</FieldDescription>;
return <div className="flex flex-wrap gap-2" aria-label="Level 90 base stats">{stats.map((stat) => <Badge key={stat.key} variant="outline">{formatBaseStat(stat)}</Badge>)}</div>;
}
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 = <K extends keyof Build>(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 <Card><CardHeader className="flex-row items-start justify-between"><div><CardTitle>{build.characterName}</CardTitle><CardDescription>{build.characterKey} · {totalRolls} total rolls</CardDescription></div><Button variant="ghost" size="icon" disabled={!canRemove} onClick={onRemove} aria-label="Remove character"><Trash2Icon /></Button></CardHeader><CardContent><FieldGroup>
<Field><FieldLabel>Character</FieldLabel><CatalogCombobox items={catalog.characters} value={build.characterId} onChange={chooseCharacter} placeholder="Choose character" /></Field>
<Field><FieldLabel>Character</FieldLabel><CatalogCombobox items={catalog.characters} value={build.characterId} onChange={chooseCharacter} placeholder="Choose character" /><BaseStats stats={selectedCharacter?.baseStats ?? []} /></Field>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4"><Field><FieldLabel>Level</FieldLabel><NumberSelect value={build.level} values={levels.filter((v) => v <= build.maxLevel)} onChange={(v) => update("level", v)} /></Field><Field><FieldLabel>Ascension cap</FieldLabel><NumberSelect value={build.maxLevel} values={levels} onChange={(v) => onChange({ ...build, maxLevel: v, level: Math.min(build.level, v) })} /></Field><Field><FieldLabel>Constellation</FieldLabel><NumberSelect value={build.constellation} values={[0,1,2,3,4,5,6]} onChange={(v) => update("constellation", v)} /></Field><Field><FieldLabel>Talents NA / Skill / Burst</FieldLabel><div className="grid grid-cols-3 gap-2">{build.talents.map((value, index) => <Input key={index} type="number" min={1} max={15} value={value} onChange={(event) => { const next = [...build.talents] as Build["talents"]; next[index] = Number(event.target.value); update("talents", next); }} aria-label={["Normal attack", "Skill", "Burst"][index]} />)}</div></Field></div>
<Separator />
<div className="grid gap-6 lg:grid-cols-[2fr_1fr_1fr_1fr]"><Field><FieldLabel>Weapon</FieldLabel><CatalogCombobox items={weapons} value={build.weaponId} onChange={(item) => onChange({ ...build, weaponId: item.id, weaponKey: item.key, weaponName: item.name })} placeholder="Choose weapon" /></Field><Field><FieldLabel>Level</FieldLabel><NumberSelect value={build.weaponLevel} values={levels.filter((v) => v <= build.weaponMaxLevel)} onChange={(v) => update("weaponLevel", v)} /></Field><Field><FieldLabel>Ascension cap</FieldLabel><NumberSelect value={build.weaponMaxLevel} values={levels} onChange={(v) => onChange({ ...build, weaponMaxLevel: v, weaponLevel: Math.min(build.weaponLevel, v) })} /></Field><Field><FieldLabel>Refinement</FieldLabel><NumberSelect value={build.refinement} values={[1,2,3,4,5]} onChange={(v) => update("refinement", v)} /></Field></div>
<div className="grid gap-6 lg:grid-cols-[2fr_1fr_1fr_1fr]"><Field><FieldLabel>Weapon</FieldLabel><CatalogCombobox items={weapons} value={build.weaponId} onChange={(item) => onChange({ ...build, weaponId: item.id, weaponKey: item.key, weaponName: item.name })} placeholder="Choose weapon" /><BaseStats stats={selectedWeapon?.baseStats ?? []} /></Field><Field><FieldLabel>Level</FieldLabel><NumberSelect value={build.weaponLevel} values={levels.filter((v) => v <= build.weaponMaxLevel)} onChange={(v) => update("weaponLevel", v)} /></Field><Field><FieldLabel>Ascension cap</FieldLabel><NumberSelect value={build.weaponMaxLevel} values={levels} onChange={(v) => onChange({ ...build, weaponMaxLevel: v, weaponLevel: Math.min(build.weaponLevel, v) })} /></Field><Field><FieldLabel>Refinement</FieldLabel><NumberSelect value={build.refinement} values={[1,2,3,4,5]} onChange={(v) => update("refinement", v)} /></Field></div>
<Separator />
<FieldSet><FieldLegend>Artifact sets and main stats</FieldLegend><div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"><Field><FieldLabel>Set layout</FieldLabel><Select value={build.artifactMode} onValueChange={(v) => update("artifactMode", v as Build["artifactMode"])}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="4pc">4-piece set</SelectItem><SelectItem value="2+2">2-piece + 2-piece</SelectItem></SelectGroup></SelectContent></Select></Field><Field><FieldLabel>First set</FieldLabel><CatalogCombobox items={catalog.artifacts} value={build.artifactOneId} onChange={(item) => onChange({ ...build, artifactOneId: item.id, artifactOneKey: item.key, artifactOneName: item.name })} placeholder="Choose artifact" /></Field>{build.artifactMode === "2+2" ? <Field><FieldLabel>Second set</FieldLabel><CatalogCombobox items={catalog.artifacts.filter((item) => item.id !== build.artifactOneId)} value={build.artifactTwoId} onChange={(item) => onChange({ ...build, artifactTwoId: item.id, artifactTwoKey: item.key, artifactTwoName: item.name })} placeholder="Choose artifact" /></Field> : null}<Field><FieldLabel>Sands</FieldLabel><StatSelect value={build.sands} values={sands} onChange={(v) => update("sands", v as Build["sands"])} /></Field><Field><FieldLabel>Goblet</FieldLabel><StatSelect value={build.goblet} values={goblets} onChange={(v) => update("goblet", v as Build["goblet"])} /></Field><Field><FieldLabel>Circlet</FieldLabel><StatSelect value={build.circlet} values={circlets} onChange={(v) => update("circlet", v as Build["circlet"])} /></Field></div></FieldSet>
<Separator />
+3 -2
View File
@@ -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<CatalogBaseStat[]>().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(),
+30
View File
@@ -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%");
});
});
+22
View File
@@ -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<string, CatalogBaseStat[]> {
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")),
]));
}
+37 -14
View File
@@ -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<ReturnType<typeof getLunarisWeaponStats>>): Catalog {
return { ...catalog, weapons: catalog.weapons.map((weapon) => ({ ...weapon, baseStats: stats.get(weapon.id) ?? [] })) };
}
export async function getCatalog(): Promise<Catalog> {
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<Catalog> {
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);
}
}
+11
View File
@@ -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}`;
}
+6
View File
@@ -65,6 +65,11 @@ export type Build = z.infer<typeof buildSchema>;
export type Team = z.infer<typeof teamSchema>;
export type Translation = z.infer<typeof translationSchema>;
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 {