"use client"; import { useEffect, useMemo, useState } from "react"; import { CopyIcon, PlusIcon, Trash2Icon } from "lucide-react"; import { toast } from "sonner"; import { createBuild } from "@/lib/translator/build"; import { ImportGcsimDialog } from "@/components/import-gcsim-dialog"; import { ImportTeamGuideDialog } from "@/components/import-team-guide-dialog"; import { CatalogCombobox } from "@/components/catalog-combobox"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from "@/components/ui/card"; import { Field, FieldDescription, FieldError, 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 { Toggle } from "@/components/ui/toggle"; import { translateRotation } from "@/lib/translator/rotation"; 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 { importedStatKeys, rollKeys, translationSchema, type Build, type Catalog, type CatalogItem, type MainStatKey, type Team, type Translation, } from "@/lib/translator/types"; import type { TeamGuide } from "@/lib/team-guides"; 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 { return createBuild(catalog, character, id); } 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 { 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 ( ); } function StatSelect({ value, values, onChange, }: { value: MainStatKey; values: MainStatKey[]; onChange: (value: MainStatKey) => void; }) { 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) => { 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, importedParams: undefined, }); }; 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} ·{" "} {build.importedStats ? "Imported artifact stats" : `${totalRolls} total rolls`}
{finalStats.length ? ( finalStats.map((stat) => ( {formatFinalStat(stat)} )) ) : ( Final stats unavailable )}
Character
Level v <= build.maxLevel), build.level, ]), ).sort((a, b) => a - b)} 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, importedParams: build.importedParams ? { ...build.importedParams, weapon: undefined } : undefined, }) } placeholder="Choose weapon" /> Level v <= build.weaponMaxLevel), build.weaponLevel, ]), ).sort((a, b) => a - b)} onChange={(v) => update("weaponLevel", v)} /> Ascension cap onChange({ ...build, weaponMaxLevel: v, weaponLevel: Math.min(build.weaponLevel, v), }) } /> Refinement update("refinement", v)} />
{build.importedSets ? ( ) : (
Artifact sets and main stats
Set layout First 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} {!build.importedStats && ( <> Sands update("sands", v as Build["sands"])} /> Goblet update("goblet", v as Build["goblet"])} /> Circlet update("circlet", v as Build["circlet"]) } /> )}
)} {build.importedSets && !build.importedStats && (
Artifact main stats Sands update("sands", v as Build["sands"])} /> Goblet update("goblet", v as Build["goblet"])} /> Circlet update("circlet", v as Build["circlet"])} />
)} {build.importedStats ? ( ) : (
Average substat rolls Enter roll counts. Values use gcsim's KQM average-roll scalars.
{rollKeys.map((key) => ( {key.toUpperCase()}{" "} ×{" "} {key.includes("%") || ["er", "cr", "cd"].includes(key) ? `${(SUBSTAT_SCALARS[key] * 100).toFixed(2)}%` : SUBSTAT_SCALARS[key]} update("rolls", { ...build.rolls, [key]: Math.max( 0, Math.min(99, Number(event.target.value)), ), }) } /> {key.includes("%") || ["er", "cr", "cd"].includes(key) ? `${(calculated[key] * 100).toFixed(2)}%` : calculated[key].toFixed(2)} ))}
)} {build.importedParams && ( Imported parameters:{" "} {Object.entries(build.importedParams) .filter(([, value]) => value) .map(([key, value]) => `${key} ${value}`) .join(" · ")} )}
); } export function Translator({ catalog, teamGuides, }: { catalog: Catalog; teamGuides: TeamGuide[]; }) { const [translation, setTranslation] = useState(() => ({ name: "My builds", teams: [makeTeam(catalog, 1, true)], })); const [activeTeam, setActiveTeam] = useState(translation.teams[0].id); const [draftLoaded, setDraftLoaded] = useState(false); useEffect(() => { if (draftLoaded) localStorage.setItem( "gcsim-translator-draft", JSON.stringify(translation), ); }, [translation, draftLoaded]); useEffect(() => { const stored = localStorage.getItem("gcsim-translator-draft"); let restored: Translation | undefined; if (stored) try { const parsed = translationSchema.safeParse(JSON.parse(stored)); if (parsed.success) restored = parsed.data; } catch { localStorage.removeItem("gcsim-translator-draft"); } const timer = setTimeout(() => { if (restored) { setTranslation(restored); setActiveTeam(restored.teams[0].id); } setDraftLoaded(true); }, 0); return () => clearTimeout(timer); }, []); const team = translation.teams.find((item) => item.id === activeTeam) ?? translation.teams[0]; const output = useMemo(() => generateTeam(team), [team]); const rotationError = useMemo(() => translateRotation(team).error, [team]); const updateTeam = (next: Team) => setTranslation((current) => ({ ...current, teams: current.teams.map((item) => (item.id === next.id ? next : item)), })); const copy = async (value = output) => { if (rotationError) return; try { await copyText(value); toast.success("GCSim config copied"); } catch { toast.error( "Could not copy automatically. Select the generated config and copy it manually.", ); } }; const addImportedTeam = (imported: Team, source: string) => { const next: Team = { ...imported, id: newId("team"), builds: imported.builds.map((build) => ({ ...build, id: newId("build"), })), }; setTranslation((current) => ({ ...current, teams: [...current.teams, next], })); setActiveTeam(next.id); toast.success(`${source} team imported`); }; const importDisabled = !draftLoaded || translation.teams.length >= 12; return (
addImportedTeam(team, "Guide")} /> addImportedTeam(team, "Gcsim")} />
Collection name setTranslation({ ...translation, name: event.target.value }) } /> Saved automatically in this browser.
{translation.teams.map((item) => ( {item.name} ))}
{translation.teams.map((item) => (
Team name updateTeam({ ...item, name: event.target.value }) } />
{item.builds.map((build) => ( 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, ), }) } /> ))}
copy()} copyDisabled={Boolean(rotationError)} />
))}
); } function Header() { return (
GCSIM Build translator

Build teams, not config syntax.

Choose loadouts and average artifact rolls. Get clean character blocks ready to paste.

); } function OutputCard({ output, onCopy, copyDisabled, }: { output: string; onCopy: () => void; copyDisabled: boolean; }) { return ( Generated config Character builds and optional rotation for the active team.