Files
gunshiz 3fe17a2ff7
CI / verify (push) Successful in 2m10s
CI / build-and-deploy (push) Successful in 2m14s
feat: add traveler rotation shortcut
2026-09-20 00:00:30 +07:00

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