Files
gcsim-translater/components/translator.tsx
T
gunshiz 3314b2a9c6
CI / verify (push) Successful in 40s
CI / build-and-deploy (push) Successful in 1m13s
feat: show level 90 base stats
2026-09-09 20:25:20 +07:00

114 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useMemo, useState } from "react";
import { CopyIcon, PlusIcon, Trash2Icon } from "lucide-react";
import { toast } from "sonner";
import { CatalogCombobox } from "@/components/catalog-combobox";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldDescription, FieldGroup, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
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>;
const levels = [20, 40, 50, 60, 70, 80, 90];
const sands: MainStatKey[] = ["atk%", "hp%", "def%", "em", "er"];
const goblets: MainStatKey[] = ["atk%", "hp%", "def%", "em", "pyro%", "hydro%", "cryo%", "electro%", "anemo%", "geo%", "dendro%", "phys%"];
const circlets: MainStatKey[] = ["cr", "cd", "atk%", "hp%", "def%", "em", "heal"];
function newId(prefix: string): string {
const uuid = globalThis.crypto?.randomUUID?.();
return uuid ? `${prefix}-${uuid}` : `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
function makeBuild(catalog: Catalog, character = catalog.characters[0], id = newId("build")): Build {
const weapon = catalog.weapons.find((item) => !character.weaponType || item.weaponType === character.weaponType) ?? catalog.weapons[0];
const artifact = catalog.artifacts[0];
return { id, characterId: character.id, characterKey: character.key, characterName: character.name, characterImageUrl: character.imageUrl, level: 90, maxLevel: 90, constellation: 0, talents: [9, 9, 9], weaponId: weapon.id, weaponKey: weapon.key, weaponName: weapon.name, weaponLevel: 90, weaponMaxLevel: 90, refinement: 1, artifactMode: "4pc", artifactOneId: artifact.id, artifactOneKey: artifact.key, artifactOneName: artifact.name, artifactTwoId: "", artifactTwoKey: "", artifactTwoName: "", sands: "atk%", goblet: "atk%", circlet: "cr", rolls: { ...emptyRolls } };
}
function makeTeam(catalog: Catalog, index: number, deterministic = false): Team {
const id = deterministic ? `team-${index}` : newId("team");
return { id, name: `Team ${index}`, builds: [makeBuild(catalog, catalog.characters[0], deterministic ? `build-${index}-1` : newId("build"))] };
}
async function copyText(value: string): Promise<void> {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return;
}
const textarea = document.createElement("textarea");
textarea.value = value;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.append(textarea);
textarea.select();
const copied = document.execCommand("copy");
textarea.remove();
if (!copied) throw new Error("Clipboard access is unavailable.");
}
function NumberSelect({ value, values, onChange }: { value: number; values: number[]; onChange: (value: number) => void }) {
return <Select value={String(value)} onValueChange={(next) => onChange(Number(next))}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{values.map((item) => <SelectItem key={item} value={String(item)}>{item}</SelectItem>)}</SelectGroup></SelectContent></Select>;
}
function StatSelect({ value, values, onChange }: { value: MainStatKey; values: MainStatKey[]; onChange: (value: MainStatKey) => void }) {
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) => {
const weapon = catalog.weapons.find((candidate) => !item.weaponType || candidate.weaponType === item.weaponType) ?? catalog.weapons[0];
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 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" /><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" /><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 />
<FieldSet><FieldLegend>Average substat rolls</FieldLegend><FieldDescription>Enter roll counts. Values use gcsim&apos;s KQM average-roll scalars.</FieldDescription><div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-5">{rollKeys.map((key) => <Field key={key}><FieldLabel htmlFor={`${build.id}-${key}`}>{key.toUpperCase()} <span className="text-muted-foreground">× {key.includes("%") || ["er","cr","cd"].includes(key) ? `${(SUBSTAT_SCALARS[key] * 100).toFixed(2)}%` : SUBSTAT_SCALARS[key]}</span></FieldLabel><Input id={`${build.id}-${key}`} type="number" min={0} max={99} value={build.rolls[key]} onChange={(event) => update("rolls", { ...build.rolls, [key]: Math.max(0, Math.min(99, Number(event.target.value))) })} /><FieldDescription>{key.includes("%") || ["er","cr","cd"].includes(key) ? `${(calculated[key] * 100).toFixed(2)}%` : calculated[key].toFixed(2)}</FieldDescription></Field>)}</div></FieldSet>
</FieldGroup></CardContent></Card>;
}
export function Translator({ catalog }: { catalog: Catalog }) {
const [translation, setTranslation] = useState<Translation>(() => ({ name: "My builds", teams: [makeTeam(catalog, 1, true)] }));
const [activeTeam, setActiveTeam] = useState(translation.teams[0].id);
useEffect(() => { localStorage.setItem("gcsim-translator-draft", JSON.stringify(translation)); }, [translation]);
useEffect(() => { const stored = localStorage.getItem("gcsim-translator-draft"); if (stored) try { const parsed = translationSchema.safeParse(JSON.parse(stored)); if (parsed.success) setTimeout(() => { setTranslation(parsed.data); setActiveTeam(parsed.data.teams[0].id); }, 0); } catch { localStorage.removeItem("gcsim-translator-draft"); } }, []);
const team = translation.teams.find((item) => item.id === activeTeam) ?? translation.teams[0];
const output = useMemo(() => generateTeam(team), [team]);
const updateTeam = (next: Team) => setTranslation((current) => ({ ...current, teams: current.teams.map((item) => item.id === next.id ? next : item) }));
const copy = async (value = output) => {
try {
await copyText(value);
toast.success("GCSim build copied");
} catch {
toast.error("Could not copy automatically. Select the generated config and copy it manually.");
}
};
return <main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-8 px-4 py-8 sm:px-6 lg:py-12"><Header /><div className="flex flex-col gap-2"><Field className="max-w-md"><FieldLabel htmlFor="translation-name">Collection name</FieldLabel><Input id="translation-name" value={translation.name} onChange={(event) => setTranslation({ ...translation, name: event.target.value })} /><FieldDescription>Saved automatically in this browser. Nothing is written to the database.</FieldDescription></Field></div><Tabs value={activeTeam} onValueChange={setActiveTeam}><div className="flex items-center gap-2 overflow-x-auto"><TabsList>{translation.teams.map((item) => <TabsTrigger key={item.id} value={item.id}>{item.name}</TabsTrigger>)}</TabsList><Button variant="outline" size="icon" disabled={translation.teams.length >= 12} onClick={() => { const next = makeTeam(catalog, translation.teams.length + 1); setTranslation({ ...translation, teams: [...translation.teams, next] }); setActiveTeam(next.id); }} aria-label="Add team"><PlusIcon /></Button></div>{translation.teams.map((item) => <TabsContent key={item.id} value={item.id}><div className="grid gap-8 xl:grid-cols-[minmax(0,1fr)_420px]"><div className="flex flex-col gap-6"><div className="flex items-end gap-3"><Field><FieldLabel>Team name</FieldLabel><Input value={item.name} onChange={(event) => updateTeam({ ...item, name: event.target.value })} /></Field><Button variant="ghost" size="icon" disabled={translation.teams.length === 1} onClick={() => { const teams = translation.teams.filter((candidate) => candidate.id !== item.id); setTranslation({ ...translation, teams }); setActiveTeam(teams[0].id); }} aria-label="Remove team"><Trash2Icon /></Button></div>{item.builds.map((build) => <BuildEditor key={build.id} build={build} catalog={catalog} canRemove={item.builds.length > 1} onRemove={() => updateTeam({ ...item, builds: item.builds.filter((candidate) => candidate.id !== build.id) })} onChange={(next) => updateTeam({ ...item, builds: item.builds.map((candidate) => candidate.id === next.id ? next : candidate) })} />)}<Button variant="outline" disabled={item.builds.length >= 4} onClick={() => { const used = new Set(item.builds.map((build) => build.characterId)); const character = catalog.characters.find((candidate) => !used.has(candidate.id)); if (!character) return toast.error("No more characters available"); updateTeam({ ...item, builds: [...item.builds, makeBuild(catalog, character)] }); }}><PlusIcon data-icon="inline-start" />Add character ({item.builds.length}/4)</Button></div><div className="xl:sticky xl:top-8 xl:self-start"><OutputCard output={output} onCopy={() => copy()} /></div></div></TabsContent>)}</Tabs></main>;
}
function Header() { return <header className="flex flex-col gap-3"><div className="flex items-center gap-3"><Badge variant="secondary">GCSIM</Badge><span className="text-sm text-muted-foreground">Build translator</span></div><h1 className="max-w-3xl text-4xl font-semibold tracking-tight sm:text-5xl">Build teams, not config syntax.</h1><p className="max-w-2xl text-muted-foreground">Choose loadouts and average artifact rolls. Get clean character blocks ready to paste.</p></header>; }
function OutputCard({ output, onCopy }: { output: string; onCopy: () => void }) { return <Card><CardHeader><CardTitle>Generated config</CardTitle><CardDescription>Character build block for the active team.</CardDescription></CardHeader><CardContent><Textarea className="min-h-96 font-mono text-xs" value={output} readOnly aria-label="Generated gcsim config" /></CardContent><CardFooter><Button className="w-full" onClick={onCopy}><CopyIcon data-icon="inline-start" />Copy config</Button></CardFooter></Card>; }