Show source locations for gcsim import errors
CI / verify (push) Successful in 53s
CI / build-and-deploy (push) Successful in 1m23s

This commit is contained in:
2026-09-20 01:11:27 +07:00 Unverified
parent b3bc4c3bb4
commit 55f2619ef3
4 changed files with 87 additions and 18 deletions
+2 -1
View File
@@ -16,6 +16,7 @@ export function ImportGcsimDialog({ catalog, disabled, onImport }: { catalog: Ca
const [input, setInput] = useState("");
const [name, setName] = useState("Imported team");
const preview = useMemo(() => input.trim() ? importGcsimConfig(input, catalog, name) : undefined, [input, catalog, name]);
const lineNumbers = useMemo(() => Array.from({ length: input.split("\n").length }, (_, index) => `${index + 1} |`).join("\n"), [input]);
const error = preview?.error;
return <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button variant="outline" className="self-start" disabled={disabled} />}><ImportIcon data-icon="inline-start" />Import from gcsim</DialogTrigger>
@@ -23,7 +24,7 @@ export function ImportGcsimDialog({ catalog, disabled, onImport }: { catalog: Ca
<DialogHeader><DialogTitle>Import from gcsim</DialogTitle><DialogDescription>Paste character settings, artifact stats, and rotation script. Import creates a new team.</DialogDescription></DialogHeader>
<FieldGroup>
<Field><FieldLabel htmlFor="gcsim-import-name">Team name</FieldLabel><Input id="gcsim-import-name" value={name} maxLength={60} onChange={(event) => setName(event.target.value)} /></Field>
<Field data-invalid={Boolean(error)}><FieldLabel htmlFor="gcsim-import-input">Gcsim config</FieldLabel><Textarea id="gcsim-import-input" className="min-h-64" value={input} maxLength={100000} onChange={(event) => setInput(event.target.value)} placeholder={'bennett char lvl=90/90 cons=0 talent=9,9,9;\nbennett add weapon="favoniussword" refine=1 lvl=90/90;\nbennett add set="noblesseoblige" count=4;\nbennett add stats hp=4780 atk=311 er=0.518;\n\nactive bennett;\nbennett skill, burst;'} aria-invalid={Boolean(error)} aria-describedby={`gcsim-import-help${error ? " gcsim-import-error" : ""}`} /><FieldDescription id="gcsim-import-help">Exact artifact values are preserved. Loops and action parameters stay as gcsim script. Character and equipment keys must exist in the catalog.</FieldDescription>{error && <FieldError id="gcsim-import-error">{error}</FieldError>}</Field>
<Field data-invalid={Boolean(error)}><FieldLabel htmlFor="gcsim-import-input">Gcsim config</FieldLabel><div className="grid grid-cols-[auto_minmax(0,1fr)]"><pre aria-hidden="true" className="select-none py-3 pr-3 text-right font-mono text-sm leading-6 text-muted-foreground">{lineNumbers}</pre><Textarea id="gcsim-import-input" className="min-h-64 font-mono leading-6" wrap="off" value={input} maxLength={100000} onChange={(event) => setInput(event.target.value)} placeholder={'bennett char lvl=90/90 cons=0 talent=9,9,9;\nbennett add weapon="favoniussword" refine=1 lvl=90/90;\nbennett add set="noblesseoblige" count=4;\nbennett add stats hp=4780 atk=311 er=0.518;\n\nactive bennett;\nbennett skill, burst;'} aria-invalid={Boolean(error)} aria-describedby={`gcsim-import-help${error ? " gcsim-import-error" : ""}`} /></div><FieldDescription id="gcsim-import-help">Exact artifact values are preserved. Loops and action parameters stay as gcsim script. Character and equipment keys must exist in the catalog.</FieldDescription>{error && <FieldError id="gcsim-import-error"><span>{error}{preview.errorLine ? ` (line ${preview.errorLine})` : ""}</span>{preview.errorSource && <code className="block whitespace-pre-wrap break-words">{preview.errorLine} | {preview.errorRange ? <>{preview.errorSource.slice(0, preview.errorRange.start)}<u>{preview.errorSource.slice(preview.errorRange.start, preview.errorRange.end)}</u>{preview.errorSource.slice(preview.errorRange.end)}</> : preview.errorSource}</code>}</FieldError>}</Field>
{preview?.team && <Alert><AlertTitle>Ready to import</AlertTitle><AlertDescription>{preview.team.builds.map((build) => build.characterName).join(", ")} · {preview.team.builds.length} character{preview.team.builds.length === 1 ? "" : "s"} · {preview.team.rotation?.enabled ? "Rotation included" : "No rotation"}</AlertDescription></Alert>}
{preview?.warnings?.map((warning) => <Alert key={warning}><AlertTitle>Import note</AlertTitle><AlertDescription>{warning}</AlertDescription></Alert>)}
</FieldGroup>
+47 -11
View File
@@ -1,14 +1,38 @@
export type GcsimStatement = { text: string; line: number; source: string };
export class GcsimTextError extends Error {
constructor(message: string, public line: number, public source: string) {
super(message);
}
}
function lineDetails(input: string, position: number) {
const line = input.slice(0, position).split("\n").length;
return { line, source: input.split(/\r?\n/)[line - 1]?.trim() ?? "" };
}
// Split only at top level: loops, conditionals, functions and action parameters
// must stay intact. Comments are removed without touching quoted strings.
export function splitGcsimStatements(input: string): string[] {
const statements: string[] = [];
export function splitGcsimStatementsWithLocations(input: string): GcsimStatement[] {
const originalInput = input;
const statements: GcsimStatement[] = [];
const stack: string[] = [];
// Replacing comments up front also lets a comment appear before an else.
input = input.replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|#[^\n]*|\/\/[^\n]*/g, (match) => match.startsWith("#") || match.startsWith("//") ? " " : match);
input = input.replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|#[^\n]*|\/\/[^\n]*/g, (match) => match.startsWith("#") || match.startsWith("//") ? " ".repeat(match.length) : match);
let text = "";
let start = 0;
let quote = "";
let escaped = false;
const flush = () => { if (text.trim()) statements.push(text.trim()); text = ""; };
const flush = (end: number) => {
const leadingWhitespace = text.search(/\S/);
if (leadingWhitespace >= 0) {
const position = start + leadingWhitespace;
const { line, source } = lineDetails(originalInput, position);
statements.push({ text: text.trim(), line, source });
}
text = "";
start = end;
};
for (let i = 0; i < input.length; i++) {
const char = input[i];
if (quote) {
@@ -21,18 +45,30 @@ export function splitGcsimStatements(input: string): string[] {
if (char === '"' || char === "'") { quote = char; text += char; continue; }
if ("([{".includes(char)) {
stack.push(char);
if (stack.length > 64) throw new Error("Config is nested too deeply.");
if (stack.length > 64) {
const details = lineDetails(originalInput, i);
throw new GcsimTextError("Config is nested too deeply.", details.line, details.source);
}
} else if (")]}".includes(char)) {
const expected: Record<string, string> = { ")": "(", "]": "[", "}": "{" };
if (stack.pop() !== expected[char]) throw new Error(`Unmatched '${char}' at position ${i + 1}.`);
if (stack.pop() !== expected[char]) {
const details = lineDetails(originalInput, i);
throw new GcsimTextError(`Unmatched '${char}' at position ${i + 1}.`, details.line, details.source);
}
}
text += char;
if (stack.length === 0 && char === ";") flush();
if (stack.length === 0 && char === ";") flush(i + 1);
// Keep an else branch attached to its if statement.
if (stack.length === 0 && char === "}" && !/^\s*else\b/.test(input.slice(i + 1))) flush();
if (stack.length === 0 && char === "}" && !/^\s*else\b/.test(input.slice(i + 1))) flush(i + 1);
}
if (quote) throw new Error("Unclosed quoted string.");
if (stack.length) throw new Error("Unclosed bracket or parenthesis.");
if (text.trim()) throw new Error("Missing ';' at the end of a statement.");
const trailingPosition = start + Math.max(0, text.search(/\S/));
const trailing = lineDetails(originalInput, trailingPosition);
if (quote) throw new GcsimTextError("Unclosed quoted string.", trailing.line, trailing.source);
if (stack.length) throw new GcsimTextError("Unclosed bracket or parenthesis.", trailing.line, trailing.source);
if (text.trim()) throw new GcsimTextError("Missing ';' at the end of a statement.", trailing.line, trailing.source);
return statements;
}
export function splitGcsimStatements(input: string): string[] {
return splitGcsimStatementsWithLocations(input).map((statement) => statement.text);
}
+12
View File
@@ -214,6 +214,18 @@ describe("gcsim import", () => {
expect(result.team).toBeUndefined();
expect(result.error).toContain(expected);
});
it("reports the source line for duplicate stats", () => {
const input = config.replace("hp=717 atk%=0.1234567 cr=0.128 cd=0.265", "hp=717 def%=0.1 def%=0.2");
const result = importGcsimConfig(input, catalog);
const expectedLine = input.split("\n").findIndex((line) => line.includes("def%=0.1")) + 1;
expect(result).toMatchObject({
error: "Duplicate build setting 'def%'.",
errorLine: expectedLine,
});
expect(result.errorSource).toContain("def%=0.1 def%=0.2");
expect(result.errorSource?.slice(result.errorRange?.start, result.errorRange?.end)).toBe("def%");
expect(result.errorRange?.start).toBe(result.errorSource?.lastIndexOf("def%"));
});
it("limits characters and input size", () => {
const declarations = catalog.characters.map((char) => `${char.key} char lvl=90/90 cons=0 talent=9,9,9;`).join("\n");
expect(importGcsimConfig(declarations, catalog).error).toContain("four");
+26 -6
View File
@@ -1,11 +1,22 @@
import { createBuild } from "./build";
import { splitGcsimStatements } from "./gcsim-text";
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";
export type ImportResult = { team: Team; warnings: string[]; error?: never } | { error: string; team?: never; warnings?: never };
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);
@@ -64,14 +75,17 @@ function addStatValues(left: number, right: number): number {
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 = splitGcsimStatements(input);
const statements = splitGcsimStatementsWithLocations(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) {
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();
@@ -86,6 +100,7 @@ export function importGcsimConfig(input: string, catalog: Catalog, name = "Impor
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");
@@ -93,7 +108,9 @@ export function importGcsimConfig(input: string, catalog: Catalog, name = "Impor
if (!build) throw new Error(`Character '${key}' has settings but no character declaration.`);
return build;
};
for (const statement of statements) {
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) {
@@ -130,6 +147,7 @@ export function importGcsimConfig(input: string, catalog: Catalog, name = "Impor
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}'.`);
@@ -146,6 +164,8 @@ export function importGcsimConfig(input: string, catalog: Catalog, name = "Impor
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 };
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) };
}
}