Files
gcsim-translater/components/translator.tsx
T
gunshiz 73efb513d2
CI / verify (push) Successful in 42s
CI / build-and-deploy (push) Successful in 1m57s
feat : add import from gcsim and roation translater
2026-09-13 15:22:31 +07:00

171 lines
24 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 { 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, 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";
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<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, 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} · {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={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, 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 />
{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 />
{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);
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.");
}
};
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, 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>;
}