Files
gunshiz b3bc4c3bb4
CI / verify (push) Successful in 59s
CI / build-and-deploy (push) Successful in 1m38s
feat : add import from buzz guide
2026-09-20 00:41:02 +07:00

1182 lines
37 KiB
TypeScript
Raw Permalink 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 { 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<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,
teamGuides,
}: {
catalog: Catalog;
teamGuides: TeamGuide[];
}) {
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.",
);
}
};
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 (
<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-wrap gap-3">
<ImportTeamGuideDialog
catalog={catalog}
guides={teamGuides}
disabled={importDisabled}
onImport={(team) => addImportedTeam(team, "Guide")}
/>
<ImportGcsimDialog
catalog={catalog}
disabled={importDisabled}
onImport={(team) => addImportedTeam(team, "Gcsim")}
/>
</div>
<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.
</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
? "Includes rotation, the fixed delay helper, target, and energy settings."
: "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(", ")}
. Use mc for Traveler. 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>
);
}