91b878f347
- Added 6 Quaternius weapon models (Pistol, Revolver, Rifle, Shotgun, P90, SniperRifle) - Created weapon resource files with balanced stats - Wired Pistol + Rifle into player.tscn replacing Kenney blasters - Fixed WASD input (matched input map action names) - Fixed Escape key toggles mouse capture - Added Audio autoload singleton - Fixed broken sound asset paths across all scripts - Fixed all scene ext_resource text paths (nested under assets/kenney-fps/) - Fixed all .import source_file paths - Deleted stale .import files (will be regenerated by editor on open)
375 lines
17 KiB
JavaScript
375 lines
17 KiB
JavaScript
import { join } from 'path';
|
|
import { existsSync, writeFileSync, unlinkSync, mkdirSync } from 'fs';
|
|
import { randomUUID } from 'crypto';
|
|
import { normalizeParameters, validateSubPath, validateProjectArgs, createErrorResponse, extractGdError, getErrorMessage, } from '../utils/godot-runner.js';
|
|
export const validateToolDefinitions = [
|
|
{
|
|
name: 'validate',
|
|
description: "Validate GDScript syntax or scene file integrity using headless Godot. Use before attach_script or run_script to catch parse errors early. Single-target: provide exactly one of scriptPath, source, or scenePath. Batch: provide a targets array — runs all in one Godot process. Returns { valid, errors: [{ line?, message }] } for single, or { results: [{ target, valid, errors }] } for batch. Line numbers appear when Godot's stderr includes them (not always). Returns valid:false on any parse error; never throws.",
|
|
annotations: { readOnlyHint: true },
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
projectPath: {
|
|
type: 'string',
|
|
description: 'Path to the Godot project directory',
|
|
},
|
|
scriptPath: {
|
|
type: 'string',
|
|
description: '[single] Path to a .gd file relative to the project to validate (e.g. "scripts/player.gd")',
|
|
},
|
|
source: {
|
|
type: 'string',
|
|
description: '[single] Inline GDScript source code to validate. Written to a temporary file and validated against the project.',
|
|
},
|
|
scenePath: {
|
|
type: 'string',
|
|
description: '[single] Path to a .tscn scene file relative to the project to validate (e.g. "scenes/main.tscn")',
|
|
},
|
|
targets: {
|
|
type: 'array',
|
|
description: '[batch] Array of targets to validate in a single Godot process. Each item must have exactly one of: scriptPath, source, or scenePath.',
|
|
items: {
|
|
type: 'object',
|
|
properties: {
|
|
scriptPath: {
|
|
type: 'string',
|
|
description: 'Path to a .gd file relative to the project',
|
|
},
|
|
source: { type: 'string', description: 'Inline GDScript source code' },
|
|
scenePath: {
|
|
type: 'string',
|
|
description: 'Path to a .tscn file relative to the project',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
required: ['projectPath'],
|
|
},
|
|
},
|
|
];
|
|
/**
|
|
* Core Godot stderr parser. Returns a flat list of error entries, each with an
|
|
* optional line number and optional res:// file path (from the "at:" line).
|
|
*/
|
|
function parseGodotErrorEntries(stderr) {
|
|
const entries = [];
|
|
if (!stderr)
|
|
return entries;
|
|
const lines = stderr.split('\n');
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
// Pattern: "SCRIPT ERROR: Parse Error: MESSAGE" or "ERROR: MESSAGE"
|
|
// followed by " at: res://...:LINE" or " at: ...:LINE"
|
|
const scriptErrorMatch = line.match(/SCRIPT ERROR:\s*(?:Parse Error:\s*)?(.+)/);
|
|
const errorMatch = !scriptErrorMatch ? line.match(/^ERROR:\s*(.+)/) : null;
|
|
const match = scriptErrorMatch || errorMatch;
|
|
if (match) {
|
|
const message = match[1].trim();
|
|
let lineNum;
|
|
let filePath;
|
|
if (i + 1 < lines.length) {
|
|
// Try res:// path first (captures file + line). Tolerates an optional
|
|
// "<method> (" prefix before res:// so we catch both
|
|
// " at: res://foo.gd:3"
|
|
// and
|
|
// " at: GDScript::reload (res://foo.gd:3)"
|
|
// Real Godot 4.5 stderr uses the parenthesized form.
|
|
const resAtMatch = lines[i + 1].match(/\s*at:\s*(?:[^()\n]*\()?(res:\/\/[^):"\s]+):(\d+)/);
|
|
if (resAtMatch) {
|
|
filePath = resAtMatch[1];
|
|
lineNum = parseInt(resAtMatch[2], 10);
|
|
i++;
|
|
}
|
|
else {
|
|
// Fall back to loose match (line only, e.g. native code "at:" lines)
|
|
const looseAtMatch = lines[i + 1].match(/\s*at:\s*.+:(\d+)/);
|
|
if (looseAtMatch) {
|
|
lineNum = parseInt(looseAtMatch[1], 10);
|
|
i++;
|
|
}
|
|
}
|
|
}
|
|
// Parse-error entries reference synthetic gdscript:// URIs in their `at:` line
|
|
// rather than a res:// path. Peek forward up to 10 lines for the secondary
|
|
// "Failed to load script/resource: \"res://...\"" message that names the file,
|
|
// and adopt that path so batch error attribution can find it. The window is
|
|
// intentionally wide enough to clear a full GDScript backtrace.
|
|
if (!filePath && /Parse Error/i.test(line)) {
|
|
const lookaheadLimit = Math.min(i + 11, lines.length);
|
|
for (let j = i + 1; j < lookaheadLimit; j++) {
|
|
const failMatch = lines[j].match(/Failed to load (?:script|resource):?\s*"?(res:\/\/[^":\s]+)/);
|
|
if (failMatch) {
|
|
filePath = failMatch[1];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
entries.push({ message, line: lineNum, filePath });
|
|
continue;
|
|
}
|
|
// Pattern: "Parse Error: MESSAGE at line LINE"
|
|
const parseErrorMatch = line.match(/Parse Error:\s*(.+?)\s+at line\s+(\d+)/);
|
|
if (parseErrorMatch) {
|
|
entries.push({
|
|
line: parseInt(parseErrorMatch[2], 10),
|
|
message: parseErrorMatch[1].trim(),
|
|
});
|
|
}
|
|
}
|
|
return entries;
|
|
}
|
|
function parseGodotErrors(stderr) {
|
|
return parseGodotErrorEntries(stderr).map(({ message, line }) => ({ message, line }));
|
|
}
|
|
/**
|
|
* Write inline GDScript source to a uniquely-named file under <projectPath>/.mcp/
|
|
* for validation. Returns the project-relative path (e.g. ".mcp/validate_temp_xxx.gd")
|
|
* that the runner consumes plus the absolute path the caller cleans up.
|
|
*/
|
|
function writeTempGdScript(projectPath, source, prefix) {
|
|
const mcpDir = join(projectPath, '.mcp');
|
|
mkdirSync(mcpDir, { recursive: true });
|
|
const name = `${prefix}_${randomUUID()}.gd`;
|
|
const absPath = join(mcpDir, name);
|
|
writeFileSync(absPath, source, 'utf8');
|
|
return { resPath: `.mcp/${name}`, absPath };
|
|
}
|
|
/**
|
|
* Group Godot stderr errors by their res:// file path.
|
|
* Used for batch validation where multiple files produce output in one stderr stream.
|
|
*/
|
|
function parseGodotErrorsByPath(stderr) {
|
|
const result = new Map();
|
|
for (const { message, line, filePath } of parseGodotErrorEntries(stderr)) {
|
|
if (filePath) {
|
|
if (!result.has(filePath))
|
|
result.set(filePath, []);
|
|
result.get(filePath).push({ line, message });
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
export async function handleValidate(runner, args) {
|
|
args = normalizeParameters(args);
|
|
const pv = validateProjectArgs(args);
|
|
if ('isError' in pv)
|
|
return pv;
|
|
// Batch mode: targets array
|
|
if (args.targets && Array.isArray(args.targets)) {
|
|
const targets = args.targets;
|
|
const tempFiles = [];
|
|
try {
|
|
const snakeTargets = [];
|
|
const preErrors = new Map();
|
|
for (let i = 0; i < targets.length; i++) {
|
|
const t = targets[i];
|
|
if (t.source) {
|
|
const { resPath, absPath } = writeTempGdScript(pv.projectPath, t.source, 'validate_batch');
|
|
tempFiles.push(absPath);
|
|
snakeTargets.push({ script_path: resPath });
|
|
}
|
|
else if (t.scriptPath) {
|
|
if (!validateSubPath(pv.projectPath, t.scriptPath)) {
|
|
preErrors.set(i, {
|
|
target: t.scriptPath,
|
|
errors: [
|
|
{
|
|
message: 'Invalid scriptPath: must be a relative path inside the project root, no ".."',
|
|
},
|
|
],
|
|
});
|
|
}
|
|
else {
|
|
snakeTargets.push({ script_path: t.scriptPath });
|
|
}
|
|
}
|
|
else if (t.scenePath) {
|
|
if (!validateSubPath(pv.projectPath, t.scenePath)) {
|
|
preErrors.set(i, {
|
|
target: t.scenePath,
|
|
errors: [
|
|
{
|
|
message: 'Invalid scenePath: must be a relative path inside the project root, no ".."',
|
|
},
|
|
],
|
|
});
|
|
}
|
|
else {
|
|
snakeTargets.push({ scene_path: t.scenePath });
|
|
}
|
|
}
|
|
else {
|
|
snakeTargets.push({});
|
|
}
|
|
}
|
|
// Short-circuit when every target failed pre-validation — no work for
|
|
// Godot, and spawning it would just cost ~3s for a no-op.
|
|
if (snakeTargets.length === 0 && preErrors.size === targets.length) {
|
|
const results = targets.map((_, i) => {
|
|
const pre = preErrors.get(i);
|
|
return { target: pre.target, valid: false, errors: pre.errors };
|
|
});
|
|
return { content: [{ type: 'text', text: JSON.stringify({ results }, null, 2) }] };
|
|
}
|
|
const { stdout, stderr } = await runner.executeOperation('validate_batch', { targets: snakeTargets }, pv.projectPath);
|
|
if (!stdout.trim()) {
|
|
return createErrorResponse(`Batch validate failed: ${extractGdError(stderr)}`, [
|
|
'Check that all target paths are valid',
|
|
'Ensure Godot is installed correctly',
|
|
]);
|
|
}
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(stdout.trim());
|
|
}
|
|
catch {
|
|
return createErrorResponse(`Invalid response from validate_batch: ${stdout}`, [
|
|
'Ensure Godot is installed correctly',
|
|
]);
|
|
}
|
|
const errorsByPath = parseGodotErrorsByPath(stderr || '');
|
|
const godotResults = parsed.results.map((r) => {
|
|
const key = r.target.startsWith('res://') ? r.target : `res://${r.target}`;
|
|
const stderrErrors = errorsByPath.get(key) || errorsByPath.get(r.target) || [];
|
|
const allErrors = stderrErrors.length > 0 ? stderrErrors : r.errors || [];
|
|
return {
|
|
target: r.target,
|
|
valid: r.valid && stderrErrors.length === 0,
|
|
errors: allErrors,
|
|
};
|
|
});
|
|
// Merge pre-validation failures back into their original positions so
|
|
// output order matches input order. Pre-validation errors are ours, not
|
|
// Godot's — they bypass the stderr overlay above.
|
|
const results = [];
|
|
let godotIdx = 0;
|
|
for (let i = 0; i < targets.length; i++) {
|
|
if (preErrors.has(i)) {
|
|
const pre = preErrors.get(i);
|
|
results.push({ target: pre.target, valid: false, errors: pre.errors });
|
|
}
|
|
else {
|
|
results.push(godotResults[godotIdx++]);
|
|
}
|
|
}
|
|
return { content: [{ type: 'text', text: JSON.stringify({ results }, null, 2) }] };
|
|
}
|
|
catch (error) {
|
|
return createErrorResponse(`Batch validation failed: ${getErrorMessage(error)}`, [
|
|
'Ensure Godot is installed correctly',
|
|
'Check if the GODOT_PATH environment variable is set correctly',
|
|
]);
|
|
}
|
|
finally {
|
|
for (const f of tempFiles) {
|
|
try {
|
|
unlinkSync(f);
|
|
}
|
|
catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Determine mode — exactly one must be provided
|
|
const modeCount = [args.scriptPath, args.source, args.scenePath].filter(Boolean).length;
|
|
if (modeCount === 0) {
|
|
return createErrorResponse('One of scriptPath, source, or scenePath is required', [
|
|
'Provide scriptPath to validate an existing .gd file, source to validate inline GDScript, or scenePath to validate a .tscn file',
|
|
]);
|
|
}
|
|
if (modeCount > 1) {
|
|
return createErrorResponse('Provide exactly one of scriptPath, source, or scenePath — not multiple', ['Only one target can be validated per call']);
|
|
}
|
|
let tempFile = false;
|
|
let resolvedScriptPath;
|
|
let resolvedScenePath;
|
|
try {
|
|
if (args.source) {
|
|
const { resPath } = writeTempGdScript(pv.projectPath, args.source, 'validate_temp');
|
|
resolvedScriptPath = resPath;
|
|
tempFile = true;
|
|
}
|
|
else if (args.scriptPath) {
|
|
if (!validateSubPath(pv.projectPath, args.scriptPath)) {
|
|
return createErrorResponse('Invalid scriptPath', [
|
|
'Provide a valid relative path without ".." that stays inside the project directory',
|
|
]);
|
|
}
|
|
const fullPath = join(pv.projectPath, args.scriptPath);
|
|
if (!existsSync(fullPath)) {
|
|
return createErrorResponse(`Script file does not exist: ${args.scriptPath}`, [
|
|
'Ensure the path is correct relative to the project directory',
|
|
]);
|
|
}
|
|
resolvedScriptPath = args.scriptPath;
|
|
}
|
|
else if (args.scenePath) {
|
|
if (!validateSubPath(pv.projectPath, args.scenePath)) {
|
|
return createErrorResponse('Invalid scenePath', [
|
|
'Provide a valid relative path without ".." that stays inside the project directory',
|
|
]);
|
|
}
|
|
const fullPath = join(pv.projectPath, args.scenePath);
|
|
if (!existsSync(fullPath)) {
|
|
return createErrorResponse(`Scene file does not exist: ${args.scenePath}`, [
|
|
'Ensure the path is correct relative to the project directory',
|
|
]);
|
|
}
|
|
resolvedScenePath = args.scenePath;
|
|
}
|
|
const params = {};
|
|
if (resolvedScriptPath)
|
|
params.scriptPath = resolvedScriptPath;
|
|
if (resolvedScenePath)
|
|
params.scenePath = resolvedScenePath;
|
|
const { stdout, stderr } = await runner.executeOperation('validate_resource', params, pv.projectPath);
|
|
// Parse stdout for the base valid/invalid signal from GDScript
|
|
let valid = false;
|
|
let gdErrors = [];
|
|
try {
|
|
const parsed = JSON.parse(stdout.trim());
|
|
valid = parsed.valid === true;
|
|
if (Array.isArray(parsed.errors) && parsed.errors.length > 0) {
|
|
gdErrors = parsed.errors;
|
|
}
|
|
}
|
|
catch {
|
|
// stdout wasn't JSON — treat as invalid
|
|
valid = false;
|
|
}
|
|
// Parse stderr for detailed error messages from Godot's script compiler
|
|
const stderrErrors = parseGodotErrors(stderr || '');
|
|
// Merge errors: prefer detailed stderr errors when available, otherwise keep gdErrors
|
|
const allErrors = stderrErrors.length > 0 ? stderrErrors : gdErrors;
|
|
// The GDScript-side `valid` flag is unreliable for malformed scripts: load()
|
|
// returns a non-null placeholder Resource even when parsing fails, so
|
|
// resource != null is true. Fall back to the parsed stderr errors as the
|
|
// authoritative signal — matches the batch branch above.
|
|
const result = {
|
|
valid: valid && allErrors.length === 0,
|
|
errors: allErrors,
|
|
};
|
|
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
}
|
|
catch (error) {
|
|
return createErrorResponse(`Validation failed: ${getErrorMessage(error)}`, [
|
|
'Ensure Godot is installed correctly',
|
|
'Check if the GODOT_PATH environment variable is set correctly',
|
|
]);
|
|
}
|
|
finally {
|
|
if (tempFile && resolvedScriptPath) {
|
|
const tempFilePath = join(pv.projectPath, resolvedScriptPath);
|
|
try {
|
|
unlinkSync(tempFilePath);
|
|
}
|
|
catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
}
|
|
}
|
|
}
|
|
//# sourceMappingURL=validate-tools.js.map
|