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(); }); });