145 lines
7.3 KiB
TypeScript
145 lines
7.3 KiB
TypeScript
import type { Team } from "./types";
|
|
import { splitGcsimStatements } from "./gcsim-text";
|
|
import { canonicalCharacterKey } from "./keys";
|
|
import { isRotationDefaultStatement } from "./rotation-defaults";
|
|
|
|
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).filter((statement) => !isRotationDefaultStatement(statement));
|
|
if (!statements.length) return { code: "", error: "Enter rotation actions or turn Include rotation off." };
|
|
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]);
|
|
const body = statements.join("\n");
|
|
return { code: active ? body : `active ${team.builds[0].characterKey};\n\n${body}` };
|
|
} catch (error) { return { code: "", error: (error as Error).message }; }
|
|
}
|
|
const travelers = team.builds.filter((build) => build.characterId.startsWith("traveler_"));
|
|
const names = team.builds.flatMap((build) => [build.characterName, build.characterKey].map((name) => ({ name, key: build.characterKey })));
|
|
if (travelers.length === 1) names.push({ name: "mc", key: travelers[0].characterKey });
|
|
names.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).`);
|
|
if (travelers.length > 1 && /^mc(?:\s|$)/i.test(step)) throw new Error(`Step ${index + 1}: 'mc' is ambiguous because this team has multiple Travelers. Use a Traveler name or key.`);
|
|
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 };
|
|
}
|
|
}
|