feat : add import from gcsim and roation translater
This commit is contained in:
@@ -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