import { createBuild } from "./build"; import { GcsimTextError, splitGcsimStatementsWithLocations, type GcsimStatement } from "./gcsim-text"; import { translateRotation } from "./rotation"; import { isRotationDefaultStatement } from "./rotation-defaults"; import { canonicalArtifactKey, canonicalCharacterKey, canonicalWeaponKey } from "./keys"; import { importedStatKeys, translationSchema, type Build, type Catalog, type CatalogItem, type Team } from "./types"; type ErrorRange = { start: number; end: number }; export type ImportResult = { team: Team; warnings: string[]; error?: never; errorLine?: never; errorSource?: never; errorRange?: never } | { error: string; errorLine?: number; errorSource?: string; errorRange?: ErrorRange; team?: never; warnings?: never }; function findErrorRange(message: string, source?: string): ErrorRange | undefined { if (!source) return; const token = /'([^']+)'/.exec(message)?.[1]; if (!token) return; const normalizedSource = source.toLowerCase(); const normalizedToken = token.toLowerCase(); const start = message.startsWith("Duplicate ") ? normalizedSource.lastIndexOf(normalizedToken) : normalizedSource.indexOf(normalizedToken); return start >= 0 ? { start, end: start + token.length } : undefined; } 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; 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 = {}; 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, 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." }; let currentStatement: GcsimStatement | undefined; try { const statements = splitGcsimStatementsWithLocations(input); const builds = new Map(); const ids = new Set(); const weaponSeen = new Set(); const warnings = new Set(); const script: string[] = []; for (const locatedStatement of statements) { currentStatement = locatedStatement; const statement = locatedStatement.text; 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 }); } currentStatement = 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 locatedStatement of statements) { currentStatement = locatedStatement; const statement = locatedStatement.text; 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![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. With rotation enabled, output uses the fixed target and energy defaults."); continue; } if (isRotationDefaultStatement(statement)) continue; script.push(statement); } currentStatement = undefined; 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) { if (error instanceof GcsimTextError) return { error: error.message, errorLine: error.line, errorSource: error.source, errorRange: findErrorRange(error.message, error.source) }; const message = (error as Error).message; return { error: message, errorLine: currentStatement?.line, errorSource: currentStatement?.source, errorRange: findErrorRange(message, currentStatement?.source) }; } }