236 lines
16 KiB
TypeScript
236 lines
16 KiB
TypeScript
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();
|
|
});
|
|
});
|