Files
tactical-shooter/package/dist/tools/project-tools.js
T
shawn 91b878f347 Quaternius weapons, input fixes, sound & path fixes
- 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)
2026-07-05 22:53:27 -04:00

544 lines
22 KiB
JavaScript

import { join, basename } from 'path';
import { existsSync, readdirSync, readFileSync } from 'fs';
import { normalizeParameters, validatePath, validateSubPath, validateProjectArgs, createErrorResponse, createStructuredResponse, getErrorMessage, projectGodotPath, } from '../utils/godot-runner.js';
import { logDebug } from '../utils/logger.js';
function fileExtension(name) {
const dotIdx = name.lastIndexOf('.');
return dotIdx >= 0 ? name.slice(dotIdx + 1).toLowerCase() : '';
}
// --- Tool definitions ---
export const projectToolDefinitions = [
{
name: 'list_projects',
description: 'Find Godot projects under a directory by locating project.godot files. Use to discover available projects when the user has not specified one; for inspecting a known project, use get_project_info. recursive:true descends into subdirectories (skipping hidden ones); default false checks only the directory itself and its immediate children. Returns: [{ path, name }], empty array on no matches.',
annotations: { readOnlyHint: true },
inputSchema: {
type: 'object',
properties: {
directory: {
type: 'string',
description: 'Directory to search for Godot projects',
},
recursive: {
type: 'boolean',
description: 'Whether to search recursively (default: false)',
},
},
required: ['directory'],
},
},
{
name: 'get_project_info',
description: 'Get metadata about a Godot project: name, path, Godot version, and a structure summary (counts of scenes/scripts/assets/other). Omit projectPath to get just the Godot version (useful for capability checks). Returns: { name, path, godotVersion, structure } or { godotVersion } when projectPath is omitted. Errors if projectPath is set but lacks project.godot.',
annotations: { readOnlyHint: true },
inputSchema: {
type: 'object',
properties: {
projectPath: {
type: 'string',
description: 'Path to the Godot project directory (optional — omit to get Godot version only)',
},
},
required: [],
},
},
{
name: 'get_project_files',
description: 'Return a recursive file tree of a Godot project. Use to discover project structure when paths are unknown. Pass extensions to filter (e.g. ["gd","tscn"]); maxDepth caps recursion (-1 unlimited). Skips hidden (dot-prefixed) entries and the .mcp directory. Returns: { name, type, path, extension?, children? } (nested tree).',
annotations: { readOnlyHint: true },
inputSchema: {
type: 'object',
properties: {
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
maxDepth: {
type: 'number',
description: 'Maximum recursion depth. -1 means unlimited (default: -1)',
},
extensions: {
type: 'array',
items: { type: 'string' },
description: 'Filter to only these file extensions (e.g. ["gd", "tscn"]). Omit to include all.',
},
},
required: ['projectPath'],
},
},
{
name: 'search_project',
description: 'Plain-text (substring) search across project files. Use to find references, callers, or signatures across the codebase. Default fileTypes is ["gd","tscn","cs","gdshader"]; caseSensitive default false; maxResults default 100. Skips hidden entries and the .mcp directory. Returns: matches[] (project-relative file, 1-indexed lineNumber, line text) and truncated:true when maxResults was hit — consider raising it.',
annotations: { readOnlyHint: true },
inputSchema: {
type: 'object',
properties: {
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
pattern: { type: 'string', description: 'Plain-text string to search for' },
fileTypes: {
type: 'array',
items: { type: 'string' },
description: 'File extensions to search (default: ["gd", "tscn", "cs", "gdshader"])',
},
caseSensitive: { type: 'boolean', description: 'Case-sensitive search (default: false)' },
maxResults: { type: 'number', description: 'Maximum matches to return (default: 100)' },
},
required: ['projectPath', 'pattern'],
},
outputSchema: {
type: 'object',
properties: {
matches: {
type: 'array',
items: {
type: 'object',
properties: {
file: { type: 'string' },
lineNumber: { type: 'number' },
line: { type: 'string' },
},
},
},
truncated: { type: 'boolean' },
},
},
},
{
name: 'get_scene_dependencies',
description: 'Parse a .tscn file for ext_resource references (scripts, textures, subscenes). Use to inspect what a scene depends on before refactoring or moving files. Returns: the queried scene path and dependencies[] from ext_resource refs (path, type, optional uid). Errors if scene file does not exist.',
annotations: { readOnlyHint: true },
inputSchema: {
type: 'object',
properties: {
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
scenePath: {
type: 'string',
description: 'Path to the .tscn file relative to the project root (e.g. "scenes/main.tscn")',
},
},
required: ['projectPath', 'scenePath'],
},
outputSchema: {
type: 'object',
properties: {
scene: { type: 'string' },
dependencies: {
type: 'array',
items: {
type: 'object',
properties: {
path: { type: 'string' },
type: { type: 'string' },
uid: { type: 'string' },
},
},
},
},
},
},
{
name: 'get_project_settings',
description: 'Parse project.godot into structured JSON. Use to inspect configured display, input, rendering, etc. settings without launching Godot. Pass section to filter to one INI section (e.g. "display", "application"). Returns: { settings: { [section]: { [key]: value } } } or { settings: { [key]: value } } when section is given. Complex Godot types are returned as raw strings; keys outside any section appear under __global__.',
annotations: { readOnlyHint: true },
inputSchema: {
type: 'object',
properties: {
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
section: {
type: 'string',
description: 'Filter to a specific INI section (e.g. "display", "application"). Omit for all sections.',
},
},
required: ['projectPath'],
},
},
];
// --- Helpers ---
const PROJECT_SCAN_BLACKLIST = new Set(['.git', '.godot', '.mcp', 'node_modules', '.svn', '.hg']);
function findGodotProjects(directory, recursive) {
const projects = [];
try {
const projectFile = projectGodotPath(directory);
if (existsSync(projectFile)) {
projects.push({
path: directory,
name: basename(directory),
});
}
const entries = readdirSync(directory, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory() || PROJECT_SCAN_BLACKLIST.has(entry.name))
continue;
const subdir = join(directory, entry.name);
if (existsSync(projectGodotPath(subdir))) {
projects.push({ path: subdir, name: entry.name });
}
else if (recursive) {
projects.push(...findGodotProjects(subdir, true));
}
}
}
catch (error) {
logDebug(`Error searching directory ${directory}: ${error}`);
}
return projects;
}
function getProjectStructure(projectPath) {
const structure = {
scenes: 0,
scripts: 0,
assets: 0,
other: 0,
};
const scanDirectory = (currentPath) => {
try {
const entries = readdirSync(currentPath, { withFileTypes: true });
for (const entry of entries) {
const entryPath = join(currentPath, entry.name);
if (entry.name.startsWith('.')) {
continue;
}
if (entry.isDirectory()) {
scanDirectory(entryPath);
}
else if (entry.isFile()) {
const ext = fileExtension(entry.name);
if (ext === 'tscn') {
structure.scenes++;
}
else if (ext === 'gd' || ext === 'gdscript' || ext === 'cs') {
structure.scripts++;
}
else if (['png', 'jpg', 'jpeg', 'webp', 'svg', 'ttf', 'wav', 'mp3', 'ogg'].includes(ext || '')) {
structure.assets++;
}
else {
structure.other++;
}
}
}
}
catch (error) {
logDebug(`Error scanning directory ${currentPath}: ${error}`);
}
};
scanDirectory(projectPath);
return structure;
}
function buildFilesystemTree(currentPath, relativePath, maxDepth, currentDepth, extensions) {
const name = basename(currentPath);
const node = { name, type: 'dir', path: relativePath || '.' };
if (maxDepth !== -1 && currentDepth >= maxDepth) {
node.children = [];
return node;
}
const children = [];
try {
const entries = readdirSync(currentPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.name.startsWith('.'))
continue;
const childRelPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
children.push(buildFilesystemTree(join(currentPath, entry.name), childRelPath, maxDepth, currentDepth + 1, extensions));
}
else if (entry.isFile()) {
const ext = fileExtension(entry.name);
if (extensions && !extensions.includes(ext))
continue;
children.push({ name: entry.name, type: 'file', path: childRelPath, extension: ext });
}
}
}
catch (err) {
logDebug(`buildFilesystemTree error at ${currentPath}: ${err}`);
}
node.children = children;
return node;
}
function searchInFiles(rootPath, pattern, fileTypes, caseSensitive, maxResults) {
const matches = [];
let truncated = false;
const searchDir = (currentPath, relBase) => {
if (truncated)
return;
let entries;
try {
entries = readdirSync(currentPath, { withFileTypes: true });
}
catch (err) {
logDebug(`searchInFiles readdir error at ${currentPath}: ${err}`);
return;
}
for (const entry of entries) {
if (truncated)
return;
if (entry.name.startsWith('.'))
continue;
const childRelPath = relBase ? `${relBase}/${entry.name}` : entry.name;
const fullPath = join(currentPath, entry.name);
if (entry.isDirectory()) {
searchDir(fullPath, childRelPath);
}
else if (entry.isFile()) {
const ext = fileExtension(entry.name);
if (!fileTypes.includes(ext))
continue;
let content;
try {
content = readFileSync(fullPath, 'utf8');
}
catch {
continue;
}
const lines = content.split('\n');
const needle = caseSensitive ? pattern : pattern.toLowerCase();
for (let i = 0; i < lines.length; i++) {
const haystack = caseSensitive ? lines[i] : lines[i].toLowerCase();
if (haystack.includes(needle)) {
matches.push({ file: childRelPath, lineNumber: i + 1, line: lines[i] });
if (matches.length >= maxResults) {
truncated = true;
return;
}
}
}
}
}
};
searchDir(rootPath, '');
return { matches, truncated };
}
function parseProjectSettings(projectFilePath) {
const content = readFileSync(projectFilePath, 'utf8');
const result = {};
let currentSection = '__global__';
for (const rawLine of content.split('\n')) {
const line = rawLine.trim();
if (line === '' || line.startsWith(';') || line.startsWith('#'))
continue;
if (line.startsWith('config_version'))
continue; // header line
if (line.startsWith('[') && line.endsWith(']')) {
currentSection = line.slice(1, -1);
continue;
}
const eqIdx = line.indexOf('=');
if (eqIdx === -1)
continue;
const key = line.slice(0, eqIdx).trim();
const rawVal = line.slice(eqIdx + 1).trim();
let value;
if (rawVal.startsWith('"') && rawVal.endsWith('"')) {
value = rawVal.slice(1, -1);
}
else if (rawVal === 'true') {
value = true;
}
else if (rawVal === 'false') {
value = false;
}
else {
const num = Number(rawVal);
value = isNaN(num) ? rawVal : num;
}
if (!result[currentSection])
result[currentSection] = {};
result[currentSection][key] = value;
}
return result;
}
// --- Handlers ---
export async function handleListProjects(args) {
args = normalizeParameters(args);
if (!args.directory) {
return createErrorResponse('Directory is required', [
'Provide a valid directory path to search for Godot projects',
]);
}
if (!validatePath(args.directory)) {
return createErrorResponse('Invalid directory path', [
'Provide a valid path without ".." or other potentially unsafe characters',
]);
}
try {
if (!existsSync(args.directory)) {
return createErrorResponse(`Directory does not exist: ${args.directory}`, [
'Provide a valid directory path that exists on the system',
]);
}
const recursive = args.recursive === true;
const projects = findGodotProjects(args.directory, recursive);
return {
content: [{ type: 'text', text: JSON.stringify(projects) }],
};
}
catch (error) {
return createErrorResponse(`Failed to list projects: ${getErrorMessage(error)}`, [
'Ensure the directory exists and is accessible',
'Check if you have permission to read the directory',
]);
}
}
export async function handleGetProjectInfo(runner, args) {
args = normalizeParameters(args);
try {
const version = await runner.getVersion();
// If no project path, return just the Godot version
if (!args.projectPath) {
return {
content: [{ type: 'text', text: JSON.stringify({ godotVersion: version }) }],
};
}
const v = validateProjectArgs(args);
if ('isError' in v)
return v;
const projectFile = projectGodotPath(v.projectPath);
const projectStructure = getProjectStructure(v.projectPath);
let projectName = basename(v.projectPath);
try {
const projectFileContent = readFileSync(projectFile, 'utf8');
const configNameMatch = projectFileContent.match(/config\/name="([^"]+)"/);
if (configNameMatch && configNameMatch[1]) {
projectName = configNameMatch[1];
logDebug(`Found project name in config: ${projectName}`);
}
}
catch (error) {
logDebug(`Error reading project file: ${error}`);
}
return {
content: [
{
type: 'text',
text: JSON.stringify({
name: projectName,
path: v.projectPath,
godotVersion: version,
structure: projectStructure,
}),
},
],
};
}
catch (error) {
return createErrorResponse(`Failed to get project info: ${getErrorMessage(error)}`, [
'Ensure Godot is installed correctly',
'Check if the GODOT_PATH environment variable is set correctly',
]);
}
}
export async function handleGetProjectFiles(args) {
args = normalizeParameters(args);
const v = validateProjectArgs(args);
if ('isError' in v)
return v;
try {
const maxDepth = typeof args.maxDepth === 'number' ? args.maxDepth : -1;
const extensions = Array.isArray(args.extensions)
? args.extensions.map((e) => e.toLowerCase().replace(/^\./, ''))
: null;
const tree = buildFilesystemTree(v.projectPath, '', maxDepth, 0, extensions);
return { content: [{ type: 'text', text: JSON.stringify(tree) }] };
}
catch (error) {
return createErrorResponse(`Failed to get project files: ${getErrorMessage(error)}`, [
'Check if the project directory is accessible',
]);
}
}
export async function handleSearchProject(args) {
args = normalizeParameters(args);
const v = validateProjectArgs(args);
if ('isError' in v)
return v;
if (!args.pattern || typeof args.pattern !== 'string') {
return createErrorResponse('pattern is required', ['Provide a plain-text search string']);
}
try {
const fileTypes = Array.isArray(args.fileTypes)
? args.fileTypes.map((e) => e.toLowerCase().replace(/^\./, ''))
: ['gd', 'tscn', 'cs', 'gdshader'];
const caseSensitive = args.caseSensitive === true;
const maxResults = typeof args.maxResults === 'number' ? args.maxResults : 100;
const result = searchInFiles(v.projectPath, args.pattern, fileTypes, caseSensitive, maxResults);
return createStructuredResponse(result);
}
catch (error) {
return createErrorResponse(`Failed to search project: ${getErrorMessage(error)}`, [
'Check if the project directory is accessible',
]);
}
}
export async function handleGetSceneDependencies(args) {
args = normalizeParameters(args);
const v = validateProjectArgs(args);
if ('isError' in v)
return v;
if (!args.scenePath || typeof args.scenePath !== 'string') {
return createErrorResponse('scenePath is required', [
'Provide a path relative to the project root, e.g. "scenes/main.tscn"',
]);
}
if (!validateSubPath(v.projectPath, args.scenePath)) {
return createErrorResponse('Invalid scenePath', [
'Provide a valid relative path without ".." that stays inside the project directory',
]);
}
try {
const sceneFullPath = join(v.projectPath, args.scenePath);
if (!existsSync(sceneFullPath)) {
return createErrorResponse(`Scene file not found: ${args.scenePath}`, [
'Verify the path is relative to the project root',
'Use get_project_files to list available .tscn files',
]);
}
const sceneContent = readFileSync(sceneFullPath, 'utf8');
const dependencies = [];
const extResourcePattern = /^\[ext_resource([^\]]*)\]/gm;
let match;
while ((match = extResourcePattern.exec(sceneContent)) !== null) {
const attrs = match[1];
const typeMatch = attrs.match(/\btype="([^"]*)"/);
const pathMatch = attrs.match(/\bpath="([^"]*)"/);
const uidMatch = attrs.match(/\buid="([^"]*)"/);
if (pathMatch) {
const depPath = pathMatch[1].replace(/^res:\/\//, '');
const dep = {
path: depPath,
type: typeMatch ? typeMatch[1] : 'Unknown',
};
if (uidMatch)
dep.uid = uidMatch[1];
dependencies.push(dep);
}
}
return createStructuredResponse({
scene: args.scenePath,
dependencies,
});
}
catch (error) {
return createErrorResponse(`Failed to get scene dependencies: ${getErrorMessage(error)}`, [
'Check if the scene file is accessible',
]);
}
}
export async function handleGetProjectSettings(args) {
args = normalizeParameters(args);
const v = validateProjectArgs(args);
if ('isError' in v)
return v;
try {
const projectFile = projectGodotPath(v.projectPath);
const allSettings = parseProjectSettings(projectFile);
if (args.section && typeof args.section === 'string') {
const sectionData = allSettings[args.section] ?? {};
return { content: [{ type: 'text', text: JSON.stringify({ settings: sectionData }) }] };
}
return { content: [{ type: 'text', text: JSON.stringify({ settings: allSettings }) }] };
}
catch (error) {
return createErrorResponse(`Failed to get project settings: ${getErrorMessage(error)}`, [
'Check if project.godot is accessible',
]);
}
}
//# sourceMappingURL=project-tools.js.map