feat : add import from gcsim and roation translater
This commit is contained in:
@@ -17,6 +17,26 @@ bun run typecheck
|
||||
bun run lint
|
||||
```
|
||||
|
||||
## Optional rotations
|
||||
|
||||
Enable **Include rotation** for a team to append actions to its character blocks:
|
||||
|
||||
```text
|
||||
Mavuika E > Xilonen E N2
|
||||
```
|
||||
|
||||
Use character names or keys from that team, separated by `>` or newlines. Supported shorthand: `E` (skill), `Q` (burst), `N2` (two normals), `C` (charged attack), `D` (dash), `J` (jump), `tE` / `hE` (tap / hold skill), and `W(60)` (wait 60 frames). Combine actions with `N2CD` or repeat groups with `3(N2CD)`; nested groups are supported. Compact repeats such as `2N2D` mean `2(N2D)`: perform two normals and a dash twice. A following spaced action runs once, so `2N2D Q` repeats `N2D` twice, then uses burst. **Repeat rotation** loops the actions until the simulation ends.
|
||||
|
||||
Both toggles default to off. Rotation text and settings save with each team in this browser; turning rotation off preserves the text and outputs only builds. Invalid enabled rotations show an error and disable copying. The translator checks shorthand, not combat legality or energy requirements. Output contains build blocks and optional actions; add simulation settings and targets in gcsim.
|
||||
|
||||
## Import from gcsim
|
||||
|
||||
Click **Import from gcsim**, paste a config, review the preview, and click **Import team**. Import creates a new team with character levels, constellations, talents, weapons, artifact sets, and exact artifact stat totals. Multiple `add stats` lines are summed without converting their values into roll estimates. Imported stats and artifact piece counts can be edited directly, and numeric character, weapon, and set `+params` are preserved.
|
||||
|
||||
Imported rotations remain editable gcsim script, retaining loops, conditionals, waits, and action parameters. **Include rotation** controls whether that script is copied. Use **Clear script and use shorthand** to start a new shorthand rotation instead.
|
||||
|
||||
Imports support up to four characters from the current catalog, common character aliases, and Favonius aliases such as `favsword`, `favgs`, `favbow`, `favspear`, and `favcodex`. Unknown catalog entries, malformed build settings, and invalid values show errors before any team is added. Simulation options, targets, and energy settings are omitted with a notice in the import preview; full script validation happens in gcsim.
|
||||
|
||||
## Production
|
||||
|
||||
The `gcsim-secrets` Kubernetes secret only needs `DATABASE_URL`. Use a dedicated PostgreSQL role with read-only access to the Buzz Sheet `catalog` schema.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { ImportIcon } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { importGcsimConfig } from "@/lib/translator/import";
|
||||
import type { Catalog, Team } from "@/lib/translator/types";
|
||||
|
||||
export function ImportGcsimDialog({ catalog, disabled, onImport }: { catalog: Catalog; disabled: boolean; onImport: (team: Team) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [input, setInput] = useState("");
|
||||
const [name, setName] = useState("Imported team");
|
||||
const preview = useMemo(() => input.trim() ? importGcsimConfig(input, catalog, name) : undefined, [input, catalog, name]);
|
||||
const error = preview?.error;
|
||||
return <Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button variant="outline" className="self-start" disabled={disabled} />}><ImportIcon data-icon="inline-start" />Import from gcsim</DialogTrigger>
|
||||
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader><DialogTitle>Import from gcsim</DialogTitle><DialogDescription>Paste character settings, artifact stats, and rotation script. Import creates a new team.</DialogDescription></DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field><FieldLabel htmlFor="gcsim-import-name">Team name</FieldLabel><Input id="gcsim-import-name" value={name} maxLength={60} onChange={(event) => setName(event.target.value)} /></Field>
|
||||
<Field data-invalid={Boolean(error)}><FieldLabel htmlFor="gcsim-import-input">Gcsim config</FieldLabel><Textarea id="gcsim-import-input" className="min-h-64" value={input} maxLength={100000} onChange={(event) => setInput(event.target.value)} placeholder={'bennett char lvl=90/90 cons=0 talent=9,9,9;\nbennett add weapon="favoniussword" refine=1 lvl=90/90;\nbennett add set="noblesseoblige" count=4;\nbennett add stats hp=4780 atk=311 er=0.518;\n\nactive bennett;\nbennett skill, burst;'} aria-invalid={Boolean(error)} aria-describedby={`gcsim-import-help${error ? " gcsim-import-error" : ""}`} /><FieldDescription id="gcsim-import-help">Exact artifact values are preserved. Loops and action parameters stay as gcsim script. Character and equipment keys must exist in the catalog.</FieldDescription>{error && <FieldError id="gcsim-import-error">{error}</FieldError>}</Field>
|
||||
{preview?.team && <Alert><AlertTitle>Ready to import</AlertTitle><AlertDescription>{preview.team.builds.map((build) => build.characterName).join(", ")} · {preview.team.builds.length} character{preview.team.builds.length === 1 ? "" : "s"} · {preview.team.rotation?.enabled ? "Rotation included" : "No rotation"}</AlertDescription></Alert>}
|
||||
{preview?.warnings?.map((warning) => <Alert key={warning}><AlertTitle>Import note</AlertTitle><AlertDescription>{warning}</AlertDescription></Alert>)}
|
||||
</FieldGroup>
|
||||
<DialogFooter><DialogClose render={<Button variant="outline" />}>Cancel</DialogClose><Button disabled={disabled || !preview?.team} onClick={() => {
|
||||
if (!preview?.team || disabled) return;
|
||||
onImport(preview.team);
|
||||
setOpen(false); setInput(""); setName("Imported team");
|
||||
}}>Import team</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
+70
-17
@@ -3,22 +3,25 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { CopyIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { createBuild } from "@/lib/translator/build";
|
||||
import { ImportGcsimDialog } from "@/components/import-gcsim-dialog";
|
||||
import { CatalogCombobox } from "@/components/catalog-combobox";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field";
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Toggle } from "@/components/ui/toggle";
|
||||
import { translateRotation } from "@/lib/translator/rotation";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { calculateSubstats, generateTeam, SUBSTAT_SCALARS } from "@/lib/translator/generate";
|
||||
import { formatBaseStat } from "@/lib/translator/stat-format";
|
||||
import { calculateFinalStats, formatFinalStat } from "@/lib/translator/final-stats";
|
||||
import { rollKeys, translationSchema, type Build, type Catalog, type CatalogItem, type MainStatKey, type RollKey, type Team, type Translation } from "@/lib/translator/types";
|
||||
import { importedStatKeys, rollKeys, translationSchema, type Build, type Catalog, type CatalogItem, type MainStatKey, type Team, type Translation } from "@/lib/translator/types";
|
||||
|
||||
const emptyRolls = Object.fromEntries(rollKeys.map((key) => [key, 0])) as Record<RollKey, number>;
|
||||
const levels = [20, 40, 50, 60, 70, 80, 90];
|
||||
const sands: MainStatKey[] = ["atk%", "hp%", "def%", "em", "er"];
|
||||
const goblets: MainStatKey[] = ["atk%", "hp%", "def%", "em", "pyro%", "hydro%", "cryo%", "electro%", "anemo%", "geo%", "dendro%", "phys%"];
|
||||
@@ -30,9 +33,7 @@ function newId(prefix: string): string {
|
||||
}
|
||||
|
||||
function makeBuild(catalog: Catalog, character = catalog.characters[0], id = newId("build")): Build {
|
||||
const weapon = catalog.weapons.find((item) => !character.weaponType || item.weaponType === character.weaponType) ?? catalog.weapons[0];
|
||||
const artifact = catalog.artifacts[0];
|
||||
return { id, characterId: character.id, characterKey: character.key, characterName: character.name, characterImageUrl: character.imageUrl, level: 90, maxLevel: 90, constellation: 0, talents: [9, 9, 9], weaponId: weapon.id, weaponKey: weapon.key, weaponName: weapon.name, weaponLevel: 90, weaponMaxLevel: 90, refinement: 1, artifactMode: "4pc", artifactOneId: artifact.id, artifactOneKey: artifact.key, artifactOneName: artifact.name, artifactTwoId: "", artifactTwoKey: "", artifactTwoName: "", sands: "atk%", goblet: "atk%", circlet: "cr", rolls: { ...emptyRolls } };
|
||||
return createBuild(catalog, character, id);
|
||||
}
|
||||
function makeTeam(catalog: Catalog, index: number, deterministic = false): Team {
|
||||
const id = deterministic ? `team-${index}` : newId("team");
|
||||
@@ -75,43 +76,95 @@ function BuildEditor({ build, catalog, onChange, onRemove, canRemove }: { build:
|
||||
const update = <K extends keyof Build>(key: K, value: Build[K]) => onChange({ ...build, [key]: value });
|
||||
const chooseCharacter = (item: CatalogItem) => {
|
||||
const weapon = catalog.weapons.find((candidate) => !item.weaponType || candidate.weaponType === item.weaponType) ?? catalog.weapons[0];
|
||||
onChange({ ...build, characterId: item.id, characterKey: item.key, characterName: item.name, characterImageUrl: item.imageUrl, weaponId: weapon.id, weaponKey: weapon.key, weaponName: weapon.name });
|
||||
onChange({ ...build, characterId: item.id, characterKey: item.key, characterName: item.name, characterImageUrl: item.imageUrl, weaponId: weapon.id, weaponKey: weapon.key, weaponName: weapon.name, importedParams: undefined });
|
||||
};
|
||||
const calculated = calculateSubstats(build);
|
||||
const finalStats = selectedCharacter?.baseStats.length && selectedWeapon?.baseStats.length
|
||||
? calculateFinalStats(build, selectedCharacter.baseStats, selectedWeapon.baseStats)
|
||||
: [];
|
||||
const totalRolls = Object.values(build.rolls).reduce((sum, value) => sum + value, 0);
|
||||
return <Card><CardHeader><div><CardTitle>{build.characterName}</CardTitle><CardDescription>{build.characterKey} · {totalRolls} total rolls</CardDescription></div><CardAction className="flex items-start gap-2"><div className="flex flex-col items-end gap-1" aria-label="Final level 90 stats">{finalStats.length ? finalStats.map((stat) => <Badge key={stat.key} variant="secondary">{formatFinalStat(stat)}</Badge>) : <span className="text-sm text-muted-foreground">Final stats unavailable</span>}</div><Button variant="ghost" size="icon" disabled={!canRemove} onClick={onRemove} aria-label="Remove character"><Trash2Icon /></Button></CardAction></CardHeader><CardContent><FieldGroup>
|
||||
return <Card><CardHeader><div><CardTitle>{build.characterName}</CardTitle><CardDescription>{build.characterKey} · {build.importedStats ? "Imported artifact stats" : `${totalRolls} total rolls`}</CardDescription></div><CardAction className="flex items-start gap-2"><div className="flex flex-col items-end gap-1" aria-label="Final level 90 stats">{finalStats.length ? finalStats.map((stat) => <Badge key={stat.key} variant="secondary">{formatFinalStat(stat)}</Badge>) : <span className="text-sm text-muted-foreground">Final stats unavailable</span>}</div><Button variant="ghost" size="icon" disabled={!canRemove} onClick={onRemove} aria-label="Remove character"><Trash2Icon /></Button></CardAction></CardHeader><CardContent><FieldGroup>
|
||||
<Field><FieldLabel>Character</FieldLabel><CatalogCombobox items={catalog.characters} value={build.characterId} onChange={chooseCharacter} placeholder="Choose character" /><BaseStats stats={selectedCharacter?.baseStats ?? []} /></Field>
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4"><Field><FieldLabel>Level</FieldLabel><NumberSelect value={build.level} values={levels.filter((v) => v <= build.maxLevel)} onChange={(v) => update("level", v)} /></Field><Field><FieldLabel>Ascension cap</FieldLabel><NumberSelect value={build.maxLevel} values={levels} onChange={(v) => onChange({ ...build, maxLevel: v, level: Math.min(build.level, v) })} /></Field><Field><FieldLabel>Constellation</FieldLabel><NumberSelect value={build.constellation} values={[0,1,2,3,4,5,6]} onChange={(v) => update("constellation", v)} /></Field><Field><FieldLabel>Talents NA / Skill / Burst</FieldLabel><div className="grid grid-cols-3 gap-2">{build.talents.map((value, index) => <Input key={index} type="number" min={1} max={15} value={value} onChange={(event) => { const next = [...build.talents] as Build["talents"]; next[index] = Number(event.target.value); update("talents", next); }} aria-label={["Normal attack", "Skill", "Burst"][index]} />)}</div></Field></div>
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4"><Field><FieldLabel>Level</FieldLabel><NumberSelect value={build.level} values={Array.from(new Set([...levels.filter((v) => v <= build.maxLevel), build.level])).sort((a, b) => a - b)} onChange={(v) => update("level", v)} /></Field><Field><FieldLabel>Ascension cap</FieldLabel><NumberSelect value={build.maxLevel} values={levels} onChange={(v) => onChange({ ...build, maxLevel: v, level: Math.min(build.level, v) })} /></Field><Field><FieldLabel>Constellation</FieldLabel><NumberSelect value={build.constellation} values={[0,1,2,3,4,5,6]} onChange={(v) => update("constellation", v)} /></Field><Field><FieldLabel>Talents NA / Skill / Burst</FieldLabel><div className="grid grid-cols-3 gap-2">{build.talents.map((value, index) => <Input key={index} type="number" min={1} max={15} value={value} onChange={(event) => { const next = [...build.talents] as Build["talents"]; next[index] = Number(event.target.value); update("talents", next); }} aria-label={["Normal attack", "Skill", "Burst"][index]} />)}</div></Field></div>
|
||||
<Separator />
|
||||
<div className="grid gap-6 lg:grid-cols-[2fr_1fr_1fr_1fr]"><Field><FieldLabel>Weapon</FieldLabel><CatalogCombobox items={weapons} value={build.weaponId} onChange={(item) => onChange({ ...build, weaponId: item.id, weaponKey: item.key, weaponName: item.name })} placeholder="Choose weapon" /><BaseStats stats={selectedWeapon?.baseStats ?? []} /></Field><Field><FieldLabel>Level</FieldLabel><NumberSelect value={build.weaponLevel} values={levels.filter((v) => v <= build.weaponMaxLevel)} onChange={(v) => update("weaponLevel", v)} /></Field><Field><FieldLabel>Ascension cap</FieldLabel><NumberSelect value={build.weaponMaxLevel} values={levels} onChange={(v) => onChange({ ...build, weaponMaxLevel: v, weaponLevel: Math.min(build.weaponLevel, v) })} /></Field><Field><FieldLabel>Refinement</FieldLabel><NumberSelect value={build.refinement} values={[1,2,3,4,5]} onChange={(v) => update("refinement", v)} /></Field></div>
|
||||
<div className="grid gap-6 lg:grid-cols-[2fr_1fr_1fr_1fr]"><Field><FieldLabel>Weapon</FieldLabel><CatalogCombobox items={weapons} value={build.weaponId} onChange={(item) => onChange({ ...build, weaponId: item.id, weaponKey: item.key, weaponName: item.name, importedParams: build.importedParams ? { ...build.importedParams, weapon: undefined } : undefined })} placeholder="Choose weapon" /><BaseStats stats={selectedWeapon?.baseStats ?? []} /></Field><Field><FieldLabel>Level</FieldLabel><NumberSelect value={build.weaponLevel} values={Array.from(new Set([...levels.filter((v) => v <= build.weaponMaxLevel), build.weaponLevel])).sort((a, b) => a - b)} onChange={(v) => update("weaponLevel", v)} /></Field><Field><FieldLabel>Ascension cap</FieldLabel><NumberSelect value={build.weaponMaxLevel} values={levels} onChange={(v) => onChange({ ...build, weaponMaxLevel: v, weaponLevel: Math.min(build.weaponLevel, v) })} /></Field><Field><FieldLabel>Refinement</FieldLabel><NumberSelect value={build.refinement} values={[1,2,3,4,5]} onChange={(v) => update("refinement", v)} /></Field></div>
|
||||
<Separator />
|
||||
<FieldSet><FieldLegend>Artifact sets and main stats</FieldLegend><div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"><Field><FieldLabel>Set layout</FieldLabel><Select value={build.artifactMode} onValueChange={(v) => update("artifactMode", v as Build["artifactMode"])}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="4pc">4-piece set</SelectItem><SelectItem value="2+2">2-piece + 2-piece</SelectItem></SelectGroup></SelectContent></Select></Field><Field><FieldLabel>First set</FieldLabel><CatalogCombobox items={catalog.artifacts} value={build.artifactOneId} onChange={(item) => onChange({ ...build, artifactOneId: item.id, artifactOneKey: item.key, artifactOneName: item.name })} placeholder="Choose artifact" /></Field>{build.artifactMode === "2+2" ? <Field><FieldLabel>Second set</FieldLabel><CatalogCombobox items={catalog.artifacts.filter((item) => item.id !== build.artifactOneId)} value={build.artifactTwoId} onChange={(item) => onChange({ ...build, artifactTwoId: item.id, artifactTwoKey: item.key, artifactTwoName: item.name })} placeholder="Choose artifact" /></Field> : null}<Field><FieldLabel>Sands</FieldLabel><StatSelect value={build.sands} values={sands} onChange={(v) => update("sands", v as Build["sands"])} /></Field><Field><FieldLabel>Goblet</FieldLabel><StatSelect value={build.goblet} values={goblets} onChange={(v) => update("goblet", v as Build["goblet"])} /></Field><Field><FieldLabel>Circlet</FieldLabel><StatSelect value={build.circlet} values={circlets} onChange={(v) => update("circlet", v as Build["circlet"])} /></Field></div></FieldSet>
|
||||
{build.importedSets ? <ImportedSetsEditor build={build} catalog={catalog} onChange={onChange} /> : <FieldSet><FieldLegend>Artifact sets and main stats</FieldLegend><div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"><Field><FieldLabel>Set layout</FieldLabel><Select value={build.artifactMode} onValueChange={(v) => update("artifactMode", v as Build["artifactMode"])}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="4pc">4-piece set</SelectItem><SelectItem value="2+2">2-piece + 2-piece</SelectItem></SelectGroup></SelectContent></Select></Field><Field><FieldLabel>First set</FieldLabel><CatalogCombobox items={catalog.artifacts} value={build.artifactOneId} onChange={(item) => onChange({ ...build, artifactOneId: item.id, artifactOneKey: item.key, artifactOneName: item.name })} placeholder="Choose artifact" /></Field>{build.artifactMode === "2+2" ? <Field><FieldLabel>Second set</FieldLabel><CatalogCombobox items={catalog.artifacts.filter((item) => item.id !== build.artifactOneId)} value={build.artifactTwoId} onChange={(item) => onChange({ ...build, artifactTwoId: item.id, artifactTwoKey: item.key, artifactTwoName: item.name })} placeholder="Choose artifact" /></Field> : null}{!build.importedStats && <><Field><FieldLabel>Sands</FieldLabel><StatSelect value={build.sands} values={sands} onChange={(v) => update("sands", v as Build["sands"])} /></Field><Field><FieldLabel>Goblet</FieldLabel><StatSelect value={build.goblet} values={goblets} onChange={(v) => update("goblet", v as Build["goblet"])} /></Field><Field><FieldLabel>Circlet</FieldLabel><StatSelect value={build.circlet} values={circlets} onChange={(v) => update("circlet", v as Build["circlet"])} /></Field></>}</div></FieldSet>}
|
||||
{build.importedSets && !build.importedStats && <FieldSet><FieldLegend>Artifact main stats</FieldLegend><FieldGroup className="grid gap-6 sm:grid-cols-3"><Field><FieldLabel>Sands</FieldLabel><StatSelect value={build.sands} values={sands} onChange={(v) => update("sands", v as Build["sands"])} /></Field><Field><FieldLabel>Goblet</FieldLabel><StatSelect value={build.goblet} values={goblets} onChange={(v) => update("goblet", v as Build["goblet"])} /></Field><Field><FieldLabel>Circlet</FieldLabel><StatSelect value={build.circlet} values={circlets} onChange={(v) => update("circlet", v as Build["circlet"])} /></Field></FieldGroup></FieldSet>}
|
||||
<Separator />
|
||||
<FieldSet><FieldLegend>Average substat rolls</FieldLegend><FieldDescription>Enter roll counts. Values use gcsim's KQM average-roll scalars.</FieldDescription><div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-5">{rollKeys.map((key) => <Field key={key}><FieldLabel htmlFor={`${build.id}-${key}`}>{key.toUpperCase()} <span className="text-muted-foreground">× {key.includes("%") || ["er","cr","cd"].includes(key) ? `${(SUBSTAT_SCALARS[key] * 100).toFixed(2)}%` : SUBSTAT_SCALARS[key]}</span></FieldLabel><Input id={`${build.id}-${key}`} type="number" min={0} max={99} value={build.rolls[key]} onChange={(event) => update("rolls", { ...build.rolls, [key]: Math.max(0, Math.min(99, Number(event.target.value))) })} /><FieldDescription>{key.includes("%") || ["er","cr","cd"].includes(key) ? `${(calculated[key] * 100).toFixed(2)}%` : calculated[key].toFixed(2)}</FieldDescription></Field>)}</div></FieldSet>
|
||||
{build.importedStats ? <ImportedStatsEditor build={build} onChange={onChange} /> : <FieldSet><FieldLegend>Average substat rolls</FieldLegend><FieldDescription>Enter roll counts. Values use gcsim's KQM average-roll scalars.</FieldDescription><div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-5">{rollKeys.map((key) => <Field key={key}><FieldLabel htmlFor={`${build.id}-${key}`}>{key.toUpperCase()} <span className="text-muted-foreground">× {key.includes("%") || ["er","cr","cd"].includes(key) ? `${(SUBSTAT_SCALARS[key] * 100).toFixed(2)}%` : SUBSTAT_SCALARS[key]}</span></FieldLabel><Input id={`${build.id}-${key}`} type="number" min={0} max={99} value={build.rolls[key]} onChange={(event) => update("rolls", { ...build.rolls, [key]: Math.max(0, Math.min(99, Number(event.target.value))) })} /><FieldDescription>{key.includes("%") || ["er","cr","cd"].includes(key) ? `${(calculated[key] * 100).toFixed(2)}%` : calculated[key].toFixed(2)}</FieldDescription></Field>)}</div></FieldSet>}
|
||||
{build.importedParams && <FieldDescription>Imported parameters: {Object.entries(build.importedParams).filter(([, value]) => value).map(([key, value]) => `${key} ${value}`).join(" · ")}</FieldDescription>}
|
||||
</FieldGroup></CardContent></Card>;
|
||||
}
|
||||
|
||||
export function Translator({ catalog }: { catalog: Catalog }) {
|
||||
const [translation, setTranslation] = useState<Translation>(() => ({ name: "My builds", teams: [makeTeam(catalog, 1, true)] }));
|
||||
const [activeTeam, setActiveTeam] = useState(translation.teams[0].id);
|
||||
useEffect(() => { localStorage.setItem("gcsim-translator-draft", JSON.stringify(translation)); }, [translation]);
|
||||
useEffect(() => { const stored = localStorage.getItem("gcsim-translator-draft"); if (stored) try { const parsed = translationSchema.safeParse(JSON.parse(stored)); if (parsed.success) setTimeout(() => { setTranslation(parsed.data); setActiveTeam(parsed.data.teams[0].id); }, 0); } catch { localStorage.removeItem("gcsim-translator-draft"); } }, []);
|
||||
const [draftLoaded, setDraftLoaded] = useState(false);
|
||||
useEffect(() => { if (draftLoaded) localStorage.setItem("gcsim-translator-draft", JSON.stringify(translation)); }, [translation, draftLoaded]);
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem("gcsim-translator-draft");
|
||||
let restored: Translation | undefined;
|
||||
if (stored) try {
|
||||
const parsed = translationSchema.safeParse(JSON.parse(stored));
|
||||
if (parsed.success) restored = parsed.data;
|
||||
} catch { localStorage.removeItem("gcsim-translator-draft"); }
|
||||
const timer = setTimeout(() => {
|
||||
if (restored) { setTranslation(restored); setActiveTeam(restored.teams[0].id); }
|
||||
setDraftLoaded(true);
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
const team = translation.teams.find((item) => item.id === activeTeam) ?? translation.teams[0];
|
||||
const output = useMemo(() => generateTeam(team), [team]);
|
||||
const rotationError = useMemo(() => translateRotation(team).error, [team]);
|
||||
const updateTeam = (next: Team) => setTranslation((current) => ({ ...current, teams: current.teams.map((item) => item.id === next.id ? next : item) }));
|
||||
const copy = async (value = output) => {
|
||||
if (rotationError) return;
|
||||
try {
|
||||
await copyText(value);
|
||||
toast.success("GCSim build copied");
|
||||
toast.success("GCSim config copied");
|
||||
} catch {
|
||||
toast.error("Could not copy automatically. Select the generated config and copy it manually.");
|
||||
}
|
||||
};
|
||||
return <main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-8 px-4 py-8 sm:px-6 lg:py-12"><Header /><div className="flex flex-col gap-2"><Field className="max-w-md"><FieldLabel htmlFor="translation-name">Collection name</FieldLabel><Input id="translation-name" value={translation.name} onChange={(event) => setTranslation({ ...translation, name: event.target.value })} /><FieldDescription>Saved automatically in this browser. Nothing is written to the database.</FieldDescription></Field></div><Tabs value={activeTeam} onValueChange={setActiveTeam}><div className="flex items-center gap-2 overflow-x-auto"><TabsList>{translation.teams.map((item) => <TabsTrigger key={item.id} value={item.id}>{item.name}</TabsTrigger>)}</TabsList><Button variant="outline" size="icon" disabled={translation.teams.length >= 12} onClick={() => { const next = makeTeam(catalog, translation.teams.length + 1); setTranslation({ ...translation, teams: [...translation.teams, next] }); setActiveTeam(next.id); }} aria-label="Add team"><PlusIcon /></Button></div>{translation.teams.map((item) => <TabsContent key={item.id} value={item.id}><div className="grid gap-8 xl:grid-cols-[minmax(0,1fr)_420px]"><div className="flex flex-col gap-6"><div className="flex items-end gap-3"><Field><FieldLabel>Team name</FieldLabel><Input value={item.name} onChange={(event) => updateTeam({ ...item, name: event.target.value })} /></Field><Button variant="ghost" size="icon" disabled={translation.teams.length === 1} onClick={() => { const teams = translation.teams.filter((candidate) => candidate.id !== item.id); setTranslation({ ...translation, teams }); setActiveTeam(teams[0].id); }} aria-label="Remove team"><Trash2Icon /></Button></div>{item.builds.map((build) => <BuildEditor key={build.id} build={build} catalog={catalog} canRemove={item.builds.length > 1} onRemove={() => updateTeam({ ...item, builds: item.builds.filter((candidate) => candidate.id !== build.id) })} onChange={(next) => updateTeam({ ...item, builds: item.builds.map((candidate) => candidate.id === next.id ? next : candidate) })} />)}<Button variant="outline" disabled={item.builds.length >= 4} onClick={() => { const used = new Set(item.builds.map((build) => build.characterId)); const character = catalog.characters.find((candidate) => !used.has(candidate.id)); if (!character) return toast.error("No more characters available"); updateTeam({ ...item, builds: [...item.builds, makeBuild(catalog, character)] }); }}><PlusIcon data-icon="inline-start" />Add character ({item.builds.length}/4)</Button></div><div className="xl:sticky xl:top-8 xl:self-start"><OutputCard output={output} onCopy={() => copy()} /></div></div></TabsContent>)}</Tabs></main>;
|
||||
return <main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-8 px-4 py-8 sm:px-6 lg:py-12"><Header /><ImportGcsimDialog catalog={catalog} disabled={!draftLoaded || translation.teams.length >= 12} onImport={(imported) => {
|
||||
const next: Team = { ...imported, id: newId("team"), builds: imported.builds.map((build) => ({ ...build, id: newId("build") })) };
|
||||
setTranslation((current) => ({ ...current, teams: [...current.teams, next] }));
|
||||
setActiveTeam(next.id);
|
||||
toast.success("Gcsim team imported");
|
||||
}} /><div className="flex flex-col gap-2"><Field className="max-w-md"><FieldLabel htmlFor="translation-name">Collection name</FieldLabel><Input id="translation-name" value={translation.name} onChange={(event) => setTranslation({ ...translation, name: event.target.value })} /><FieldDescription>Saved automatically in this browser. Nothing is written to the database.</FieldDescription></Field></div><Tabs value={activeTeam} onValueChange={setActiveTeam}><div className="flex items-center gap-2 overflow-x-auto"><TabsList>{translation.teams.map((item) => <TabsTrigger key={item.id} value={item.id}>{item.name}</TabsTrigger>)}</TabsList><Button variant="outline" size="icon" disabled={translation.teams.length >= 12} onClick={() => { const next = makeTeam(catalog, translation.teams.length + 1); setTranslation({ ...translation, teams: [...translation.teams, next] }); setActiveTeam(next.id); }} aria-label="Add team"><PlusIcon /></Button></div>{translation.teams.map((item) => <TabsContent key={item.id} value={item.id}><div className="grid gap-8 xl:grid-cols-[minmax(0,1fr)_420px]"><div className="flex flex-col gap-6"><div className="flex items-end gap-3"><Field><FieldLabel>Team name</FieldLabel><Input value={item.name} onChange={(event) => updateTeam({ ...item, name: event.target.value })} /></Field><Button variant="ghost" size="icon" disabled={translation.teams.length === 1} onClick={() => { const teams = translation.teams.filter((candidate) => candidate.id !== item.id); setTranslation({ ...translation, teams }); setActiveTeam(teams[0].id); }} aria-label="Remove team"><Trash2Icon /></Button></div>{item.builds.map((build) => <BuildEditor key={build.id} build={build} catalog={catalog} canRemove={item.builds.length > 1} onRemove={() => updateTeam({ ...item, builds: item.builds.filter((candidate) => candidate.id !== build.id) })} onChange={(next) => updateTeam({ ...item, builds: item.builds.map((candidate) => candidate.id === next.id ? next : candidate) })} />)}<Button variant="outline" disabled={item.builds.length >= 4} onClick={() => { const used = new Set(item.builds.map((build) => build.characterId)); const character = catalog.characters.find((candidate) => !used.has(candidate.id)); if (!character) return toast.error("No more characters available"); updateTeam({ ...item, builds: [...item.builds, makeBuild(catalog, character)] }); }}><PlusIcon data-icon="inline-start" />Add character ({item.builds.length}/4)</Button></div><div className="flex flex-col gap-6 xl:sticky xl:top-8 xl:self-start"><RotationEditor team={item} onChange={updateTeam} /><OutputCard output={output} onCopy={() => copy()} copyDisabled={Boolean(rotationError)} /></div></div></TabsContent>)}</Tabs></main>;
|
||||
}
|
||||
|
||||
function Header() { return <header className="flex flex-col gap-3"><div className="flex items-center gap-3"><Badge variant="secondary">GCSIM</Badge><span className="text-sm text-muted-foreground">Build translator</span></div><h1 className="max-w-3xl text-4xl font-semibold tracking-tight sm:text-5xl">Build teams, not config syntax.</h1><p className="max-w-2xl text-muted-foreground">Choose loadouts and average artifact rolls. Get clean character blocks ready to paste.</p></header>; }
|
||||
function OutputCard({ output, onCopy }: { output: string; onCopy: () => void }) { return <Card><CardHeader><CardTitle>Generated config</CardTitle><CardDescription>Character build block for the active team.</CardDescription></CardHeader><CardContent><Textarea className="min-h-96 font-mono text-xs" value={output} readOnly aria-label="Generated gcsim config" /></CardContent><CardFooter><Button className="w-full" onClick={onCopy}><CopyIcon data-icon="inline-start" />Copy config</Button></CardFooter></Card>; }
|
||||
function OutputCard({ output, onCopy, copyDisabled }: { output: string; onCopy: () => void; copyDisabled: boolean }) { return <Card><CardHeader><CardTitle>Generated config</CardTitle><CardDescription>Character builds and optional rotation for the active team.</CardDescription></CardHeader><CardContent><Textarea className="min-h-96 font-mono text-xs" value={output} readOnly aria-label="Generated gcsim config" /></CardContent><CardFooter><Button className="w-full" onClick={onCopy} disabled={copyDisabled}><CopyIcon data-icon="inline-start" />Copy config</Button></CardFooter></Card>; }
|
||||
|
||||
function RotationEditor({ team, onChange }: { team: Team; onChange: (team: Team) => void }) {
|
||||
const rotation = team.rotation ?? { enabled: false, text: "", repeat: false };
|
||||
const error = translateRotation(team).error;
|
||||
const update = (patch: Partial<typeof rotation>) => onChange({ ...team, rotation: { ...rotation, ...patch } });
|
||||
const inputId = `${team.id}-rotation`;
|
||||
return <Card>
|
||||
<CardHeader><CardTitle>Rotation translator</CardTitle><CardDescription>{rotation.format === "gcsim" ? "Imported script with its original loops and action parameters." : "Write your combo using character names and shorthand."}</CardDescription></CardHeader>
|
||||
<CardContent><FieldGroup>
|
||||
<Field><Toggle variant="outline" pressed={rotation.enabled} onPressedChange={(enabled) => update({ enabled })} aria-controls={`${inputId}-controls`}>Include rotation</Toggle><FieldDescription>{rotation.enabled ? "Rotation actions will be included in the copied config." : "Off: only character builds are included."}</FieldDescription></Field>
|
||||
{rotation.enabled && <FieldGroup id={`${inputId}-controls`}>
|
||||
<Field data-invalid={Boolean(error)}><FieldLabel htmlFor={inputId}>{rotation.format === "gcsim" ? "Imported gcsim rotation" : "Rotation"}</FieldLabel><Textarea id={inputId} value={rotation.text} maxLength={10000} onChange={(event) => update({ text: event.target.value })} placeholder={rotation.format === "gcsim" ? "mavuika skill;" : "Mavuika E > Xilonen E N2"} aria-invalid={Boolean(error)} aria-describedby={`${inputId}-help${error ? ` ${inputId}-error` : ""}`} /><FieldDescription id={`${inputId}-help`}>{rotation.format === "gcsim" ? "Edit the imported gcsim script directly. Brackets are checked here; full script validation happens in gcsim." : <>Use names or keys from this team: {team.builds.map((build) => build.characterName).join(", ")}. Separate character steps with > 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>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client"
|
||||
|
||||
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "cn"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"group/toggle inline-flex items-center justify-center gap-1.5 rounded-none text-xs font-semibold tracking-widest whitespace-nowrap uppercase transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline: "border border-input bg-transparent hover:bg-muted",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-10 min-w-10 px-6 has-data-[icon=inline-end]:pr-4 has-data-[icon=inline-start]:pl-4",
|
||||
sm: "h-9 min-w-9 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
lg: "h-11 min-w-11 px-8 has-data-[icon=inline-end]:pr-5 has-data-[icon=inline-start]:pl-5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
@@ -0,0 +1,9 @@
|
||||
import { rollKeys, type Build, type Catalog, type CatalogItem, type RollKey } from "./types";
|
||||
|
||||
const emptyRolls = Object.fromEntries(rollKeys.map((key) => [key, 0])) as Record<RollKey, number>;
|
||||
|
||||
export function createBuild(catalog: Catalog, character: CatalogItem, id: string): Build {
|
||||
const weapon = catalog.weapons.find((item) => !character.weaponType || item.weaponType === character.weaponType) ?? catalog.weapons[0];
|
||||
const artifact = catalog.artifacts[0];
|
||||
return { id, characterId: character.id, characterKey: character.key, characterName: character.name, characterImageUrl: character.imageUrl, level: 90, maxLevel: 90, constellation: 0, talents: [9, 9, 9], weaponId: weapon.id, weaponKey: weapon.key, weaponName: weapon.name, weaponLevel: 90, weaponMaxLevel: 90, refinement: 1, artifactMode: "4pc", artifactOneId: artifact.id, artifactOneKey: artifact.key, artifactOneName: artifact.name, artifactTwoId: "", artifactTwoKey: "", artifactTwoName: "", sands: "atk%", goblet: "atk%", circlet: "cr", rolls: { ...emptyRolls } };
|
||||
}
|
||||
@@ -38,12 +38,16 @@ export function calculateFinalStats(build: Build, characterStats: CatalogBaseSta
|
||||
addCatalogStats(weaponStats.filter((stat) => stat.key.toLowerCase() !== "atk"), bonuses);
|
||||
|
||||
const artifactStats: Bonuses = { hp: 4780, atk: 311, def: 0, em: 0, er: 0, cr: 0, cd: 0, "hp%": 0, "atk%": 0, "def%": 0 };
|
||||
for (const key of [build.sands, build.goblet, build.circlet]) {
|
||||
for (const key of build.importedStats ? [] : [build.sands, build.goblet, build.circlet]) {
|
||||
const bonusKey = key.endsWith("%") && !["hp%", "atk%", "def%"].includes(key) ? key.slice(0, -1) : key;
|
||||
artifactStats[bonusKey] = (artifactStats[bonusKey] ?? 0) + MAIN_STATS[key];
|
||||
}
|
||||
const substats = calculateSubstats(build);
|
||||
for (const [key, value] of Object.entries(substats)) artifactStats[key] = (artifactStats[key] ?? 0) + value;
|
||||
const substats = build.importedStats ?? calculateSubstats(build);
|
||||
if (build.importedStats) { artifactStats.hp = 0; artifactStats.atk = 0; }
|
||||
for (const [key, value] of Object.entries(substats)) {
|
||||
const bonusKey = key.endsWith("%") && !["hp%", "atk%", "def%"].includes(key) ? key.slice(0, -1) : key;
|
||||
artifactStats[bonusKey] = (artifactStats[bonusKey] ?? 0) + value;
|
||||
}
|
||||
for (const [key, value] of Object.entries(artifactStats)) bonuses[key] = (bonuses[key] ?? 0) + value;
|
||||
|
||||
const characterAtk = baseValue(characterStats, "atk");
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Split only at top level: loops, conditionals, functions and action parameters
|
||||
// must stay intact. Comments are removed without touching quoted strings.
|
||||
export function splitGcsimStatements(input: string): string[] {
|
||||
const statements: string[] = [];
|
||||
const stack: string[] = [];
|
||||
// Replacing comments up front also lets a comment appear before an else.
|
||||
input = input.replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|#[^\n]*|\/\/[^\n]*/g, (match) => match.startsWith("#") || match.startsWith("//") ? " " : match);
|
||||
let text = "";
|
||||
let quote = "";
|
||||
let escaped = false;
|
||||
const flush = () => { if (text.trim()) statements.push(text.trim()); text = ""; };
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const char = input[i];
|
||||
if (quote) {
|
||||
text += char;
|
||||
if (escaped) escaped = false;
|
||||
else if (char === "\\") escaped = true;
|
||||
else if (char === quote) quote = "";
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") { quote = char; text += char; continue; }
|
||||
if ("([{".includes(char)) {
|
||||
stack.push(char);
|
||||
if (stack.length > 64) throw new Error("Config is nested too deeply.");
|
||||
} else if (")]}".includes(char)) {
|
||||
const expected: Record<string, string> = { ")": "(", "]": "[", "}": "{" };
|
||||
if (stack.pop() !== expected[char]) throw new Error(`Unmatched '${char}' at position ${i + 1}.`);
|
||||
}
|
||||
text += char;
|
||||
if (stack.length === 0 && char === ";") flush();
|
||||
// Keep an else branch attached to its if statement.
|
||||
if (stack.length === 0 && char === "}" && !/^\s*else\b/.test(input.slice(i + 1))) flush();
|
||||
}
|
||||
if (quote) throw new Error("Unclosed quoted string.");
|
||||
if (stack.length) throw new Error("Unclosed bracket or parenthesis.");
|
||||
if (text.trim()) throw new Error("Missing ';' at the end of a statement.");
|
||||
return statements;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Build, MainStatKey, RollKey, Team } from "./types";
|
||||
import { translateRotation } from "./rotation";
|
||||
|
||||
export const SUBSTAT_SCALARS: Record<RollKey, number> = {
|
||||
hp: 253.94, atk: 16.54, def: 19.68, em: 19.82,
|
||||
@@ -29,18 +30,23 @@ export function calculateSubstats(build: Build): Record<RollKey, number> {
|
||||
export function generateBuild(build: Build): string {
|
||||
const main: Record<string, number> = { hp: 4780, atk: 311 };
|
||||
for (const stat of [build.sands, build.goblet, build.circlet]) main[stat] = (main[stat] ?? 0) + MAIN_STATS[stat];
|
||||
const sets = build.artifactMode === "4pc"
|
||||
const sets = build.importedSets ? build.importedSets.map((set) => `${build.characterKey} add set="${set.key}" count=${set.count}${set.params ? ` +params=${set.params}` : ""};`) : build.artifactMode === "4pc"
|
||||
? [`${build.characterKey} add set="${build.artifactOneKey}" count=4;`]
|
||||
: [`${build.characterKey} add set="${build.artifactOneKey}" count=2;`, `${build.characterKey} add set="${build.artifactTwoKey}" count=2;`];
|
||||
const importedBody = Object.entries(build.importedStats ?? {}).map(([key, value]) => `${key}=${value}`).join(" ");
|
||||
const stats = build.importedStats
|
||||
? (importedBody ? [`${build.characterKey} add stats ${importedBody}; # imported artifact stats`] : [])
|
||||
: [statsLine(build.characterKey, main, "main stats"), statsLine(build.characterKey, calculateSubstats(build), "substats")];
|
||||
return [
|
||||
`${build.characterKey} char lvl=${build.level}/${build.maxLevel} cons=${build.constellation} talent=${build.talents.join(",")};`,
|
||||
`${build.characterKey} add weapon="${build.weaponKey}" refine=${build.refinement} lvl=${build.weaponLevel}/${build.weaponMaxLevel};`,
|
||||
`${build.characterKey} char lvl=${build.level}/${build.maxLevel} cons=${build.constellation} talent=${build.talents.join(",")}${build.importedParams?.character ? ` +params=${build.importedParams.character}` : ""};`,
|
||||
`${build.characterKey} add weapon="${build.weaponKey}" refine=${build.refinement} lvl=${build.weaponLevel}/${build.weaponMaxLevel}${build.importedParams?.weapon ? ` +params=${build.importedParams.weapon}` : ""};`,
|
||||
...sets,
|
||||
statsLine(build.characterKey, main, "main stats"),
|
||||
statsLine(build.characterKey, calculateSubstats(build), "substats"),
|
||||
...stats,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function generateTeam(team: Team): string {
|
||||
return team.builds.map(generateBuild).join("\n\n");
|
||||
const builds = team.builds.map(generateBuild).join("\n\n");
|
||||
const rotation = translateRotation(team);
|
||||
return rotation.code ? `${builds}\n\n# Rotation\n${rotation.code}` : builds;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createBuild } from "./build";
|
||||
import { calculateFinalStats } from "./final-stats";
|
||||
import { generateBuild, generateTeam } from "./generate";
|
||||
import { splitGcsimStatements } from "./gcsim-text";
|
||||
import { importGcsimConfig } from "./import";
|
||||
import { translateRotation } from "./rotation";
|
||||
import { translationSchema, type Catalog, type CatalogItem } from "./types";
|
||||
|
||||
const item = (key: string, name = key): CatalogItem => ({ id: key, key, name, imageUrl: "", rarity: 5, supported: true, baseStats: [], weaponType: "sword" });
|
||||
const catalog: Catalog = {
|
||||
version: "test",
|
||||
characters: [item("bennett", "Bennett"), item("furina", "Furina"), item("kaedeharakazuha", "Kaedehara Kazuha"), item("kamisatoayaka", "Kamisato Ayaka"), item("xilonen", "Xilonen")],
|
||||
weapons: [item("favoniussword", "Favonius Sword"), { ...item("favoniusgreatsword"), weaponType: "claymore" }],
|
||||
artifacts: [item("noblesseoblige", "Noblesse Oblige"), item("goldentroupe", "Golden Troupe")],
|
||||
};
|
||||
const config = `
|
||||
# A complete simulation
|
||||
options iteration=1000 duration=90;
|
||||
target lvl=100 resist=0.1;
|
||||
energy every interval=480,720 amount=1;
|
||||
bennett char lvl=70/80 cons=6 talent=6,8,8;
|
||||
bennett add weapon="favoniussword" refine=5 lvl=80/90;
|
||||
bennett add set="noblesseoblige" count=4;
|
||||
bennett add stats hp=4780 atk=311 er=0.518 pyro%=0.466 cr=0.311;
|
||||
bennett add stats hp=717 atk%=0.1234567 cr=0.128 cd=0.265;
|
||||
active bennett;
|
||||
while 1 {
|
||||
bennett skill[hold=1], attack:2, burst;
|
||||
wait(60);
|
||||
}
|
||||
`;
|
||||
|
||||
describe("gcsim import", () => {
|
||||
it("imports character levels, talents, equipment, exact stats, and advanced rotation", () => {
|
||||
const result = importGcsimConfig(config, catalog);
|
||||
expect(result.error).toBeUndefined();
|
||||
const build = result.team!.builds[0];
|
||||
expect(build).toMatchObject({ characterName: "Bennett", level: 70, maxLevel: 80, constellation: 6, talents: [6, 8, 8], weaponKey: "favoniussword", refinement: 5, weaponLevel: 80, weaponMaxLevel: 90, importedStats: { hp: 5497, atk: 311, er: 0.518, "pyro%": 0.466, cr: 0.439, "atk%": 0.1234567, cd: 0.265 } });
|
||||
expect(build.importedSets).toMatchObject([{ key: "noblesseoblige", count: 4 }]);
|
||||
expect(result.team!.rotation).toMatchObject({ enabled: true, format: "gcsim", repeat: false });
|
||||
expect(result.team!.rotation!.text).toContain("while 1 {\n bennett skill[hold=1], attack:2, burst;\n wait(60);\n}");
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
});
|
||||
it("imports declarations and settings in any order with multiple statements per line", () => {
|
||||
const result = importGcsimConfig('furina add stats hp=123; furina add weapon="favoniussword" lvl=90/90 refine=1; furina char lvl=89/90 cons=0 talent=9,9,9;', catalog);
|
||||
expect(result.team!.builds[0]).toMatchObject({ level: 89, importedStats: { hp: 123 }, importedSets: [] });
|
||||
expect(result.team!.rotation!.enabled).toBe(false);
|
||||
expect(generateTeam(result.team!)).not.toContain('add set=');
|
||||
});
|
||||
it("preserves character, weapon and artifact +params", () => {
|
||||
const result = importGcsimConfig(config.replace('talent=6,8,8;', 'talent=6,8,8 +params=[start_hp=5000];').replace('lvl=80/90;', 'lvl=80/90 +params=[stacks=5];').replace('count=4;', 'count=4 +params=[stacks=2];'), catalog);
|
||||
const output = generateTeam(result.team!);
|
||||
expect(output).toContain('+params=[start_hp=5000]');
|
||||
expect(output).toContain('+params=[stacks=5]');
|
||||
expect(output).toContain('+params=[stacks=2]');
|
||||
});
|
||||
it("preserves unusual piece counts and two-set configurations", () => {
|
||||
const result = importGcsimConfig(config.replace('count=4;', 'count=3;\nbennett add set="goldentroupe" count=2;'), catalog);
|
||||
expect(result.team!.builds[0].importedSets).toMatchObject([{ count: 3 }, { key: "goldentroupe", count: 2 }]);
|
||||
expect(generateTeam(result.team!)).toContain('set="noblesseoblige" count=3;');
|
||||
});
|
||||
it("accepts common character aliases and retains matching script keys", () => {
|
||||
const result = importGcsimConfig(config.replaceAll("bennett", "kazuha").replace("active kazuha;", "active kaedeharakazuha;"), catalog);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.team!.builds[0]).toMatchObject({ characterId: "kaedeharakazuha", characterKey: "kazuha" });
|
||||
});
|
||||
it.each([
|
||||
["favsword", "favoniussword"], ["FAVSWORD", "favoniussword"],
|
||||
["favgs", "favoniusgreatsword"], ["favbow", "favoniuswarbow"],
|
||||
["favlance", "favoniuslance"], ["favspear", "favoniuslance"], ["favcodex", "favoniuscodex"],
|
||||
])("imports weapon alias %s as %s", (alias, key) => {
|
||||
const weapon = item(key);
|
||||
const fixture = { ...catalog, weapons: [weapon] };
|
||||
const result = importGcsimConfig(config.replace('"favoniussword"', `"${alias}"`), fixture);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.team!.builds[0].weaponKey).toBe(key);
|
||||
expect(generateTeam(result.team!)).toContain(`weapon="${key}"`);
|
||||
});
|
||||
it("keeps weapon compatibility checks when resolving aliases", () => {
|
||||
expect(importGcsimConfig(config.replace('"favoniussword"', '"favgs"'), catalog).error).toContain("not compatible");
|
||||
});
|
||||
it.each(["ttds", "TTDS"])("imports catalyst weapon alias %s", (alias) => {
|
||||
const character = { ...item("sangonomiyakokomi", "Sangonomiya Kokomi"), weaponType: "catalyst" };
|
||||
const weapon = { ...item("thrillingtalesofdragonslayers", "Thrilling Tales of Dragon Slayers"), weaponType: "catalyst" };
|
||||
const fixture = { ...catalog, characters: [character], weapons: [weapon] };
|
||||
const input = config.replaceAll("bennett", "kokomi").replace('"favoniussword"', `"${alias}"`).replace("skill[hold=1]", "skill");
|
||||
const result = importGcsimConfig(input, fixture);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.team!.builds[0]).toMatchObject({ weaponId: weapon.id, weaponKey: weapon.key, weaponName: weapon.name });
|
||||
const output = generateTeam(result.team!);
|
||||
expect(output).toContain('weapon="thrillingtalesofdragonslayers" refine=5');
|
||||
expect(importGcsimConfig(output, fixture).team!.builds[0].weaponKey).toBe(weapon.key);
|
||||
});
|
||||
it("rejects TTDS on a character that cannot equip catalysts", () => {
|
||||
const weapon = { ...item("thrillingtalesofdragonslayers"), weaponType: "catalyst" };
|
||||
expect(importGcsimConfig(config.replace('"favoniussword"', '"ttds"'), { ...catalog, weapons: [weapon] }).error).toContain("not compatible");
|
||||
});
|
||||
it.each(["totm", "TOTM", "tom", "tenacity", "millelith", "tenacityofthemillelith"])("imports artifact alias %s as Tenacity of the Millelith", (alias) => {
|
||||
const artifact = item("tenacityofthemillelith", "Tenacity of the Millelith");
|
||||
const fixture = { ...catalog, artifacts: [artifact] };
|
||||
const result = importGcsimConfig(config.replace('"noblesseoblige"', `"${alias}"`), fixture);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.team!.builds[0].importedSets).toMatchObject([{ id: artifact.id, key: artifact.key, name: artifact.name, count: 4 }]);
|
||||
const output = generateTeam(result.team!);
|
||||
expect(output).toContain('set="tenacityofthemillelith" count=4;');
|
||||
expect(importGcsimConfig(output, fixture).team!.builds[0].importedSets).toEqual(result.team!.builds[0].importedSets);
|
||||
});
|
||||
it("still reports Tenacity aliases when the artifact is absent from the catalog", () => {
|
||||
expect(importGcsimConfig(config.replace('"noblesseoblige"', '"totm"'), catalog).error).toContain("Unknown artifact set 'totm'");
|
||||
});
|
||||
it.each(["no", "NO", "noblesse"])("imports artifact alias %s as Noblesse Oblige", (alias) => {
|
||||
const result = importGcsimConfig(config.replace('"noblesseoblige"', `"${alias}"`), catalog);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.team!.builds[0].importedSets).toMatchObject([{ key: "noblesseoblige", name: "Noblesse Oblige", count: 4 }]);
|
||||
const output = generateTeam(result.team!);
|
||||
expect(output).toContain('set="noblesseoblige" count=4;');
|
||||
expect(importGcsimConfig(output, catalog).team!.builds[0].importedSets).toEqual(result.team!.builds[0].importedSets);
|
||||
});
|
||||
it.each(["scroll", "SCROLL", "sothocc"])("imports artifact alias %s as Scroll of the Hero of Cinder City", (alias) => {
|
||||
const artifact = item("scrolloftheheroofcindercity", "Scroll of the Hero of Cinder City");
|
||||
const fixture = { ...catalog, artifacts: [artifact] };
|
||||
const result = importGcsimConfig(config.replace('"noblesseoblige"', `"${alias}"`), fixture);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.team!.builds[0].importedSets).toMatchObject([{ key: artifact.key, name: artifact.name, count: 4 }]);
|
||||
const output = generateTeam(result.team!);
|
||||
expect(output).toContain('set="scrolloftheheroofcindercity" count=4;');
|
||||
expect(importGcsimConfig(output, fixture).team!.builds[0].importedSets).toEqual(result.team!.builds[0].importedSets);
|
||||
});
|
||||
it("accepts signed decimal and scientific stats without rounding to rolls", () => {
|
||||
const result = importGcsimConfig(config.replace('hp=717 atk%=0.1234567 cr=0.128 cd=0.265', 'hp=7.17e2 atk%=.1234567 cr=+0.128 cd=-0.265 dmg%=0.1 atkspd%=0.2'), catalog);
|
||||
expect(result.team!.builds[0].importedStats).toMatchObject({ hp: 5497, "atk%": 0.1234567, cd: -0.265, "dmg%": 0.1, "atkspd%": 0.2 });
|
||||
});
|
||||
it("keeps high precision values and sums decimal bonuses without rounding noise", () => {
|
||||
const result = importGcsimConfig(config.replace('atk%=0.1234567', 'atk%=0.12345678901234567') + '\nbennett add stats dmg%=0.1; bennett add stats dmg%=0.2;', catalog);
|
||||
expect(result.team!.builds[0].importedStats).toMatchObject({ "atk%": 0.12345678901234567, "dmg%": 0.3 });
|
||||
expect(generateTeam(result.team!)).toContain('atk%=0.12345678901234566');
|
||||
});
|
||||
it("round trips an imported team without changing stats, equipment, or rotation", () => {
|
||||
const imported = importGcsimConfig(config, catalog).team!;
|
||||
const again = importGcsimConfig(generateTeam(imported), catalog).team!;
|
||||
expect(again.builds).toEqual(imported.builds);
|
||||
expect(again.rotation).toEqual(imported.rotation);
|
||||
});
|
||||
it("imports app generated configs without adding more artifact stats", () => {
|
||||
const original = createBuild(catalog, catalog.characters[0], "build");
|
||||
original.rolls.cr = 5;
|
||||
const imported = importGcsimConfig(generateBuild(original), catalog).team!;
|
||||
expect(imported.builds[0].importedStats?.cr).toBeCloseTo(0.4765);
|
||||
expect(generateTeam(imported)).not.toContain("# main stats");
|
||||
});
|
||||
it("uses imported stats for final stat calculations and reflects direct edits", () => {
|
||||
const imported = importGcsimConfig(config, catalog).team!.builds[0];
|
||||
const character = [{ key: "hp", value: 10000 }, { key: "atk", value: 200 }, { key: "def", value: 500 }];
|
||||
const weapon = [{ key: "atk", value: 400 }];
|
||||
const stats = calculateFinalStats(imported, character, weapon);
|
||||
expect(stats.find((stat) => stat.key === "HP")!.value).toBe(15497);
|
||||
expect(stats.find((stat) => stat.key === "ATK")!.value).toBeCloseTo(600 * 1.1234567 + 311);
|
||||
expect(stats.find((stat) => stat.key === "pyro")!.value).toBe(0.466);
|
||||
const edited = { ...imported, importedStats: { ...imported.importedStats, hp: 100 } };
|
||||
expect(calculateFinalStats(edited, character, weapon).find((stat) => stat.key === "HP")!.value).toBe(10100);
|
||||
expect(generateBuild(edited)).toContain("hp=100");
|
||||
});
|
||||
it("saves imported fields and keeps old drafts compatible", () => {
|
||||
const team = importGcsimConfig(config, catalog).team!;
|
||||
expect(translationSchema.parse(JSON.parse(JSON.stringify({ name: "Saved", teams: [team] }))).teams[0]).toEqual(team);
|
||||
const old = { ...team, rotation: undefined, builds: [createBuild(catalog, catalog.characters[0], "old")] };
|
||||
expect(translationSchema.safeParse({ name: "Old", teams: [old] }).success).toBe(true);
|
||||
});
|
||||
it("switching imported rotation off excludes all script and keeps stats intact", () => {
|
||||
const team = importGcsimConfig(config, catalog).team!;
|
||||
const disabled = { ...team, rotation: { ...team.rotation!, enabled: false } };
|
||||
expect(generateTeam(disabled)).not.toContain("while 1");
|
||||
expect(generateTeam(disabled)).toContain("hp=5497");
|
||||
expect(disabled.rotation.text).toContain("while 1");
|
||||
});
|
||||
it("supplies the active character when the script omits it", () => {
|
||||
const team = importGcsimConfig(config.replace('active bennett;', ''), catalog).team!;
|
||||
expect(translateRotation(team).code).toContain("active bennett;");
|
||||
});
|
||||
it("rejects references to characters removed after import", () => {
|
||||
const team = importGcsimConfig(config, catalog).team!;
|
||||
expect(translateRotation({ ...team, builds: [{ ...team.builds[0], characterKey: "furina" }] }).error).toContain("not in this team");
|
||||
});
|
||||
it.each([
|
||||
["empty", "", "Paste"],
|
||||
["no declarations", "bennett skill;", "No character"],
|
||||
["unknown character", config.replaceAll("bennett", "unknown"), "Unknown character"],
|
||||
["unknown weapon", config.replace('"favoniussword"', '"missing"'), "Unknown weapon"],
|
||||
["incompatible weapon", config.replace('"favoniussword"', '"favoniusgreatsword"'), "not compatible"],
|
||||
["unknown set", config.replace('"noblesseoblige"', '"missing"'), "Unknown artifact"],
|
||||
["missing weapon", config.replace(/bennett add weapon[^;]+;/, ''), "Missing weapon"],
|
||||
["duplicate weapon", config + '\nbennett add weapon="favoniussword" refine=1 lvl=90/90;', "More than one weapon"],
|
||||
["duplicate declaration", config + '\nbennett char lvl=90/90 cons=0 talent=9,9,9;', "more than once"],
|
||||
["duplicate aliases", config.replaceAll("bennett", "kazuha") + '\nkaedeharakazuha char lvl=90/90 cons=0 talent=9,9,9;', "more than once"],
|
||||
["invalid stats", config.replace('hp=717', 'hp=NaN'), "Invalid value"],
|
||||
["unknown stats", config.replace('hp=717', 'foo=717'), "Unsupported build"],
|
||||
["unknown attributes", config.replace('cons=6', 'foo=6'), "Unsupported build"],
|
||||
["duplicate attributes", config.replace('cons=6', 'cons=6 cons=1'), "Duplicate"],
|
||||
["invalid levels", config.replace('lvl=70/80', 'lvl=90/80'), "Level cannot exceed"],
|
||||
["invalid level syntax", config.replace('lvl=70/80', 'lvl=90'), "level/cap"],
|
||||
["invalid talents", config.replace('talent=6,8,8', 'talent=6,8'), "Invalid talent"],
|
||||
["too many pieces", config.replace('count=4', 'count=6'), "exceeds five"],
|
||||
["invalid piece count", config.replace('count=4', 'count=1.5'), "Invalid input"],
|
||||
["unknown rotation member", config.replace('bennett skill', 'furina skill'), "not in this team"],
|
||||
["invalid parameters", config.replace('talent=6,8,8;', 'talent=6,8,8 +params=[stacks=foo];'), "numeric +params"],
|
||||
["unclosed loop", config.replace(/}\s*$/, ''), "Unclosed"],
|
||||
["unclosed string", config.replace('weapon="favoniussword"', 'weapon="favoniussword'), "Unclosed"],
|
||||
["missing semicolon", config.replace('active bennett;', 'active bennett'), "semicolon"],
|
||||
])("rejects %s", (_name, input, expected) => {
|
||||
const result = importGcsimConfig(input, catalog);
|
||||
expect(result.team).toBeUndefined();
|
||||
expect(result.error).toContain(expected);
|
||||
});
|
||||
it("limits characters and input size", () => {
|
||||
const declarations = catalog.characters.map((char) => `${char.key} char lvl=90/90 cons=0 talent=9,9,9;`).join("\n");
|
||||
expect(importGcsimConfig(declarations, catalog).error).toContain("four");
|
||||
expect(importGcsimConfig("x".repeat(100001), catalog).error).toContain("100,000");
|
||||
expect(importGcsimConfig(config + 'bennett attack;\n'.repeat(700), catalog).error).toContain("10,000");
|
||||
});
|
||||
});
|
||||
|
||||
describe("gcsim statement reader", () => {
|
||||
it("ignores comments without damaging quoted strings", () => {
|
||||
expect(splitGcsimStatements('foo("#; // literal"); # comment\n wait(60); // end')).toEqual(['foo("#; // literal");', 'wait(60);']);
|
||||
});
|
||||
it("keeps conditionals, functions, nested loops, and comments before else intact", () => {
|
||||
const script = 'fn combo() { bennett skill; }\nif 1 { while 1 { bennett attack; } } # note\nelse { bennett burst; }';
|
||||
expect(splitGcsimStatements(script)).toHaveLength(2);
|
||||
expect(splitGcsimStatements(script)[1]).toContain("else");
|
||||
});
|
||||
it.each(['wait(60]', 'bennett attack', 'wait("broken);', 'while 1 { bennett attack;', '('.repeat(65)])("rejects malformed script %s", (script) => {
|
||||
expect(() => splitGcsimStatements(script)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { createBuild } from "./build";
|
||||
import { splitGcsimStatements } from "./gcsim-text";
|
||||
import { translateRotation } from "./rotation";
|
||||
import { canonicalArtifactKey, canonicalCharacterKey, canonicalWeaponKey } from "./keys";
|
||||
import { importedStatKeys, translationSchema, type Build, type Catalog, type CatalogItem, type Team } from "./types";
|
||||
|
||||
export type ImportResult = { team: Team; warnings: string[]; error?: never } | { error: string; team?: never; warnings?: never };
|
||||
function findItem(items: CatalogItem[], key: string, kind: string): CatalogItem {
|
||||
const canonicalize = kind === "character" ? canonicalCharacterKey : kind === "weapon" ? canonicalWeaponKey : canonicalArtifactKey;
|
||||
const canonical = canonicalize(key);
|
||||
const item = items.find((item) => [item.key, item.name].some((candidate) => canonicalize(candidate) === canonical));
|
||||
if (!item) throw new Error(`Unknown ${kind} '${key}' in the current catalog.`);
|
||||
return item;
|
||||
}
|
||||
|
||||
function attributes(text: string, allowed: string[]): { values: Record<string, string>; params?: string } {
|
||||
let params: string | undefined;
|
||||
const parameterMatch = /\s*\+params\s*=\s*(\[[\s\S]*\])\s*$/.exec(text);
|
||||
if (parameterMatch) {
|
||||
params = parameterMatch[1];
|
||||
if (params.length > 2000) throw new Error("Parameters exceed 2000 characters.");
|
||||
const body = params.slice(1, -1).trim();
|
||||
if (body && !/^(?:[a-zA-Z][\w]*\s*=\s*[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?\s*)+$/.test(body)) throw new Error("Only numeric +params values are supported for builds.");
|
||||
text = text.slice(0, parameterMatch.index);
|
||||
}
|
||||
const values: Record<string, string> = {};
|
||||
let remaining = text.trim();
|
||||
while (remaining) {
|
||||
const match = /^([a-zA-Z][\w%]*)\s*=\s*("[^"\n]*"|[^\s]+)(?:\s+|$)/.exec(remaining);
|
||||
if (!match) throw new Error(`Could not read build settings near '${remaining.slice(0, 40)}'.`);
|
||||
const key = match[1].toLowerCase();
|
||||
if (!allowed.includes(key)) throw new Error(`Unsupported build setting '${key}'.`);
|
||||
if (key in values) throw new Error(`Duplicate build setting '${key}'.`);
|
||||
values[key] = match[2].replace(/^"|"$/g, "");
|
||||
remaining = remaining.slice(match[0].length).trim();
|
||||
}
|
||||
return { values, params };
|
||||
}
|
||||
|
||||
function required(values: Record<string, string>, key: string): string {
|
||||
if (!(key in values)) throw new Error(`Missing '${key}' in build settings.`);
|
||||
return values[key];
|
||||
}
|
||||
|
||||
function level(value: string): [number, number] {
|
||||
const match = /^(\d+)\/(\d+)$/.exec(value);
|
||||
if (!match) throw new Error("Levels must use level/cap, for example 90/90.");
|
||||
return [Number(match[1]), Number(match[2])];
|
||||
}
|
||||
|
||||
// Add decimal values without introducing binary rounding noise in exported stats.
|
||||
function addStatValues(left: number, right: number): number {
|
||||
const decimal = (value: number) => {
|
||||
const [mantissa, exponent = "0"] = value.toString().split("e");
|
||||
return { amount: BigInt(mantissa.replace(".", "")), scale: (mantissa.split(".")[1]?.length ?? 0) - Number(exponent) };
|
||||
};
|
||||
const a = decimal(left), b = decimal(right);
|
||||
const scale = Math.max(a.scale, b.scale);
|
||||
const sum = a.amount * BigInt(10) ** BigInt(scale - a.scale) + b.amount * BigInt(10) ** BigInt(scale - b.scale);
|
||||
return Number(`${sum}e${-scale}`);
|
||||
}
|
||||
|
||||
export function importGcsimConfig(input: string, catalog: Catalog, name = "Imported team"): ImportResult {
|
||||
if (!input.trim()) return { error: "Paste a gcsim config to import." };
|
||||
if (input.length > 100000) return { error: "Config exceeds 100,000 characters." };
|
||||
try {
|
||||
const statements = splitGcsimStatements(input);
|
||||
const builds = new Map<string, Build>();
|
||||
const ids = new Set<string>();
|
||||
const weaponSeen = new Set<string>();
|
||||
const warnings = new Set<string>();
|
||||
const script: string[] = [];
|
||||
for (const statement of statements) {
|
||||
const match = /^([a-z]+)\s+char\s+([\s\S]*);$/i.exec(statement);
|
||||
if (!match) continue;
|
||||
const key = match[1].toLowerCase();
|
||||
const character = findItem(catalog.characters, key, "character");
|
||||
if (ids.has(character.id)) throw new Error(`Character '${character.name}' appears more than once.`);
|
||||
ids.add(character.id);
|
||||
if (ids.size > 4) throw new Error("Import supports at most four characters per team.");
|
||||
const { values, params } = attributes(match[2], ["lvl", "cons", "talent"]);
|
||||
const build = createBuild(catalog, character, `import-build-${builds.size + 1}`);
|
||||
const [lvl, cap] = level(required(values, "lvl"));
|
||||
const talents = required(values, "talent");
|
||||
if (!/^\d+,\d+,\d+$/.test(talents)) throw new Error(`Invalid talent levels for '${key}'.`);
|
||||
builds.set(key, { ...build, characterKey: key, level: lvl, maxLevel: cap, constellation: Number(required(values, "cons")), talents: talents.split(",").map(Number) as Build["talents"], importedStats: {}, importedSets: [], importedParams: params ? { character: params } : undefined });
|
||||
}
|
||||
if (!builds.size) throw new Error("No character declarations found. Include lines such as 'bennett char lvl=90/90 cons=0 talent=9,9,9;'.");
|
||||
const resolveBuild = (key: string): Build => {
|
||||
const item = findItem(catalog.characters, key, "character");
|
||||
const build = [...builds.values()].find((build) => build.characterId === item.id);
|
||||
if (!build) throw new Error(`Character '${key}' has settings but no character declaration.`);
|
||||
return build;
|
||||
};
|
||||
for (const statement of statements) {
|
||||
if (/^[a-z]+\s+char\b/i.test(statement)) continue;
|
||||
const setting = /^([a-z]+)\s+add\s+([\s\S]*);$/i.exec(statement);
|
||||
if (setting) {
|
||||
const build = resolveBuild(setting[1]);
|
||||
const body = setting[2];
|
||||
if (/^weapon\s*=/i.test(body)) {
|
||||
if (weaponSeen.has(build.characterId)) throw new Error(`More than one weapon specified for '${build.characterName}'.`);
|
||||
weaponSeen.add(build.characterId);
|
||||
const { values, params } = attributes(body, ["weapon", "refine", "lvl"]);
|
||||
const weapon = findItem(catalog.weapons, required(values, "weapon"), "weapon");
|
||||
const character = catalog.characters.find((item) => item.id === build.characterId)!;
|
||||
if (character.weaponType && weapon.weaponType && character.weaponType !== weapon.weaponType) throw new Error(`${weapon.name} is not compatible with ${character.name}.`);
|
||||
const [lvl, cap] = level(required(values, "lvl"));
|
||||
Object.assign(build, { weaponId: weapon.id, weaponKey: weapon.key, weaponName: weapon.name, weaponLevel: lvl, weaponMaxLevel: cap, refinement: Number(required(values, "refine")), importedParams: { ...build.importedParams, weapon: params } });
|
||||
} else if (/^set\s*=/i.test(body)) {
|
||||
const { values, params } = attributes(body, ["set", "count"]);
|
||||
const artifact = findItem(catalog.artifacts, required(values, "set"), "artifact set");
|
||||
build.importedSets!.push({ id: artifact.id, key: artifact.key, name: artifact.name, count: Number(required(values, "count")), params });
|
||||
} else if (/^stats\b/i.test(body)) {
|
||||
const { values, params } = attributes(body.replace(/^stats\s*/i, ""), [...importedStatKeys]);
|
||||
if (params) throw new Error("+params is not supported on stats lines.");
|
||||
for (const [key, raw] of Object.entries(values)) {
|
||||
if (!/^[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?$/.test(raw) || !Number.isFinite(Number(raw))) throw new Error(`Invalid value '${raw}' for stat '${key}'.`);
|
||||
const stat = key as keyof NonNullable<Build["importedStats"]>;
|
||||
build.importedStats![stat] = addStatValues(build.importedStats![stat] ?? 0, Number(raw));
|
||||
}
|
||||
} else throw new Error(`Unsupported character setting: '${statement}'.`);
|
||||
continue;
|
||||
}
|
||||
if (/^(options|target|energy)\b/i.test(statement)) {
|
||||
warnings.add("Simulation options, targets, and energy settings are not imported; add them in gcsim when running the config.");
|
||||
continue;
|
||||
}
|
||||
script.push(statement);
|
||||
}
|
||||
for (const build of builds.values()) {
|
||||
if (!weaponSeen.has(build.characterId)) throw new Error(`Missing weapon settings for '${build.characterName}'.`);
|
||||
if (build.importedSets!.reduce((sum, set) => sum + set.count, 0) > 5) throw new Error(`Artifact count exceeds five for '${build.characterName}'.`);
|
||||
const [first, second] = build.importedSets!;
|
||||
if (first) Object.assign(build, { artifactOneId: first.id, artifactOneKey: first.key, artifactOneName: first.name });
|
||||
if (second) Object.assign(build, { artifactMode: "2+2", artifactTwoId: second.id, artifactTwoKey: second.key, artifactTwoName: second.name });
|
||||
}
|
||||
const rotation = script.join("\n");
|
||||
if (rotation.length > 10000) throw new Error("Imported rotation exceeds 10,000 characters.");
|
||||
const team: Team = { id: "import-preview", name, builds: [...builds.values()], rotation: { enabled: Boolean(rotation), text: rotation, repeat: false, format: "gcsim" } };
|
||||
const rotationError = translateRotation(team).error;
|
||||
if (rotationError) throw new Error(rotationError);
|
||||
const validated = translationSchema.safeParse({ name: "Import", teams: [team] });
|
||||
if (!validated.success) throw new Error(validated.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join(" "));
|
||||
return { team: validated.data.teams[0], warnings: [...warnings] };
|
||||
} catch (error) {
|
||||
return { error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
@@ -8,3 +8,43 @@ export function gcsimKey(kind: "character" | "weapon" | "artifact", name: string
|
||||
}
|
||||
return normalizedKey(name);
|
||||
}
|
||||
|
||||
const characterAliases: Record<string, string> = {
|
||||
ayaka: "kamisatoayaka", ayato: "kamisatoayato", kazuha: "kaedeharakazuha", kaz: "kaedeharakazuha",
|
||||
raiden: "raidenshogun", baal: "raidenshogun", kokomi: "sangonomiyakokomi", sara: "kujousara",
|
||||
childe: "tartaglia", hutao: "hutao", itto: "aratakiitto", heizou: "shikanoinheizou", shinobu: "kukishinobu",
|
||||
kuki: "kukishinobu", yae: "yaemiko", miko: "yaemiko", mav: "mavuika",
|
||||
};
|
||||
|
||||
export function canonicalCharacterKey(key: string): string {
|
||||
const normalized = normalizedKey(key);
|
||||
return characterAliases[normalized] ?? normalized;
|
||||
}
|
||||
|
||||
// Documented gcsim names: https://docs.gcsim.app/reference/weapons/favoniussword/
|
||||
// https://docs.gcsim.app/reference/weapons/thrillingtalesofdragonslayers/
|
||||
const weaponAliases: Record<string, string> = {
|
||||
favsword: "favoniussword", favgs: "favoniusgreatsword", favbow: "favoniuswarbow",
|
||||
favlance: "favoniuslance", favspear: "favoniuslance", favcodex: "favoniuscodex",
|
||||
ttds: "thrillingtalesofdragonslayers",
|
||||
};
|
||||
|
||||
export function canonicalWeaponKey(key: string): string {
|
||||
const normalized = normalizedKey(key);
|
||||
return weaponAliases[normalized] ?? normalized;
|
||||
}
|
||||
|
||||
// Documented gcsim names: https://docs.gcsim.app/reference/artifacts/tenacityofthemillelith/
|
||||
// https://docs.gcsim.app/reference/artifacts/noblesseoblige/
|
||||
// https://docs.gcsim.app/reference/artifacts/scrolloftheheroofcindercity/
|
||||
const artifactAliases: Record<string, string> = {
|
||||
totm: "tenacityofthemillelith", tom: "tenacityofthemillelith",
|
||||
tenacity: "tenacityofthemillelith", millelith: "tenacityofthemillelith",
|
||||
no: "noblesseoblige", noblesse: "noblesseoblige",
|
||||
scroll: "scrolloftheheroofcindercity", sothocc: "scrolloftheheroofcindercity",
|
||||
};
|
||||
|
||||
export function canonicalArtifactKey(key: string): string {
|
||||
const normalized = normalizedKey(key);
|
||||
return artifactAliases[normalized] ?? normalized;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { generateTeam } from "./generate";
|
||||
import { translateRotation } from "./rotation";
|
||||
import { teamSchema, type Team } from "./types";
|
||||
|
||||
const build = {
|
||||
id: "mav", characterId: "mav", characterKey: "mavuika", characterName: "Mavuika", characterImageUrl: "", level: 90, maxLevel: 90, constellation: 0, talents: [9, 9, 9], weaponId: "weapon", weaponKey: "favoniusgreatsword", weaponName: "Weapon", weaponLevel: 90, weaponMaxLevel: 90, refinement: 1, artifactMode: "4pc", artifactOneId: "set", artifactOneKey: "noblesseoblige", artifactOneName: "Set", artifactTwoId: "", artifactTwoKey: "", artifactTwoName: "", sands: "atk%", goblet: "pyro%", circlet: "cr", rolls: { hp: 0, atk: 0, def: 0, em: 0, er: 0, "hp%": 0, "atk%": 0, "def%": 0, cr: 0, cd: 0 },
|
||||
} satisfies Team["builds"][number];
|
||||
const team: Team = { id: "team", name: "Team", builds: [build, { ...build, id: "xil", characterId: "xil", characterKey: "xilonen", characterName: "Xilonen" }] };
|
||||
const withRotation = (text: string, repeat = false): Team => ({ ...team, rotation: { enabled: true, text, repeat } });
|
||||
|
||||
describe("rotation translator", () => {
|
||||
it("translates the requested example", () => {
|
||||
expect(translateRotation(withRotation("Mavuika E > Xilonen E N2"))).toEqual({ code: "active mavuika;\n\nmavuika skill;\nxilonen skill, attack:2;" });
|
||||
});
|
||||
it("preserves combined actions and repeated group order", () => {
|
||||
expect(translateRotation(withRotation("mavuika Q 3(N2CD) J")).code).toContain("mavuika burst, attack:2, charge, dash, attack:2, charge, dash, attack:2, charge, dash, jump;");
|
||||
});
|
||||
it("accepts Skirk's compact repeat in the fourth rotation step", () => {
|
||||
const skirk = { ...build, id: "skirk", characterId: "skirk", characterKey: "skirk", characterName: "Skirk" };
|
||||
const rotation = { ...withRotation("Mavuika E > Xilonen E N2 > Mavuika Q > Skirk E 2N2D Q"), builds: [...team.builds, skirk] };
|
||||
expect(translateRotation(rotation).error).toBeUndefined();
|
||||
expect(translateRotation(rotation).code).toContain("skirk skill, attack:2, dash, attack:2, dash, burst;");
|
||||
});
|
||||
it.each([
|
||||
["2N2D", "2(N2D)"], ["3N2CD Q", "3(N2CD) Q"],
|
||||
["2n2d", "2(N2D)"], ["2hE J", "2(hE) J"],
|
||||
["2(3N1CJ D)", "2(3(N1CJ) D)"],
|
||||
])("interprets compact combo %s as %s", (compact, grouped) => {
|
||||
expect(translateRotation(withRotation(`Mavuika ${compact}`))).toEqual(translateRotation(withRotation(`Mavuika ${grouped}`)));
|
||||
});
|
||||
it.each(["0N2D", "2000N2D", "2N0D", "2.5N2D", "2N2DX", "2N2001D"])("rejects invalid compact combo %s", (combo) => {
|
||||
expect(translateRotation(withRotation(`Mavuika ${combo}`)).error).toBeTruthy();
|
||||
});
|
||||
it("supports tap/hold, waits, case insensitive keys and newlines", () => {
|
||||
expect(translateRotation(withRotation("MAVUIKA tE W(60) hE\nxilonen n" )).code).toBe("active mavuika;\n\nmavuika skill[hold=0];\nwait(60);\nmavuika skill[hold=1];\nxilonen attack;");
|
||||
});
|
||||
it("supports nested repetitions and loops", () => {
|
||||
expect(translateRotation(withRotation("Mavuika 2(2(N)D)", true)).code).toBe("active mavuika;\n\nwhile 1 {\n mavuika attack, attack, dash, attack, attack, dash;\n}");
|
||||
});
|
||||
it("matches multiword names before shorter names", () => {
|
||||
const named: Team = { ...withRotation("Kaedehara Kazuha E"), builds: [{ ...build, characterKey: "kazuha", characterName: "Kaedehara Kazuha" }] };
|
||||
expect(translateRotation(named).code).toContain("kazuha skill;");
|
||||
});
|
||||
it.each(["", "Mavuika", "Mavuika N0", "Mavuika 0(N)", "Mavuika 2()", "Mavuika 2(N", "Mavuika N)", "Mavuika W(0)", "Mavuika W(-1)", "Mavuika Z", "Mavuika E >", "Nahida E", "MavuikaExtra E", "Mavuika 2000(N2)", "Mavuika N2000 > Xilonen E"])("rejects invalid rotation %s", (text) => {
|
||||
expect(translateRotation(withRotation(text)).error).toBeTruthy();
|
||||
expect(translateRotation(withRotation(text)).code).toBe("");
|
||||
});
|
||||
it("enforces text and nesting limits", () => {
|
||||
expect(translateRotation(withRotation("Mavuika " + "N ".repeat(5000))).error).toContain("10,000");
|
||||
expect(translateRotation(withRotation("Mavuika " + "1(".repeat(40) + "N" + ")".repeat(40))).error).toContain("nested");
|
||||
});
|
||||
it("accepts exactly 2000 expanded actions", () => {
|
||||
expect(translateRotation(withRotation("Mavuika N2000")).error).toBeUndefined();
|
||||
});
|
||||
it("excludes disabled rotation without validating its text", () => {
|
||||
const disabled: Team = { ...withRotation("invalid"), rotation: { enabled: false, text: "invalid", repeat: true } };
|
||||
expect(translateRotation(disabled)).toEqual({ code: "" });
|
||||
expect(generateTeam(disabled)).toBe(generateTeam(team));
|
||||
});
|
||||
it("appends valid rotation and keeps build preview for invalid input", () => {
|
||||
expect(generateTeam(withRotation("Mavuika E"))).toBe(generateTeam(team) + "\n\n# Rotation\nactive mavuika;\n\nmavuika skill;");
|
||||
expect(generateTeam(withRotation("Mavuika Z"))).toBe(generateTeam(team));
|
||||
});
|
||||
it("rejects removed characters", () => {
|
||||
expect(translateRotation({ ...withRotation("Xilonen E"), builds: [build] }).error).toContain("Unknown character");
|
||||
});
|
||||
it("loads old drafts and round trips independent settings", () => {
|
||||
expect(teamSchema.parse(team).rotation).toBeUndefined();
|
||||
const saved = teamSchema.parse(JSON.parse(JSON.stringify(withRotation("Mavuika E", true))));
|
||||
expect(saved.rotation).toEqual({ enabled: true, text: "Mavuika E", repeat: true });
|
||||
expect(teamSchema.parse({ ...team, rotation: {} }).rotation).toEqual({ enabled: false, text: "", repeat: false });
|
||||
expect(team.rotation).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { Team } from "./types";
|
||||
import { splitGcsimStatements } from "./gcsim-text";
|
||||
import { canonicalCharacterKey } from "./keys";
|
||||
|
||||
export type RotationResult = { code: string; error?: never } | { code: ""; error: string };
|
||||
const MAX_ACTIONS = 2000;
|
||||
|
||||
// Keep normal counts compact in the output, but budget every expanded action.
|
||||
interface Action { code: string; count: number }
|
||||
|
||||
function parseActions(text: string): Action[] {
|
||||
let position = 0;
|
||||
const fail = (message: string): never => { throw new Error(`${message} at combo position ${position + 1}.`); };
|
||||
const count = (digits: string): number => {
|
||||
const value = Number(digits);
|
||||
if (!Number.isSafeInteger(value) || value < 1 || value > MAX_ACTIONS) fail("Count must be between 1 and 2000");
|
||||
return value;
|
||||
};
|
||||
const sequence = (nested = false, depth = 0): Action[] => {
|
||||
if (depth > 32) fail("Repeated groups are nested too deeply");
|
||||
const actions: Action[] = [];
|
||||
let total = 0;
|
||||
const append = (items: Action[], times = 1) => {
|
||||
total += items.reduce((sum, item) => sum + item.count, 0) * times;
|
||||
if (total > MAX_ACTIONS) fail("Rotation exceeds 2000 actions");
|
||||
for (let i = 0; i < times; i++) actions.push(...items);
|
||||
};
|
||||
while (position < text.length) {
|
||||
if (/\s/.test(text[position])) { position++; continue; }
|
||||
if (text[position] === ")") {
|
||||
if (!nested) fail("Unexpected closing parenthesis");
|
||||
position++;
|
||||
if (!actions.length) fail("Repeated group is empty");
|
||||
return actions;
|
||||
}
|
||||
const rest = text.slice(position);
|
||||
const group = /^(\d+)\s*\(/.exec(rest);
|
||||
if (group) {
|
||||
const times = count(group[1]);
|
||||
position += group[0].length;
|
||||
append(sequence(true, depth + 1), times);
|
||||
continue;
|
||||
}
|
||||
// A leading count repeats the entire compact combo, up to whitespace or ')'.
|
||||
// For example, 2N2D means 2(N2D), while the next spaced action runs once.
|
||||
const compact = /^(\d+)((?:[th]E|N\d*|[EQCDJ])+)(?=$|\s|\))/i.exec(rest);
|
||||
if (compact) {
|
||||
const times = count(compact[1]);
|
||||
const combo = parseActions(compact[2]);
|
||||
position += compact[0].length;
|
||||
append(combo, times);
|
||||
continue;
|
||||
}
|
||||
const wait = /^W\(\s*(\d+)\s*\)/i.exec(rest);
|
||||
if (wait) {
|
||||
const frames = Number(wait[1]);
|
||||
if (!Number.isSafeInteger(frames) || frames < 1) fail("Wait must be a positive whole number of frames");
|
||||
position += wait[0].length;
|
||||
append([{ code: `wait(${frames});`, count: 1 }]);
|
||||
continue;
|
||||
}
|
||||
const normal = /^N(\d*)/i.exec(rest);
|
||||
if (normal) {
|
||||
const n = normal[1] ? count(normal[1]) : 1;
|
||||
position += normal[0].length;
|
||||
append([{ code: n === 1 ? "attack" : `attack:${n}`, count: n }]);
|
||||
continue;
|
||||
}
|
||||
const skill = /^[th]E/i.exec(rest);
|
||||
if (skill) {
|
||||
position += 2;
|
||||
append([{ code: `skill[hold=${skill[0][0].toLowerCase() === "h" ? 1 : 0}]`, count: 1 }]);
|
||||
continue;
|
||||
}
|
||||
const simple: Record<string, string> = { E: "skill", Q: "burst", C: "charge", D: "dash", J: "jump" };
|
||||
const action = simple[text[position].toUpperCase()];
|
||||
if (!action) fail(`Unsupported token "${rest.split(/\s/)[0]}"`);
|
||||
position++;
|
||||
append([{ code: action, count: 1 }]);
|
||||
}
|
||||
if (nested) fail("Missing closing parenthesis");
|
||||
if (!actions.length) fail("Enter at least one action");
|
||||
return actions;
|
||||
};
|
||||
return sequence();
|
||||
}
|
||||
|
||||
export function translateRotation(team: Team): RotationResult {
|
||||
if (!team.rotation?.enabled) return { code: "" };
|
||||
const { text, repeat } = team.rotation;
|
||||
if (!text.trim()) return { code: "", error: "Enter a rotation or turn Include rotation off." };
|
||||
if (text.length > 10000) return { code: "", error: "Rotation exceeds 10,000 characters." };
|
||||
if (team.rotation.format === "gcsim") {
|
||||
try {
|
||||
const statements = splitGcsimStatements(text);
|
||||
if (statements.some((statement) => /^active\b/i.test(statement) && !/^active\s+[a-z]+\s*;$/i.test(statement))) return { code: "", error: "Active character must use 'active character;' with a semicolon." };
|
||||
const script = statements.join("\n").replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, '""');
|
||||
const references = [...script.matchAll(/\bactive\s+([a-z]+)\s*;|\b([a-z]+)\s+(?:attack|skill|burst|charge|aim|low_plunge|high_plunge|dash|jump|swap|walk)\b/gi)];
|
||||
for (const reference of references) {
|
||||
const key = reference[1] ?? reference[2];
|
||||
if (!team.builds.some((build) => canonicalCharacterKey(build.characterKey) === canonicalCharacterKey(key))) return { code: "", error: `Rotation character '${key}' is not in this team.` };
|
||||
}
|
||||
const active = references.some((reference) => reference[1]);
|
||||
return { code: active ? text.trim() : `active ${team.builds[0].characterKey};\n\n${text.trim()}` };
|
||||
} catch (error) { return { code: "", error: (error as Error).message }; }
|
||||
}
|
||||
const names = team.builds.flatMap((build) => [build.characterName, build.characterKey].map((name) => ({ name, key: build.characterKey }))).sort((a, b) => b.name.length - a.name.length);
|
||||
const lines: string[] = [];
|
||||
let firstCharacter = "";
|
||||
let total = 0;
|
||||
try {
|
||||
const steps = text.trim().split(/>|\r?\n/);
|
||||
steps.forEach((raw, index) => {
|
||||
const step = raw.trim();
|
||||
if (!step) throw new Error(`Step ${index + 1}: Enter a character and actions (remove empty steps).`);
|
||||
const character = names.find(({ name }) => step.slice(0, name.length).toLowerCase() === name.toLowerCase() && (step.length === name.length || /\s/.test(step[name.length])));
|
||||
if (!character) throw new Error(`Step ${index + 1}: Unknown character. Use a name or key from this team.`);
|
||||
firstCharacter ||= character.key;
|
||||
let actions: Action[];
|
||||
try { actions = parseActions(step.slice(character.name.length)); }
|
||||
catch (error) { throw new Error(`Step ${index + 1} (${character.name}): ${(error as Error).message}`); }
|
||||
total += actions.reduce((sum, action) => sum + action.count, 0);
|
||||
if (total > MAX_ACTIONS) throw new Error("Rotation exceeds 2000 actions.");
|
||||
let pending: string[] = [];
|
||||
const flush = () => { if (pending.length) lines.push(`${character.key} ${pending.join(", ")};`); pending = []; };
|
||||
for (const action of actions) {
|
||||
if (action.code.startsWith("wait(")) { flush(); lines.push(action.code); }
|
||||
else pending.push(action.code);
|
||||
}
|
||||
flush();
|
||||
});
|
||||
const body = repeat ? `while 1 {\n${lines.map((line) => ` ${line}`).join("\n")}\n}` : lines.join("\n");
|
||||
return { code: `active ${firstCharacter};\n\n${body}` };
|
||||
} catch (error) {
|
||||
return { code: "", error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@ export const mainStatKeys = [
|
||||
] as const;
|
||||
export type MainStatKey = (typeof mainStatKeys)[number];
|
||||
|
||||
export const importedStatKeys = [...rollKeys, "pyro%", "hydro%", "cryo%", "electro%", "anemo%", "geo%", "dendro%", "phys%", "heal", "atkspd%", "dmg%"] as const;
|
||||
export const importedStatsSchema = z.partialRecord(z.enum(importedStatKeys), z.number().finite());
|
||||
|
||||
export const rollsSchema = z.record(z.enum(rollKeys), z.number().int().min(0).max(99));
|
||||
|
||||
export const buildSchema = z.object({
|
||||
@@ -38,12 +41,24 @@ export const buildSchema = z.object({
|
||||
goblet: z.enum(["hp%", "atk%", "def%", "em", "pyro%", "hydro%", "cryo%", "electro%", "anemo%", "geo%", "dendro%", "phys%"]),
|
||||
circlet: z.enum(["hp%", "atk%", "def%", "em", "cr", "cd", "heal"]),
|
||||
rolls: rollsSchema,
|
||||
importedStats: importedStatsSchema.optional(),
|
||||
importedSets: z.array(z.object({
|
||||
id: z.string(), key: z.string().regex(/^[a-z]+$/), name: z.string(),
|
||||
count: z.number().int().min(1).max(5), params: z.string().max(2000).optional(),
|
||||
})).max(5).optional(),
|
||||
importedParams: z.object({ character: z.string().max(2000).optional(), weapon: z.string().max(2000).optional() }).optional(),
|
||||
});
|
||||
|
||||
export const teamSchema = z.object({
|
||||
id: z.string().max(80),
|
||||
name: z.string().trim().min(1).max(60),
|
||||
builds: z.array(buildSchema).min(1).max(4),
|
||||
rotation: z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
text: z.string().max(10000).default(""),
|
||||
repeat: z.boolean().default(false),
|
||||
format: z.enum(["shorthand", "gcsim"]).optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export const translationSchema = z.object({
|
||||
|
||||
Reference in New Issue
Block a user