feat: add traveler rotation shortcut
This commit is contained in:
@@ -27,6 +27,8 @@ 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.
|
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.
|
||||||
|
|
||||||
|
When the team contains one Traveler, use `mc` as their character name in shorthand rotations for any element. Generated configs use the Traveler's element-specific gcsim key. The shortcut does not apply to imported gcsim scripts.
|
||||||
|
|
||||||
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. Enabling rotation automatically includes the fixed action-delay helper above the character builds, then these fixed environment settings below all character stats:
|
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. Enabling rotation automatically includes the fixed action-delay helper above the character builds, then these fixed environment settings below all character stats:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ function RotationEditor({ team, onChange }: { team: Team; onChange: (team: Team)
|
|||||||
<CardContent><FieldGroup>
|
<CardContent><FieldGroup>
|
||||||
<Field><Toggle variant="outline" pressed={rotation.enabled} onPressedChange={(enabled) => update({ enabled })} aria-controls={`${inputId}-controls`}>Include rotation</Toggle><FieldDescription>{rotation.enabled ? "Includes rotation, the fixed delay helper, target, and energy settings." : "Off: only character builds are included."}</FieldDescription></Field>
|
<Field><Toggle variant="outline" pressed={rotation.enabled} onPressedChange={(enabled) => update({ enabled })} aria-controls={`${inputId}-controls`}>Include rotation</Toggle><FieldDescription>{rotation.enabled ? "Includes rotation, the fixed delay helper, target, and energy settings." : "Off: only character builds are included."}</FieldDescription></Field>
|
||||||
{rotation.enabled && <FieldGroup id={`${inputId}-controls`}>
|
{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>
|
<Field data-invalid={Boolean(error)}><FieldLabel htmlFor={inputId}>{rotation.format === "gcsim" ? "Imported gcsim rotation" : "Rotation"}</FieldLabel><Textarea id={inputId} value={rotation.text} maxLength={10000} onChange={(event) => update({ text: event.target.value })} placeholder={rotation.format === "gcsim" ? "mavuika skill;" : "Mavuika E > Xilonen E N2"} aria-invalid={Boolean(error)} aria-describedby={`${inputId}-help${error ? ` ${inputId}-error` : ""}`} /><FieldDescription id={`${inputId}-help`}>{rotation.format === "gcsim" ? "Edit the imported gcsim script directly. Brackets are checked here; full script validation happens in gcsim." : <>Use names or keys from this team: {team.builds.map((build) => build.characterName).join(", ")}. Use mc for Traveler. Separate character steps with > or a new line.</>}</FieldDescription>{error && <FieldError id={`${inputId}-error`}>{error}</FieldError>}</Field>
|
||||||
{rotation.format === "gcsim" ? <Field><Button variant="outline" onClick={() => update({ format: "shorthand", text: "", repeat: false })}>Clear script and use shorthand</Button></Field> : <><Field><FieldDescription>E = skill · Q = burst · N2 = 2 normals · C = charged attack · D = dash · J = jump. tE / hE = tap / hold skill. W(60) = wait 60 frames. Combine actions: N2CD. Repeat a combo: 3(N2CD).</FieldDescription></Field>
|
{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></>}
|
<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>}
|
||||||
|
|||||||
@@ -43,6 +43,26 @@ describe("rotation translator", () => {
|
|||||||
const named: Team = { ...withRotation("Kaedehara Kazuha E"), builds: [{ ...build, characterKey: "kazuha", characterName: "Kaedehara Kazuha" }] };
|
const named: Team = { ...withRotation("Kaedehara Kazuha E"), builds: [{ ...build, characterKey: "kazuha", characterName: "Kaedehara Kazuha" }] };
|
||||||
expect(translateRotation(named).code).toContain("kazuha skill;");
|
expect(translateRotation(named).code).toContain("kazuha skill;");
|
||||||
});
|
});
|
||||||
|
it.each([
|
||||||
|
["traveler_anemo", "aetheranemo", "mc"],
|
||||||
|
["traveler_dendro", "aetherdendro", "MC"],
|
||||||
|
["traveler_cryo", "aethercryo", "Mc"],
|
||||||
|
])("resolves %s from the %s shortcut", (characterId, characterKey, shortcut) => {
|
||||||
|
const traveler = { ...build, id: characterId, characterId, characterKey, characterName: `${characterKey.slice(6)} Traveler` };
|
||||||
|
const result = translateRotation({ ...withRotation(`${shortcut} E`), builds: [traveler] });
|
||||||
|
expect(result).toEqual({ code: `active ${characterKey};\n\n${characterKey} skill;` });
|
||||||
|
});
|
||||||
|
it("only accepts mc when exactly one Traveler is in the shorthand team", () => {
|
||||||
|
expect(translateRotation(withRotation("mc E")).error).toContain("Unknown character");
|
||||||
|
const anemo = { ...build, id: "anemo", characterId: "traveler_anemo", characterKey: "aetheranemo", characterName: "Anemo Traveler" };
|
||||||
|
const dendro = { ...build, id: "dendro", characterId: "traveler_dendro", characterKey: "aetherdendro", characterName: "Dendro Traveler" };
|
||||||
|
expect(translateRotation({ ...withRotation("mc E"), builds: [anemo, dendro] }).error).toContain("ambiguous");
|
||||||
|
});
|
||||||
|
it("does not apply the mc shortcut to imported gcsim rotations", () => {
|
||||||
|
const traveler = { ...build, characterId: "traveler_dendro", characterKey: "aetherdendro", characterName: "Dendro Traveler" };
|
||||||
|
const imported: Team = { ...withRotation("mc skill;"), builds: [traveler], rotation: { enabled: true, text: "mc skill;", repeat: false, format: "gcsim" } };
|
||||||
|
expect(translateRotation(imported).error).toContain("not in this team");
|
||||||
|
});
|
||||||
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) => {
|
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)).error).toBeTruthy();
|
||||||
expect(translateRotation(withRotation(text)).code).toBe("");
|
expect(translateRotation(withRotation(text)).code).toBe("");
|
||||||
|
|||||||
@@ -107,7 +107,10 @@ export function translateRotation(team: Team): RotationResult {
|
|||||||
return { code: active ? body : `active ${team.builds[0].characterKey};\n\n${body}` };
|
return { code: active ? body : `active ${team.builds[0].characterKey};\n\n${body}` };
|
||||||
} catch (error) { return { code: "", error: (error as Error).message }; }
|
} 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 travelers = team.builds.filter((build) => build.characterId.startsWith("traveler_"));
|
||||||
|
const names = team.builds.flatMap((build) => [build.characterName, build.characterKey].map((name) => ({ name, key: build.characterKey })));
|
||||||
|
if (travelers.length === 1) names.push({ name: "mc", key: travelers[0].characterKey });
|
||||||
|
names.sort((a, b) => b.name.length - a.name.length);
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
let firstCharacter = "";
|
let firstCharacter = "";
|
||||||
let total = 0;
|
let total = 0;
|
||||||
@@ -116,6 +119,7 @@ export function translateRotation(team: Team): RotationResult {
|
|||||||
steps.forEach((raw, index) => {
|
steps.forEach((raw, index) => {
|
||||||
const step = raw.trim();
|
const step = raw.trim();
|
||||||
if (!step) throw new Error(`Step ${index + 1}: Enter a character and actions (remove empty steps).`);
|
if (!step) throw new Error(`Step ${index + 1}: Enter a character and actions (remove empty steps).`);
|
||||||
|
if (travelers.length > 1 && /^mc(?:\s|$)/i.test(step)) throw new Error(`Step ${index + 1}: 'mc' is ambiguous because this team has multiple Travelers. Use a Traveler name or key.`);
|
||||||
const character = names.find(({ name }) => step.slice(0, name.length).toLowerCase() === name.toLowerCase() && (step.length === name.length || /\s/.test(step[name.length])));
|
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.`);
|
if (!character) throw new Error(`Step ${index + 1}: Unknown character. Use a name or key from this team.`);
|
||||||
firstCharacter ||= character.key;
|
firstCharacter ||= character.key;
|
||||||
|
|||||||
Reference in New Issue
Block a user