feat : add import from gcsim and roation translater
CI / verify (push) Successful in 42s
CI / build-and-deploy (push) Successful in 1m57s

This commit is contained in:
2026-09-13 15:22:31 +07:00 Unverified
parent 7cfdfee62e
commit 73efb513d2
14 changed files with 888 additions and 26 deletions
+70 -17
View File
@@ -3,22 +3,25 @@
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 { 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, FieldGroup, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field";
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 { rollKeys, translationSchema, type Build, type Catalog, type CatalogItem, type MainStatKey, type RollKey, type Team, type Translation } from "@/lib/translator/types";
import { importedStatKeys, rollKeys, translationSchema, type Build, type Catalog, type CatalogItem, type MainStatKey, 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%"];
@@ -30,9 +33,7 @@ function newId(prefix: string): string {
}
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 } };
return createBuild(catalog, character, id);
}
function makeTeam(catalog: Catalog, index: number, deterministic = false): Team {
const id = deterministic ? `team-${index}` : newId("team");
@@ -75,43 +76,95 @@ function BuildEditor({ build, catalog, onChange, onRemove, canRemove }: { build:
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 });
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 <Card><CardHeader><div><CardTitle>{build.characterName}</CardTitle><CardDescription>{build.characterKey} · {totalRolls} total rolls</CardDescription></div><CardAction className="flex items-start gap-2"><div className="flex flex-col items-end gap-1" aria-label="Final level 90 stats">{finalStats.length ? finalStats.map((stat) => <Badge key={stat.key} variant="secondary">{formatFinalStat(stat)}</Badge>) : <span className="text-sm text-muted-foreground">Final stats unavailable</span>}</div><Button variant="ghost" size="icon" disabled={!canRemove} onClick={onRemove} aria-label="Remove character"><Trash2Icon /></Button></CardAction></CardHeader><CardContent><FieldGroup>
return <Card><CardHeader><div><CardTitle>{build.characterName}</CardTitle><CardDescription>{build.characterKey} · {build.importedStats ? "Imported artifact stats" : `${totalRolls} total rolls`}</CardDescription></div><CardAction className="flex items-start gap-2"><div className="flex flex-col items-end gap-1" aria-label="Final level 90 stats">{finalStats.length ? finalStats.map((stat) => <Badge key={stat.key} variant="secondary">{formatFinalStat(stat)}</Badge>) : <span className="text-sm text-muted-foreground">Final stats unavailable</span>}</div><Button variant="ghost" size="icon" disabled={!canRemove} onClick={onRemove} aria-label="Remove character"><Trash2Icon /></Button></CardAction></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>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4"><Field><FieldLabel>Level</FieldLabel><NumberSelect value={build.level} values={Array.from(new Set([...levels.filter((v) => v <= build.maxLevel), build.level])).sort((a, b) => a - b)} 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>
<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, importedParams: build.importedParams ? { ...build.importedParams, weapon: undefined } : undefined })} placeholder="Choose weapon" /><BaseStats stats={selectedWeapon?.baseStats ?? []} /></Field><Field><FieldLabel>Level</FieldLabel><NumberSelect value={build.weaponLevel} values={Array.from(new Set([...levels.filter((v) => v <= build.weaponMaxLevel), build.weaponLevel])).sort((a, b) => a - b)} 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>
{build.importedSets ? <ImportedSetsEditor build={build} catalog={catalog} onChange={onChange} /> : <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}{!build.importedStats && <><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>}
{build.importedSets && !build.importedStats && <FieldSet><FieldLegend>Artifact main stats</FieldLegend><FieldGroup className="grid gap-6 sm:grid-cols-3"><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></FieldGroup></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>
{build.importedStats ? <ImportedStatsEditor build={build} onChange={onChange} /> : <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>}
{build.importedParams && <FieldDescription>Imported parameters: {Object.entries(build.importedParams).filter(([, value]) => value).map(([key, value]) => `${key} ${value}`).join(" · ")}</FieldDescription>}
</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 [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 build copied");
toast.success("GCSim config 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>;
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 /><ImportGcsimDialog catalog={catalog} disabled={!draftLoaded || translation.teams.length >= 12} onImport={(imported) => {
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("Gcsim team imported");
}} /><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="flex flex-col gap-6 xl:sticky xl:top-8 xl:self-start"><RotationEditor team={item} onChange={updateTeam} /><OutputCard output={output} onCopy={() => copy()} copyDisabled={Boolean(rotationError)} /></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>; }
function OutputCard({ output, onCopy, copyDisabled }: { output: string; onCopy: () => void; copyDisabled: boolean }) { return <Card><CardHeader><CardTitle>Generated config</CardTitle><CardDescription>Character builds and optional rotation 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} disabled={copyDisabled}><CopyIcon data-icon="inline-start" />Copy config</Button></CardFooter></Card>; }
function RotationEditor({ team, onChange }: { team: Team; onChange: (team: Team) => void }) {
const rotation = team.rotation ?? { enabled: false, text: "", repeat: false };
const error = translateRotation(team).error;
const update = (patch: Partial<typeof rotation>) => onChange({ ...team, rotation: { ...rotation, ...patch } });
const inputId = `${team.id}-rotation`;
return <Card>
<CardHeader><CardTitle>Rotation translator</CardTitle><CardDescription>{rotation.format === "gcsim" ? "Imported script with its original loops and action parameters." : "Write your combo using character names and shorthand."}</CardDescription></CardHeader>
<CardContent><FieldGroup>
<Field><Toggle variant="outline" pressed={rotation.enabled} onPressedChange={(enabled) => update({ enabled })} aria-controls={`${inputId}-controls`}>Include rotation</Toggle><FieldDescription>{rotation.enabled ? "Rotation actions will be included in the copied config." : "Off: only character builds are included."}</FieldDescription></Field>
{rotation.enabled && <FieldGroup id={`${inputId}-controls`}>
<Field data-invalid={Boolean(error)}><FieldLabel htmlFor={inputId}>{rotation.format === "gcsim" ? "Imported gcsim rotation" : "Rotation"}</FieldLabel><Textarea id={inputId} value={rotation.text} maxLength={10000} onChange={(event) => update({ text: event.target.value })} placeholder={rotation.format === "gcsim" ? "mavuika skill;" : "Mavuika E > Xilonen E N2"} aria-invalid={Boolean(error)} aria-describedby={`${inputId}-help${error ? ` ${inputId}-error` : ""}`} /><FieldDescription id={`${inputId}-help`}>{rotation.format === "gcsim" ? "Edit the imported gcsim script directly. Brackets are checked here; full script validation happens in gcsim." : <>Use names or keys from this team: {team.builds.map((build) => build.characterName).join(", ")}. Separate character steps with &gt; or a new line.</>}</FieldDescription>{error && <FieldError id={`${inputId}-error`}>{error}</FieldError>}</Field>
{rotation.format === "gcsim" ? <Field><Button variant="outline" onClick={() => update({ format: "shorthand", text: "", repeat: false })}>Clear script and use shorthand</Button></Field> : <><Field><FieldDescription>E = skill · Q = burst · N2 = 2 normals · C = charged attack · D = dash · J = jump. tE / hE = tap / hold skill. W(60) = wait 60 frames. Combine actions: N2CD. Repeat a combo: 3(N2CD).</FieldDescription></Field>
<Field><Toggle variant="outline" pressed={rotation.repeat} onPressedChange={(repeat) => update({ repeat })}>Repeat rotation</Toggle><FieldDescription>{rotation.repeat ? "Loop until the simulation ends." : "Run the rotation once."}</FieldDescription></Field></>}
</FieldGroup>}
</FieldGroup></CardContent>
</Card>;
}
function ImportedStatsEditor({ build, onChange }: { build: Build; onChange: (build: Build) => void }) {
return <FieldSet><FieldLegend>Imported artifact stats</FieldLegend><FieldDescription>Exact totals from add stats lines. Percent bonuses use decimals: 0.466 = 46.6%. These replace main stats and roll estimates.</FieldDescription>
<FieldGroup className="grid gap-5 sm:grid-cols-2 lg:grid-cols-5">{importedStatKeys.map((key) => <Field key={key}><FieldLabel htmlFor={`${build.id}-imported-${key}`}>{key.toUpperCase()}</FieldLabel><Input id={`${build.id}-imported-${key}`} type="number" step="any" value={build.importedStats?.[key] ?? 0} onChange={(event) => { const value = Number(event.target.value); if (Number.isFinite(value)) onChange({ ...build, importedStats: { ...build.importedStats, [key]: value } }); }} /></Field>)}</FieldGroup>
<Button variant="outline" onClick={() => onChange({ ...build, importedStats: undefined })}>Replace imported stats with main stats and rolls</Button>
</FieldSet>;
}
function ImportedSetsEditor({ build, catalog, onChange }: { build: Build; catalog: Catalog; onChange: (build: Build) => void }) {
const sets = build.importedSets ?? [];
return <FieldSet><FieldLegend>Imported artifact sets</FieldLegend><FieldGroup>{sets.length ? sets.map((set, index) => <FieldGroup key={`${set.id}-${index}`} className="gap-3"><Field><FieldLabel>Artifact set</FieldLabel><CatalogCombobox items={catalog.artifacts} value={set.id} onChange={(item) => onChange({ ...build, importedSets: sets.map((candidate, i) => i === index ? { id: item.id, key: item.key, name: item.name, count: set.count } : candidate) })} placeholder="Choose artifact" /></Field><Field><FieldLabel htmlFor={`${build.id}-set-${index}`}>Piece count</FieldLabel><Input id={`${build.id}-set-${index}`} type="number" min={1} max={5 - sets.reduce((sum, candidate, i) => sum + (i === index ? 0 : candidate.count), 0)} value={set.count} onChange={(event) => { const max = 5 - sets.reduce((sum, candidate, i) => sum + (i === index ? 0 : candidate.count), 0); const count = Math.max(1, Math.min(max, Math.trunc(Number(event.target.value)))); if (Number.isFinite(count)) onChange({ ...build, importedSets: sets.map((candidate, i) => i === index ? { ...candidate, count } : candidate) }); }} /></Field>{set.params && <FieldDescription>Parameters: {set.params}</FieldDescription>}</FieldGroup>) : <FieldDescription>No artifact set bonuses were specified.</FieldDescription>}</FieldGroup><Button variant="outline" onClick={() => onChange({ ...build, importedSets: undefined })}>Replace imported sets with the set editor</Button></FieldSet>;
}