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 = { 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 }; } }