feat : add import from gcsim and roation translater
CI / verify (push) Successful in 42s
CI / build-and-deploy (push) Successful in 1m57s

This commit is contained in:
2026-09-13 15:22:31 +07:00 Unverified
parent 7cfdfee62e
commit 73efb513d2
14 changed files with 888 additions and 26 deletions
+9
View File
@@ -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 } };
}
+7 -3
View File
@@ -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");
+38
View File
@@ -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;
}
+12 -6
View File
@@ -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;
}
+235
View File
@@ -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();
});
});
+149
View File
@@ -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 };
}
}
+40
View File
@@ -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;
}
+75
View File
@@ -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();
});
});
+137
View File
@@ -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 };
}
}
+15
View File
@@ -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({