diff --git a/app/page.tsx b/app/page.tsx
index 55d1132..2d30261 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,6 +1,8 @@
import { Translator } from "@/components/translator";
import { getCatalog } from "@/lib/catalog";
+import { getTeamGuides } from "@/lib/team-guide-data";
export default async function Home() {
- return ;
+ const [catalog, teamGuides] = await Promise.all([getCatalog(), getTeamGuides()]);
+ return ;
}
diff --git a/components/import-team-guide-dialog.tsx b/components/import-team-guide-dialog.tsx
new file mode 100644
index 0000000..95fd391
--- /dev/null
+++ b/components/import-team-guide-dialog.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { BookOpenIcon, CheckIcon, ChevronsUpDownIcon } from "lucide-react";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
+import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
+import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { importGuideTeam, type TeamGuide, type TeamGuideTeam } from "@/lib/team-guides";
+import { rarityGradientClass } from "@/lib/catalog-rarity";
+import { cn } from "@/lib/utils";
+import type { Catalog, Team } from "@/lib/translator/types";
+
+function GuideCharacter({ guide, catalog }: { guide: TeamGuide; catalog: Catalog }) {
+ const character = catalog.characters.find((item) => item.id === guide.characterId);
+ return {character?.name.slice(0, 1) ?? guide.name.slice(0, 1)} ;
+}
+
+function GuidePicker({ guides, catalog, value, onChange }: { guides: TeamGuide[]; catalog: Catalog; value: string; onChange: (id: string) => void }) {
+ const [open, setOpen] = useState(false);
+ const selected = guides.find((guide) => guide.id === value);
+ return
+ }>
+ {selected && }{selected?.name ?? "Choose guide"}
+
+ No matching guide. {guides.map((guide) => { onChange(guide.id); setOpen(false); }}>{guide.name} {guide.teams.length} )}
+ ;
+}
+
+function TeamCharacters({ team, catalog }: { team: TeamGuideTeam; catalog: Catalog }) {
+ return
{team.members.map((member) => {
+ const character = catalog.characters.find((item) => item.id === member.characterId);
+ return
{character?.name.slice(0, 1) ?? "?"} ;
+ })}
;
+}
+
+function TeamPicker({ groups, team, catalog, onChange }: { groups: [string, TeamGuideTeam[]][]; team: TeamGuideTeam | undefined; catalog: Catalog; onChange: (id: string) => void }) {
+ const [open, setOpen] = useState(false);
+ return
+ }>
+ {team ? <>{team.title} > : Choose team }
+
+ No matching team. {groups.map(([label, teams]) => {teams.map((item) => {
+ const characterNames = item.members.map((member) => catalog.characters.find((character) => character.id === member.characterId)?.name).filter(Boolean).join(" ");
+ return { onChange(item.id); setOpen(false); }}>{item.title} ;
+ })} )}
+ ;
+}
+
+export function ImportTeamGuideDialog({ catalog, guides, disabled, onImport }: { catalog: Catalog; guides: TeamGuide[]; disabled: boolean; onImport: (team: Team) => void }) {
+ const [open, setOpen] = useState(false);
+ const [guideId, setGuideId] = useState(guides[0]?.id ?? "");
+ const guide = guides.find((item) => item.id === guideId) ?? guides[0];
+ const [teamId, setTeamId] = useState(guide?.teams[0]?.id ?? "");
+ const team = guide?.teams.find((item) => item.id === teamId) ?? guide?.teams[0];
+ const preview = useMemo(() => team ? importGuideTeam(team, catalog) : undefined, [team, catalog]);
+ const groups = useMemo(() => {
+ const result = new Map["teams"]>();
+ for (const item of guide?.teams ?? []) {
+ const label = item.conditionName?.trim() || "Teams";
+ result.set(label, [...(result.get(label) ?? []), item]);
+ }
+ return [...result.entries()];
+ }, [guide]);
+
+ const chooseGuide = (id: string) => {
+ const next = guides.find((item) => item.id === id);
+ setGuideId(id);
+ setTeamId(next?.teams[0]?.id ?? "");
+ };
+
+ return
+ }> IMPORT FROM BUZZ GUIDE
+
+ Import from Buzz Guide Choose a public guide team to import its characters, weapons, artifacts, constellations, refinements, and combo.
+
+ Guide {guide?.teams.length ?? 0} teams available.
+ Team
+ {preview?.error && Cannot import this team {preview.error} }
+ {preview?.team && <>
+ Loadout preview {preview.team.builds.map((build) =>
{build.characterName.slice(0, 1)} {build.characterName} C{build.constellation}
{build.weaponName} R{build.refinement}
{build.artifactOneName}{build.artifactTwoName ? ` + ${build.artifactTwoName}` : " · 4pc"}
)}
+ {team?.combo.trim() && team.combo.trim() !== "-" && {preview.team.rotation?.enabled ? "Combo will be included" : "Combo saved as reference"} {preview.team.rotation?.enabled ? "The guide combo is valid shorthand and will be enabled." : "The combo uses unsupported guide notation, so it will be preserved with rotation disabled."} }
+ >}
+
+ }>Cancel { if (!preview?.team || disabled) return; onImport(preview.team); setOpen(false); }}>Import team
+
+ ;
+}
diff --git a/components/translator.tsx b/components/translator.tsx
index b52dedd..5bdeeef 100644
--- a/components/translator.tsx
+++ b/components/translator.tsx
@@ -5,39 +5,122 @@ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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"];
+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)}`;
+ 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 {
+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 {
+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"))] };
+ return {
+ id,
+ name: `Team ${index}`,
+ builds: [
+ makeBuild(
+ catalog,
+ catalog.characters[0],
+ deterministic ? `build-${index}-1` : newId("build"),
+ ),
+ ],
+ };
}
async function copyText(value: string): Promise {
@@ -57,114 +140,1042 @@ async function copyText(value: string): Promise {
if (!copied) throw new Error("Clipboard access is unavailable.");
}
-function NumberSelect({ value, values, onChange }: { value: number; values: number[]; onChange: (value: number) => void }) {
- return onChange(Number(next))}>{values.map((item) => {item} )} ;
+function NumberSelect({
+ value,
+ values,
+ onChange,
+}: {
+ value: number;
+ values: number[];
+ onChange: (value: number) => void;
+}) {
+ return (
+ onChange(Number(next))}
+ >
+
+
+
+
+
+ {values.map((item) => (
+
+ {item}
+
+ ))}
+
+
+
+ );
}
-function StatSelect({ value, values, onChange }: { value: MainStatKey; values: MainStatKey[]; onChange: (value: MainStatKey) => void }) {
- return onChange(next as MainStatKey)}>{values.map((item) => {item.toUpperCase()} )} ;
+function StatSelect({
+ value,
+ values,
+ onChange,
+}: {
+ value: MainStatKey;
+ values: MainStatKey[];
+ onChange: (value: MainStatKey) => void;
+}) {
+ return (
+ onChange(next as MainStatKey)}
+ >
+
+
+
+
+
+ {values.map((item) => (
+
+ {item.toUpperCase()}
+
+ ))}
+
+
+
+ );
}
function BaseStats({ stats }: { stats: CatalogItem["baseStats"] }) {
- if (!stats.length) return Level 90 base stats unavailable. ;
- return {stats.map((stat) => {formatBaseStat(stat)} )}
;
+ if (!stats.length)
+ return (
+ Level 90 base stats unavailable.
+ );
+ return (
+
+ {stats.map((stat) => (
+
+ {formatBaseStat(stat)}
+
+ ))}
+
+ );
}
-function BuildEditor({ build, catalog, onChange, onRemove, canRemove }: { build: Build; catalog: Catalog; onChange: (build: Build) => void; onRemove: () => void; canRemove: boolean }) {
- const selectedCharacter = catalog.characters.find((item) => item.id === build.characterId);
- const selectedWeapon = catalog.weapons.find((item) => item.id === build.weaponId);
- const weapons = catalog.weapons.filter((item) => !selectedCharacter?.weaponType || item.weaponType === selectedCharacter.weaponType);
- const update = (key: K, value: Build[K]) => onChange({ ...build, [key]: value });
+function BuildEditor({
+ build,
+ catalog,
+ onChange,
+ onRemove,
+ canRemove,
+}: {
+ build: Build;
+ catalog: Catalog;
+ onChange: (build: Build) => void;
+ onRemove: () => void;
+ canRemove: boolean;
+}) {
+ const selectedCharacter = catalog.characters.find(
+ (item) => item.id === build.characterId,
+ );
+ const selectedWeapon = catalog.weapons.find(
+ (item) => item.id === build.weaponId,
+ );
+ const weapons = catalog.weapons.filter(
+ (item) =>
+ !selectedCharacter?.weaponType ||
+ item.weaponType === selectedCharacter.weaponType,
+ );
+ const update = (key: K, value: Build[K]) =>
+ onChange({ ...build, [key]: value });
const chooseCharacter = (item: CatalogItem) => {
- const weapon = catalog.weapons.find((candidate) => !item.weaponType || candidate.weaponType === item.weaponType) ?? catalog.weapons[0];
- onChange({ ...build, characterId: item.id, characterKey: item.key, characterName: item.name, characterImageUrl: item.imageUrl, weaponId: weapon.id, weaponKey: weapon.key, weaponName: weapon.name, importedParams: undefined });
+ const weapon =
+ catalog.weapons.find(
+ (candidate) =>
+ !item.weaponType || candidate.weaponType === item.weaponType,
+ ) ?? catalog.weapons[0];
+ onChange({
+ ...build,
+ characterId: item.id,
+ characterKey: item.key,
+ characterName: item.name,
+ characterImageUrl: item.imageUrl,
+ weaponId: weapon.id,
+ weaponKey: weapon.key,
+ weaponName: weapon.name,
+ importedParams: undefined,
+ });
};
const calculated = calculateSubstats(build);
- const finalStats = selectedCharacter?.baseStats.length && selectedWeapon?.baseStats.length
- ? calculateFinalStats(build, selectedCharacter.baseStats, selectedWeapon.baseStats)
- : [];
- const totalRolls = Object.values(build.rolls).reduce((sum, value) => sum + value, 0);
- return {build.characterName} {build.characterKey} · {build.importedStats ? "Imported artifact stats" : `${totalRolls} total rolls`}
{finalStats.length ? finalStats.map((stat) => {formatFinalStat(stat)} ) : Final stats unavailable }
- Character
- Level v <= build.maxLevel), build.level])).sort((a, b) => a - b)} onChange={(v) => update("level", v)} /> Ascension cap onChange({ ...build, maxLevel: v, level: Math.min(build.level, v) })} /> Constellation update("constellation", v)} /> Talents NA / Skill / Burst {build.talents.map((value, index) => { const next = [...build.talents] as Build["talents"]; next[index] = Number(event.target.value); update("talents", next); }} aria-label={["Normal attack", "Skill", "Burst"][index]} />)}
-
- Weapon onChange({ ...build, weaponId: item.id, weaponKey: item.key, weaponName: item.name, importedParams: build.importedParams ? { ...build.importedParams, weapon: undefined } : undefined })} placeholder="Choose weapon" /> Level v <= build.weaponMaxLevel), build.weaponLevel])).sort((a, b) => a - b)} onChange={(v) => update("weaponLevel", v)} /> Ascension cap onChange({ ...build, weaponMaxLevel: v, weaponLevel: Math.min(build.weaponLevel, v) })} /> Refinement update("refinement", v)} />
-
- {build.importedSets ? : Artifact sets and main stats Set layout update("artifactMode", v as Build["artifactMode"])}>4-piece set 2-piece + 2-piece First set onChange({ ...build, artifactOneId: item.id, artifactOneKey: item.key, artifactOneName: item.name })} placeholder="Choose artifact" /> {build.artifactMode === "2+2" ? Second set item.id !== build.artifactOneId)} value={build.artifactTwoId} onChange={(item) => onChange({ ...build, artifactTwoId: item.id, artifactTwoKey: item.key, artifactTwoName: item.name })} placeholder="Choose artifact" /> : null}{!build.importedStats && <>Sands update("sands", v as Build["sands"])} /> Goblet update("goblet", v as Build["goblet"])} /> Circlet update("circlet", v as Build["circlet"])} /> >}
}
- {build.importedSets && !build.importedStats && Artifact main stats Sands update("sands", v as Build["sands"])} /> Goblet update("goblet", v as Build["goblet"])} /> Circlet update("circlet", v as Build["circlet"])} /> }
-
- {build.importedStats ? : Average substat rolls Enter roll counts. Values use gcsim's KQM average-roll scalars. {rollKeys.map((key) => {key.toUpperCase()} × {key.includes("%") || ["er","cr","cd"].includes(key) ? `${(SUBSTAT_SCALARS[key] * 100).toFixed(2)}%` : SUBSTAT_SCALARS[key]} update("rolls", { ...build.rolls, [key]: Math.max(0, Math.min(99, Number(event.target.value))) })} />{key.includes("%") || ["er","cr","cd"].includes(key) ? `${(calculated[key] * 100).toFixed(2)}%` : calculated[key].toFixed(2)} )}
}
- {build.importedParams && Imported parameters: {Object.entries(build.importedParams).filter(([, value]) => value).map(([key, value]) => `${key} ${value}`).join(" · ")} }
- ;
+ const finalStats =
+ selectedCharacter?.baseStats.length && selectedWeapon?.baseStats.length
+ ? calculateFinalStats(
+ build,
+ selectedCharacter.baseStats,
+ selectedWeapon.baseStats,
+ )
+ : [];
+ const totalRolls = Object.values(build.rolls).reduce(
+ (sum, value) => sum + value,
+ 0,
+ );
+ return (
+
+
+
+ {build.characterName}
+
+ {build.characterKey} ·{" "}
+ {build.importedStats
+ ? "Imported artifact stats"
+ : `${totalRolls} total rolls`}
+
+
+
+
+ {finalStats.length ? (
+ finalStats.map((stat) => (
+
+ {formatFinalStat(stat)}
+
+ ))
+ ) : (
+
+ Final stats unavailable
+
+ )}
+
+
+
+
+
+
+
+
+
+ Character
+
+
+
+
+
+ Level
+ v <= build.maxLevel),
+ build.level,
+ ]),
+ ).sort((a, b) => a - b)}
+ onChange={(v) => update("level", v)}
+ />
+
+
+ Ascension cap
+
+ onChange({
+ ...build,
+ maxLevel: v,
+ level: Math.min(build.level, v),
+ })
+ }
+ />
+
+
+ Constellation
+ update("constellation", v)}
+ />
+
+
+ Talents NA / Skill / Burst
+
+ {build.talents.map((value, index) => (
+ {
+ const next = [...build.talents] as Build["talents"];
+ next[index] = Number(event.target.value);
+ update("talents", next);
+ }}
+ aria-label={["Normal attack", "Skill", "Burst"][index]}
+ />
+ ))}
+
+
+
+
+
+
+ Weapon
+
+ onChange({
+ ...build,
+ weaponId: item.id,
+ weaponKey: item.key,
+ weaponName: item.name,
+ importedParams: build.importedParams
+ ? { ...build.importedParams, weapon: undefined }
+ : undefined,
+ })
+ }
+ placeholder="Choose weapon"
+ />
+
+
+
+ Level
+ v <= build.weaponMaxLevel),
+ build.weaponLevel,
+ ]),
+ ).sort((a, b) => a - b)}
+ onChange={(v) => update("weaponLevel", v)}
+ />
+
+
+ Ascension cap
+
+ onChange({
+ ...build,
+ weaponMaxLevel: v,
+ weaponLevel: Math.min(build.weaponLevel, v),
+ })
+ }
+ />
+
+
+ Refinement
+ update("refinement", v)}
+ />
+
+
+
+ {build.importedSets ? (
+
+ ) : (
+
+ Artifact sets and main stats
+
+
+ Set layout
+
+ update("artifactMode", v as Build["artifactMode"])
+ }
+ >
+
+
+
+
+
+ 4-piece set
+ 2-piece + 2-piece
+
+
+
+
+
+ First set
+
+ onChange({
+ ...build,
+ artifactOneId: item.id,
+ artifactOneKey: item.key,
+ artifactOneName: item.name,
+ })
+ }
+ placeholder="Choose artifact"
+ />
+
+ {build.artifactMode === "2+2" ? (
+
+ Second set
+ item.id !== build.artifactOneId,
+ )}
+ value={build.artifactTwoId}
+ onChange={(item) =>
+ onChange({
+ ...build,
+ artifactTwoId: item.id,
+ artifactTwoKey: item.key,
+ artifactTwoName: item.name,
+ })
+ }
+ placeholder="Choose artifact"
+ />
+
+ ) : null}
+ {!build.importedStats && (
+ <>
+
+ Sands
+ update("sands", v as Build["sands"])}
+ />
+
+
+ Goblet
+ update("goblet", v as Build["goblet"])}
+ />
+
+
+ Circlet
+
+ update("circlet", v as Build["circlet"])
+ }
+ />
+
+ >
+ )}
+
+
+ )}
+ {build.importedSets && !build.importedStats && (
+
+ Artifact main stats
+
+
+ Sands
+ update("sands", v as Build["sands"])}
+ />
+
+
+ Goblet
+ update("goblet", v as Build["goblet"])}
+ />
+
+
+ Circlet
+ update("circlet", v as Build["circlet"])}
+ />
+
+
+
+ )}
+
+ {build.importedStats ? (
+
+ ) : (
+
+ Average substat rolls
+
+ Enter roll counts. Values use gcsim's KQM average-roll
+ scalars.
+
+
+ {rollKeys.map((key) => (
+
+
+ {key.toUpperCase()}{" "}
+
+ ×{" "}
+ {key.includes("%") || ["er", "cr", "cd"].includes(key)
+ ? `${(SUBSTAT_SCALARS[key] * 100).toFixed(2)}%`
+ : SUBSTAT_SCALARS[key]}
+
+
+
+ update("rolls", {
+ ...build.rolls,
+ [key]: Math.max(
+ 0,
+ Math.min(99, Number(event.target.value)),
+ ),
+ })
+ }
+ />
+
+ {key.includes("%") || ["er", "cr", "cd"].includes(key)
+ ? `${(calculated[key] * 100).toFixed(2)}%`
+ : calculated[key].toFixed(2)}
+
+
+ ))}
+
+
+ )}
+ {build.importedParams && (
+
+ Imported parameters:{" "}
+ {Object.entries(build.importedParams)
+ .filter(([, value]) => value)
+ .map(([key, value]) => `${key} ${value}`)
+ .join(" · ")}
+
+ )}
+
+
+
+ );
}
-export function Translator({ catalog }: { catalog: Catalog }) {
- const [translation, setTranslation] = useState(() => ({ name: "My builds", teams: [makeTeam(catalog, 1, true)] }));
+export function Translator({
+ catalog,
+ teamGuides,
+}: {
+ catalog: Catalog;
+ teamGuides: TeamGuide[];
+}) {
+ const [translation, setTranslation] = useState(() => ({
+ name: "My builds",
+ teams: [makeTeam(catalog, 1, true)],
+ }));
const [activeTeam, setActiveTeam] = useState(translation.teams[0].id);
const [draftLoaded, setDraftLoaded] = useState(false);
- useEffect(() => { if (draftLoaded) localStorage.setItem("gcsim-translator-draft", JSON.stringify(translation)); }, [translation, draftLoaded]);
+ useEffect(() => {
+ 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"); }
+ 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); }
+ 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 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 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.");
+ toast.error(
+ "Could not copy automatically. Select the generated config and copy it manually.",
+ );
}
};
- return = 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] }));
+ 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("Gcsim team imported");
- }} />Collection name setTranslation({ ...translation, name: event.target.value })} />Saved automatically in this browser. Nothing is written to the database.
{translation.teams.map((item) => {item.name} )} = 12} onClick={() => { const next = makeTeam(catalog, translation.teams.length + 1); setTranslation({ ...translation, teams: [...translation.teams, next] }); setActiveTeam(next.id); }} aria-label="Add team"> {translation.teams.map((item) => copy()} copyDisabled={Boolean(rotationError)} />
)} ;
+ toast.success(`${source} team imported`);
+ };
+ const importDisabled = !draftLoaded || translation.teams.length >= 12;
+ return (
+
+
+
+ addImportedTeam(team, "Guide")}
+ />
+ addImportedTeam(team, "Gcsim")}
+ />
+
+
+
+ Collection name
+
+ setTranslation({ ...translation, name: event.target.value })
+ }
+ />
+
+ Saved automatically in this browser.
+
+
+
+
+
+
+ {translation.teams.map((item) => (
+
+ {item.name}
+
+ ))}
+
+
= 12}
+ onClick={() => {
+ const next = makeTeam(catalog, translation.teams.length + 1);
+ setTranslation({
+ ...translation,
+ teams: [...translation.teams, next],
+ });
+ setActiveTeam(next.id);
+ }}
+ aria-label="Add team"
+ >
+
+
+
+ {translation.teams.map((item) => (
+
+
+
+
+
+ Team name
+
+ updateTeam({ ...item, name: event.target.value })
+ }
+ />
+
+ {
+ const teams = translation.teams.filter(
+ (candidate) => candidate.id !== item.id,
+ );
+ setTranslation({ ...translation, teams });
+ setActiveTeam(teams[0].id);
+ }}
+ aria-label="Remove team"
+ >
+
+
+
+ {item.builds.map((build) => (
+
1}
+ onRemove={() =>
+ updateTeam({
+ ...item,
+ builds: item.builds.filter(
+ (candidate) => candidate.id !== build.id,
+ ),
+ })
+ }
+ onChange={(next) =>
+ updateTeam({
+ ...item,
+ builds: item.builds.map((candidate) =>
+ candidate.id === next.id ? next : candidate,
+ ),
+ })
+ }
+ />
+ ))}
+ = 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)],
+ });
+ }}
+ >
+
+ Add character ({item.builds.length}/4)
+
+
+
+
+ copy()}
+ copyDisabled={Boolean(rotationError)}
+ />
+
+
+
+ ))}
+
+
+ );
}
-function Header() { return ; }
-function OutputCard({ output, onCopy, copyDisabled }: { output: string; onCopy: () => void; copyDisabled: boolean }) { return Generated config Character builds and optional rotation for the active team. Copy config ; }
+function Header() {
+ return (
+
+
+ GCSIM
+ Build translator
+
+
+ Build teams, not config syntax.
+
+
+ Choose loadouts and average artifact rolls. Get clean character blocks
+ ready to paste.
+
+
+ );
+}
+function OutputCard({
+ output,
+ onCopy,
+ copyDisabled,
+}: {
+ output: string;
+ onCopy: () => void;
+ copyDisabled: boolean;
+}) {
+ return (
+
+
+ Generated config
+
+ Character builds and optional rotation for the active team.
+
+
+
+
+
+
+
+
+ Copy config
+
+
+
+ );
+}
-function RotationEditor({ team, onChange }: { team: Team; onChange: (team: Team) => void }) {
+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) => onChange({ ...team, rotation: { ...rotation, ...patch } });
+ const update = (patch: Partial) =>
+ onChange({ ...team, rotation: { ...rotation, ...patch } });
const inputId = `${team.id}-rotation`;
- return
- Rotation translator {rotation.format === "gcsim" ? "Imported script with its original loops and action parameters." : "Write your combo using character names and shorthand."}
-
- update({ enabled })} aria-controls={`${inputId}-controls`}>Include rotation {rotation.enabled ? "Includes rotation, the fixed delay helper, target, and energy settings." : "Off: only character builds are included."}
- {rotation.enabled &&
- {rotation.format === "gcsim" ? "Imported gcsim rotation" : "Rotation"}
- {rotation.format === "gcsim" ? update({ format: "shorthand", text: "", repeat: false })}>Clear script and use shorthand : <>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).
- update({ repeat })}>Repeat rotation {rotation.repeat ? "Loop until the simulation ends." : "Run the rotation once."} >}
- }
-
- ;
+ return (
+
+
+ Rotation translator
+
+ {rotation.format === "gcsim"
+ ? "Imported script with its original loops and action parameters."
+ : "Write your combo using character names and shorthand."}
+
+
+
+
+
+ update({ enabled })}
+ aria-controls={`${inputId}-controls`}
+ >
+ Include rotation
+
+
+ {rotation.enabled
+ ? "Includes rotation, the fixed delay helper, target, and energy settings."
+ : "Off: only character builds are included."}
+
+
+ {rotation.enabled && (
+
+
+
+ {rotation.format === "gcsim"
+ ? "Imported gcsim rotation"
+ : "Rotation"}
+
+
+ {rotation.format === "gcsim" ? (
+
+
+ update({ format: "shorthand", text: "", repeat: false })
+ }
+ >
+ Clear script and use shorthand
+
+
+ ) : (
+ <>
+
+
+ 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).
+
+
+
+ update({ repeat })}
+ >
+ Repeat rotation
+
+
+ {rotation.repeat
+ ? "Loop until the simulation ends."
+ : "Run the rotation once."}
+
+
+ >
+ )}
+
+ )}
+
+
+
+ );
}
-function ImportedStatsEditor({ build, onChange }: { build: Build; onChange: (build: Build) => void }) {
- return Imported artifact stats Exact totals from add stats lines. Percent bonuses use decimals: 0.466 = 46.6%. These replace main stats and roll estimates.
- {importedStatKeys.map((key) => {key.toUpperCase()} { const value = Number(event.target.value); if (Number.isFinite(value)) onChange({ ...build, importedStats: { ...build.importedStats, [key]: value } }); }} /> )}
- onChange({ ...build, importedStats: undefined })}>Replace imported stats with main stats and rolls
- ;
+function ImportedStatsEditor({
+ build,
+ onChange,
+}: {
+ build: Build;
+ onChange: (build: Build) => void;
+}) {
+ return (
+
+ Imported artifact stats
+
+ Exact totals from add stats lines. Percent bonuses use decimals: 0.466 =
+ 46.6%. These replace main stats and roll estimates.
+
+
+ {importedStatKeys.map((key) => (
+
+
+ {key.toUpperCase()}
+
+ {
+ const value = Number(event.target.value);
+ if (Number.isFinite(value))
+ onChange({
+ ...build,
+ importedStats: { ...build.importedStats, [key]: value },
+ });
+ }}
+ />
+
+ ))}
+
+ onChange({ ...build, importedStats: undefined })}
+ >
+ Replace imported stats with main stats and rolls
+
+
+ );
}
-function ImportedSetsEditor({ build, catalog, onChange }: { build: Build; catalog: Catalog; onChange: (build: Build) => void }) {
+function ImportedSetsEditor({
+ build,
+ catalog,
+ onChange,
+}: {
+ build: Build;
+ catalog: Catalog;
+ onChange: (build: Build) => void;
+}) {
const sets = build.importedSets ?? [];
- return Imported artifact sets {sets.length ? sets.map((set, index) => Artifact set 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" /> Piece count 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) }); }} /> {set.params && Parameters: {set.params} } ) : No artifact set bonuses were specified. } onChange({ ...build, importedSets: undefined })}>Replace imported sets with the set editor ;
+ return (
+
+ Imported artifact sets
+
+ {sets.length ? (
+ sets.map((set, index) => (
+
+
+ Artifact set
+
+ 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"
+ />
+
+
+
+ Piece count
+
+
+ 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,
+ ),
+ });
+ }}
+ />
+
+ {set.params && (
+ Parameters: {set.params}
+ )}
+
+ ))
+ ) : (
+
+ No artifact set bonuses were specified.
+
+ )}
+
+ onChange({ ...build, importedSets: undefined })}
+ >
+ Replace imported sets with the set editor
+
+
+ );
}
diff --git a/db/schema.ts b/db/schema.ts
index ebac181..dacab4e 100644
--- a/db/schema.ts
+++ b/db/schema.ts
@@ -1,7 +1,8 @@
-import { integer, jsonb, pgSchema, text, varchar } from "drizzle-orm/pg-core";
+import { boolean, integer, jsonb, numeric, pgSchema, text, timestamp, uuid, varchar } from "drizzle-orm/pg-core";
import type { CatalogBaseStat } from "@/lib/translator/types";
export const catalogSchema = pgSchema("catalog");
+export const guidesSchema = pgSchema("guides");
export const catalogCharacters = catalogSchema.table("character", {
key: varchar("key", { length: 64 }).primaryKey(), name: text("name").notNull(), element: varchar("element", { length: 48 }), rarity: integer("rarity"), weaponType: varchar("weapon_type", { length: 64 }), imageKey: text("image_key").notNull(), baseStats: jsonb("base_stats").$type().default([]).notNull(),
@@ -12,3 +13,40 @@ export const catalogWeapons = catalogSchema.table("weapon", {
export const catalogArtifacts = catalogSchema.table("artifact", {
key: varchar("key", { length: 64 }).primaryKey(), name: text("name").notNull(), rarity: integer("rarity"), imageKey: text("image_key").notNull(),
});
+
+export const guides = guidesSchema.table("guide", {
+ id: uuid("id").primaryKey(),
+ characterKey: varchar("character_key", { length: 64 }).notNull(),
+ name: text("name").notNull(),
+ sortOrder: integer("sort_order").notNull(),
+ isPublic: boolean("is_public").notNull(),
+ trashedAt: timestamp("trashed_at", { withTimezone: true }),
+});
+
+export const teamConditions = guidesSchema.table("team_condition", {
+ id: uuid("id").primaryKey(),
+ guideId: uuid("guide_id").notNull(),
+ name: varchar("name", { length: 255 }),
+ sortOrder: integer("sort_order").notNull(),
+});
+
+export const teamCompositions = guidesSchema.table("team_composition", {
+ id: uuid("id").primaryKey(),
+ guideId: uuid("guide_id").notNull(),
+ title: text("title").notNull(),
+ combo: text("combo").notNull(),
+ sortOrder: integer("sort_order").notNull(),
+ conditionId: uuid("condition_id"),
+ rotationSeconds: numeric("rotation_seconds").notNull(),
+});
+
+export const teamMembers = guidesSchema.table("team_member", {
+ teamId: uuid("team_id").notNull(),
+ position: integer("position").notNull(),
+ characterKey: varchar("character_key", { length: 64 }).notNull(),
+ weaponKey: varchar("weapon_key", { length: 64 }).notNull(),
+ artifactKey: varchar("artifact_key", { length: 64 }).notNull(),
+ secondArtifactKey: varchar("second_artifact_key", { length: 64 }),
+ constellation: integer("constellation").notNull(),
+ refinement: integer("refinement").notNull(),
+});
diff --git a/lib/team-guide-data.ts b/lib/team-guide-data.ts
new file mode 100644
index 0000000..50c49d1
--- /dev/null
+++ b/lib/team-guide-data.ts
@@ -0,0 +1,61 @@
+import "server-only";
+import { and, asc, eq, isNull } from "drizzle-orm";
+import { connection } from "next/server";
+import { getDb } from "@/db/client";
+import { guides, teamCompositions, teamConditions, teamMembers } from "@/db/schema";
+import type { TeamGuide } from "@/lib/team-guides";
+
+export async function getTeamGuides(): Promise {
+ await connection();
+ if (!process.env.DATABASE_URL) return [];
+ try {
+ const rows = await getDb().select({
+ guideId: guides.id,
+ guideCharacterId: guides.characterKey,
+ guideName: guides.name,
+ teamId: teamCompositions.id,
+ teamTitle: teamCompositions.title,
+ combo: teamCompositions.combo,
+ conditionName: teamConditions.name,
+ position: teamMembers.position,
+ characterId: teamMembers.characterKey,
+ weaponId: teamMembers.weaponKey,
+ artifactOneId: teamMembers.artifactKey,
+ artifactTwoId: teamMembers.secondArtifactKey,
+ constellation: teamMembers.constellation,
+ refinement: teamMembers.refinement,
+ }).from(guides)
+ .innerJoin(teamCompositions, eq(teamCompositions.guideId, guides.id))
+ .innerJoin(teamMembers, eq(teamMembers.teamId, teamCompositions.id))
+ .leftJoin(teamConditions, eq(teamConditions.id, teamCompositions.conditionId))
+ .where(and(eq(guides.isPublic, true), isNull(guides.trashedAt)))
+ .orderBy(asc(guides.sortOrder), asc(guides.name), asc(teamConditions.sortOrder), asc(teamCompositions.sortOrder), asc(teamMembers.position));
+
+ const result: TeamGuide[] = [];
+ for (const row of rows) {
+ let guide = result.at(-1);
+ if (!guide || guide.id !== row.guideId) {
+ guide = { id: row.guideId, characterId: row.guideCharacterId, name: row.guideName, teams: [] };
+ result.push(guide);
+ }
+ let team = guide.teams.at(-1);
+ if (!team || team.id !== row.teamId) {
+ team = { id: row.teamId, title: row.teamTitle, conditionName: row.conditionName, combo: row.combo, members: [] };
+ guide.teams.push(team);
+ }
+ team.members.push({
+ position: row.position,
+ characterId: row.characterId,
+ weaponId: row.weaponId,
+ artifactOneId: row.artifactOneId,
+ artifactTwoId: row.artifactTwoId,
+ constellation: row.constellation,
+ refinement: row.refinement,
+ });
+ }
+ return result;
+ } catch (error) {
+ console.error("Team guides unavailable", error);
+ return [];
+ }
+}
diff --git a/lib/team-guides.test.ts b/lib/team-guides.test.ts
new file mode 100644
index 0000000..6184d1a
--- /dev/null
+++ b/lib/team-guides.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from "vitest";
+import { generateTeam } from "./translator/generate";
+import type { Catalog, CatalogItem } from "./translator/types";
+import { importGuideTeam, type TeamGuideTeam } from "./team-guides";
+
+const item = (id: string, key: string, name: string, weaponType?: string): CatalogItem => ({ id, key, name, imageUrl: "", rarity: 5, weaponType, supported: true, baseStats: [] });
+const catalog: Catalog = {
+ version: "test",
+ characters: [item("char-1", "mavuika", "Mavuika", "claymore"), item("char-2", "xilonen", "Xilonen", "sword")],
+ weapons: [item("weapon-1", "favoniusgreatsword", "Favonius Greatsword", "claymore"), item("weapon-2", "favoniussword", "Favonius Sword", "sword")],
+ artifacts: [item("set-1", "obsidiancodex", "Obsidian Codex"), item("set-2", "scrolloftheheroofcindercity", "Scroll")],
+};
+
+const guideTeam = (combo = "Mavuika E > Xilonen E N2"): TeamGuideTeam => ({
+ id: "team-1",
+ title: "Guide team",
+ conditionName: "Main Sheet",
+ combo,
+ members: [
+ { position: 0, characterId: "char-1", weaponId: "weapon-1", artifactOneId: "set-1", artifactTwoId: null, constellation: 2, refinement: 1 },
+ { position: 1, characterId: "char-2", weaponId: "weapon-2", artifactOneId: "set-1", artifactTwoId: "set-2", constellation: 0, refinement: 5 },
+ ],
+});
+
+describe("team guide import", () => {
+ it("maps guide loadouts and defaults into a generated team", () => {
+ const result = importGuideTeam(guideTeam(), catalog);
+ expect(result.error).toBeUndefined();
+ expect(result.team!.builds[0]).toMatchObject({ characterKey: "mavuika", weaponKey: "favoniusgreatsword", constellation: 2, refinement: 1, artifactMode: "4pc", artifactOneKey: "obsidiancodex", level: 90, talents: [9, 9, 9], sands: "atk%", goblet: "atk%", circlet: "cr" });
+ expect(result.team!.builds[1]).toMatchObject({ artifactMode: "2+2", artifactOneKey: "obsidiancodex", artifactTwoKey: "scrolloftheheroofcindercity", refinement: 5 });
+ expect(result.team!.rotation).toMatchObject({ enabled: true, text: "Mavuika E > Xilonen E N2", repeat: false });
+ expect(generateTeam(result.team!)).toContain('xilonen add set="scrolloftheheroofcindercity" count=2;');
+ });
+
+ it("preserves unsupported combo text with rotation disabled", () => {
+ const result = importGuideTeam(guideTeam("Mavuika 3[C ED] Q"), catalog);
+ expect(result.team!.rotation).toEqual({ enabled: false, text: "Mavuika 3[C ED] Q", repeat: false });
+ expect(generateTeam(result.team!)).not.toContain("# Rotation");
+ });
+
+ it.each(["", " - "])("omits an empty guide combo %#", (combo) => {
+ expect(importGuideTeam(guideTeam(combo), catalog).team!.rotation).toBeUndefined();
+ });
+
+ it("reports missing catalog entries before importing", () => {
+ const source = guideTeam();
+ source.members[0].weaponId = "missing";
+ expect(importGuideTeam(source, catalog).error).toBe("Weapon missing is missing from the catalog.");
+ });
+});
diff --git a/lib/team-guides.ts b/lib/team-guides.ts
new file mode 100644
index 0000000..06419c8
--- /dev/null
+++ b/lib/team-guides.ts
@@ -0,0 +1,71 @@
+import { createBuild } from "./translator/build";
+import { translateRotation } from "./translator/rotation";
+import type { Catalog, Team } from "./translator/types";
+
+export interface TeamGuideMember {
+ position: number;
+ characterId: string;
+ weaponId: string;
+ artifactOneId: string;
+ artifactTwoId: string | null;
+ constellation: number;
+ refinement: number;
+}
+
+export interface TeamGuideTeam {
+ id: string;
+ title: string;
+ conditionName: string | null;
+ combo: string;
+ members: TeamGuideMember[];
+}
+
+export interface TeamGuide {
+ id: string;
+ characterId: string;
+ name: string;
+ teams: TeamGuideTeam[];
+}
+
+export type GuideImportResult = { team: Team; error?: never } | { team?: never; error: string };
+
+export function importGuideTeam(source: TeamGuideTeam, catalog: Catalog): GuideImportResult {
+ const builds = [] as Team["builds"];
+ for (const member of source.members) {
+ const character = catalog.characters.find((item) => item.id === member.characterId);
+ const weapon = catalog.weapons.find((item) => item.id === member.weaponId);
+ const artifactOne = catalog.artifacts.find((item) => item.id === member.artifactOneId);
+ const artifactTwo = member.artifactTwoId ? catalog.artifacts.find((item) => item.id === member.artifactTwoId) : undefined;
+ if (!character) return { error: `Character ${member.characterId} is missing from the catalog.` };
+ if (!weapon) return { error: `Weapon ${member.weaponId} is missing from the catalog.` };
+ if (!artifactOne) return { error: `Artifact ${member.artifactOneId} is missing from the catalog.` };
+ if (member.artifactTwoId && !artifactTwo) return { error: `Artifact ${member.artifactTwoId} is missing from the catalog.` };
+ if (character.weaponType && weapon.weaponType !== character.weaponType) return { error: `${weapon.name} is not compatible with ${character.name}.` };
+
+ const build = createBuild(catalog, character, `guide-build-${source.id}-${member.position}`);
+ builds.push({
+ ...build,
+ weaponId: weapon.id,
+ weaponKey: weapon.key,
+ weaponName: weapon.name,
+ refinement: member.refinement,
+ constellation: member.constellation,
+ artifactMode: artifactTwo ? "2+2" : "4pc",
+ artifactOneId: artifactOne.id,
+ artifactOneKey: artifactOne.key,
+ artifactOneName: artifactOne.name,
+ artifactTwoId: artifactTwo?.id ?? "",
+ artifactTwoKey: artifactTwo?.key ?? "",
+ artifactTwoName: artifactTwo?.name ?? "",
+ });
+ }
+ if (!builds.length) return { error: "This guide team has no characters." };
+
+ const team: Team = { id: `guide-team-${source.id}`, name: source.title, builds };
+ const combo = source.combo.trim();
+ if (combo && combo !== "-") {
+ team.rotation = { enabled: true, text: combo, repeat: false };
+ if (translateRotation(team).error) team.rotation.enabled = false;
+ }
+ return { team };
+}