1182 lines
37 KiB
TypeScript
1182 lines
37 KiB
TypeScript
"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'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 >
|
||
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>
|
||
);
|
||
}
|