Files
gcsim-translater/lib/translator/import.ts
T
gunshiz 73efb513d2
CI / verify (push) Successful in 42s
CI / build-and-deploy (push) Successful in 1m57s
feat : add import from gcsim and roation translater
2026-09-13 15:22:31 +07:00

150 lines
9.3 KiB
TypeScript

import { createBuild } from "./build";
import { splitGcsimStatements } from "./gcsim-text";
import { translateRotation } from "./rotation";
import { canonicalArtifactKey, canonicalCharacterKey, canonicalWeaponKey } from "./keys";
import { importedStatKeys, translationSchema, type Build, type Catalog, type CatalogItem, type Team } from "./types";
export type ImportResult = { team: Team; warnings: string[]; error?: never } | { error: string; team?: never; warnings?: never };
function findItem(items: CatalogItem[], key: string, kind: string): CatalogItem {
const canonicalize = kind === "character" ? canonicalCharacterKey : kind === "weapon" ? canonicalWeaponKey : canonicalArtifactKey;
const canonical = canonicalize(key);
const item = items.find((item) => [item.key, item.name].some((candidate) => canonicalize(candidate) === canonical));
if (!item) throw new Error(`Unknown ${kind} '${key}' in the current catalog.`);
return item;
}
function attributes(text: string, allowed: string[]): { values: Record<string, string>; params?: string } {
let params: string | undefined;
const parameterMatch = /\s*\+params\s*=\s*(\[[\s\S]*\])\s*$/.exec(text);
if (parameterMatch) {
params = parameterMatch[1];
if (params.length > 2000) throw new Error("Parameters exceed 2000 characters.");
const body = params.slice(1, -1).trim();
if (body && !/^(?:[a-zA-Z][\w]*\s*=\s*[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?\s*)+$/.test(body)) throw new Error("Only numeric +params values are supported for builds.");
text = text.slice(0, parameterMatch.index);
}
const values: Record<string, string> = {};
let remaining = text.trim();
while (remaining) {
const match = /^([a-zA-Z][\w%]*)\s*=\s*("[^"\n]*"|[^\s]+)(?:\s+|$)/.exec(remaining);
if (!match) throw new Error(`Could not read build settings near '${remaining.slice(0, 40)}'.`);
const key = match[1].toLowerCase();
if (!allowed.includes(key)) throw new Error(`Unsupported build setting '${key}'.`);
if (key in values) throw new Error(`Duplicate build setting '${key}'.`);
values[key] = match[2].replace(/^"|"$/g, "");
remaining = remaining.slice(match[0].length).trim();
}
return { values, params };
}
function required(values: Record<string, string>, key: string): string {
if (!(key in values)) throw new Error(`Missing '${key}' in build settings.`);
return values[key];
}
function level(value: string): [number, number] {
const match = /^(\d+)\/(\d+)$/.exec(value);
if (!match) throw new Error("Levels must use level/cap, for example 90/90.");
return [Number(match[1]), Number(match[2])];
}
// Add decimal values without introducing binary rounding noise in exported stats.
function addStatValues(left: number, right: number): number {
const decimal = (value: number) => {
const [mantissa, exponent = "0"] = value.toString().split("e");
return { amount: BigInt(mantissa.replace(".", "")), scale: (mantissa.split(".")[1]?.length ?? 0) - Number(exponent) };
};
const a = decimal(left), b = decimal(right);
const scale = Math.max(a.scale, b.scale);
const sum = a.amount * BigInt(10) ** BigInt(scale - a.scale) + b.amount * BigInt(10) ** BigInt(scale - b.scale);
return Number(`${sum}e${-scale}`);
}
export function importGcsimConfig(input: string, catalog: Catalog, name = "Imported team"): ImportResult {
if (!input.trim()) return { error: "Paste a gcsim config to import." };
if (input.length > 100000) return { error: "Config exceeds 100,000 characters." };
try {
const statements = splitGcsimStatements(input);
const builds = new Map<string, Build>();
const ids = new Set<string>();
const weaponSeen = new Set<string>();
const warnings = new Set<string>();
const script: string[] = [];
for (const statement of statements) {
const match = /^([a-z]+)\s+char\s+([\s\S]*);$/i.exec(statement);
if (!match) continue;
const key = match[1].toLowerCase();
const character = findItem(catalog.characters, key, "character");
if (ids.has(character.id)) throw new Error(`Character '${character.name}' appears more than once.`);
ids.add(character.id);
if (ids.size > 4) throw new Error("Import supports at most four characters per team.");
const { values, params } = attributes(match[2], ["lvl", "cons", "talent"]);
const build = createBuild(catalog, character, `import-build-${builds.size + 1}`);
const [lvl, cap] = level(required(values, "lvl"));
const talents = required(values, "talent");
if (!/^\d+,\d+,\d+$/.test(talents)) throw new Error(`Invalid talent levels for '${key}'.`);
builds.set(key, { ...build, characterKey: key, level: lvl, maxLevel: cap, constellation: Number(required(values, "cons")), talents: talents.split(",").map(Number) as Build["talents"], importedStats: {}, importedSets: [], importedParams: params ? { character: params } : undefined });
}
if (!builds.size) throw new Error("No character declarations found. Include lines such as 'bennett char lvl=90/90 cons=0 talent=9,9,9;'.");
const resolveBuild = (key: string): Build => {
const item = findItem(catalog.characters, key, "character");
const build = [...builds.values()].find((build) => build.characterId === item.id);
if (!build) throw new Error(`Character '${key}' has settings but no character declaration.`);
return build;
};
for (const statement of statements) {
if (/^[a-z]+\s+char\b/i.test(statement)) continue;
const setting = /^([a-z]+)\s+add\s+([\s\S]*);$/i.exec(statement);
if (setting) {
const build = resolveBuild(setting[1]);
const body = setting[2];
if (/^weapon\s*=/i.test(body)) {
if (weaponSeen.has(build.characterId)) throw new Error(`More than one weapon specified for '${build.characterName}'.`);
weaponSeen.add(build.characterId);
const { values, params } = attributes(body, ["weapon", "refine", "lvl"]);
const weapon = findItem(catalog.weapons, required(values, "weapon"), "weapon");
const character = catalog.characters.find((item) => item.id === build.characterId)!;
if (character.weaponType && weapon.weaponType && character.weaponType !== weapon.weaponType) throw new Error(`${weapon.name} is not compatible with ${character.name}.`);
const [lvl, cap] = level(required(values, "lvl"));
Object.assign(build, { weaponId: weapon.id, weaponKey: weapon.key, weaponName: weapon.name, weaponLevel: lvl, weaponMaxLevel: cap, refinement: Number(required(values, "refine")), importedParams: { ...build.importedParams, weapon: params } });
} else if (/^set\s*=/i.test(body)) {
const { values, params } = attributes(body, ["set", "count"]);
const artifact = findItem(catalog.artifacts, required(values, "set"), "artifact set");
build.importedSets!.push({ id: artifact.id, key: artifact.key, name: artifact.name, count: Number(required(values, "count")), params });
} else if (/^stats\b/i.test(body)) {
const { values, params } = attributes(body.replace(/^stats\s*/i, ""), [...importedStatKeys]);
if (params) throw new Error("+params is not supported on stats lines.");
for (const [key, raw] of Object.entries(values)) {
if (!/^[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?$/.test(raw) || !Number.isFinite(Number(raw))) throw new Error(`Invalid value '${raw}' for stat '${key}'.`);
const stat = key as keyof NonNullable<Build["importedStats"]>;
build.importedStats![stat] = addStatValues(build.importedStats![stat] ?? 0, Number(raw));
}
} else throw new Error(`Unsupported character setting: '${statement}'.`);
continue;
}
if (/^(options|target|energy)\b/i.test(statement)) {
warnings.add("Simulation options, targets, and energy settings are not imported; add them in gcsim when running the config.");
continue;
}
script.push(statement);
}
for (const build of builds.values()) {
if (!weaponSeen.has(build.characterId)) throw new Error(`Missing weapon settings for '${build.characterName}'.`);
if (build.importedSets!.reduce((sum, set) => sum + set.count, 0) > 5) throw new Error(`Artifact count exceeds five for '${build.characterName}'.`);
const [first, second] = build.importedSets!;
if (first) Object.assign(build, { artifactOneId: first.id, artifactOneKey: first.key, artifactOneName: first.name });
if (second) Object.assign(build, { artifactMode: "2+2", artifactTwoId: second.id, artifactTwoKey: second.key, artifactTwoName: second.name });
}
const rotation = script.join("\n");
if (rotation.length > 10000) throw new Error("Imported rotation exceeds 10,000 characters.");
const team: Team = { id: "import-preview", name, builds: [...builds.values()], rotation: { enabled: Boolean(rotation), text: rotation, repeat: false, format: "gcsim" } };
const rotationError = translateRotation(team).error;
if (rotationError) throw new Error(rotationError);
const validated = translationSchema.safeParse({ name: "Import", teams: [team] });
if (!validated.success) throw new Error(validated.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join(" "));
return { team: validated.data.teams[0], warnings: [...warnings] };
} catch (error) {
return { error: (error as Error).message };
}
}