feat: show level 90 base stats
This commit is contained in:
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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%");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user