b0c83af092
Complete replacement of the tactical-shooter project with the netfox-cs-sample (MIT) — a CS 1.6 inspired multiplayer FPS built with Godot 4 and netfox. ## What's new - Full CS-style gameplay: teams (T/CT), rounds, economy, buy menu - 6 weapons: Knife, Glock, USP, AK-47, M4A1, AWP - Bomb plant/defuse with 2 bombsites - Flashbang & smoke grenades - Proper netfox rollback netcode at 64 tick - Network popup UI for host/join - HUD, crosshair, round timer, scoreboard - All netfox singletons registered as autoloads (works in exported builds) ## Architecture - Listen-server (host from client, no dedicated server binary) - Multiplayer-fps game lives at examples/multiplayer-fps/ - Netfox addons registered as autoloads for exported build compat - Godot 4.7 with Forward+ renderer ## Removed - Old headless-server architecture (client_main, server_main, player.gd, etc.) - Custom netfox bootstrap with ENet fallback - Old ChaffGames FPS template (2,420 lines, 844 KB) - SimulationServer GDExtension stub - Godot-jolt physics (netfox sample uses default Godot physics) - Duplicate weapon_data.gd, anti_cheat.gd, round_manager.gd, etc. - Server browser API Python venv (87 MB) - test_range map and modular assets ## Preserved - Git history - Server config at config/default_server_config.cfg - Windows export preset - Build directory (gitignored) Co-authored-by: naxIO <naxIO@users.noreply.github.com>
334 lines
11 KiB
GDScript
334 lines
11 KiB
GDScript
@tool
|
|
extends Node
|
|
class_name ScriptTools
|
|
## Script and file management tools for MCP.
|
|
## Handles: edit_script, validate_script, list_scripts,
|
|
## create_folder, delete_file, rename_file
|
|
|
|
var _editor_plugin: EditorPlugin = null
|
|
|
|
func set_editor_plugin(plugin: EditorPlugin) -> void:
|
|
_editor_plugin = plugin
|
|
|
|
func _refresh_filesystem() -> void:
|
|
if _editor_plugin:
|
|
_editor_plugin.get_editor_interface().get_resource_filesystem().scan()
|
|
|
|
func _ensure_res_path(path: String) -> String:
|
|
if not path.begins_with("res://"):
|
|
return "res://" + path
|
|
return path
|
|
|
|
# =============================================================================
|
|
# edit_script - Apply a small surgical code edit to a GDScript file
|
|
# =============================================================================
|
|
func edit_script(args: Dictionary) -> Dictionary:
|
|
var edit: Dictionary = args.get(&"edit", {})
|
|
if edit.is_empty():
|
|
return {&"ok": false, &"error": "Missing 'edit' payload"}
|
|
|
|
var path: String = str(edit.get(&"file", ""))
|
|
if path.is_empty():
|
|
return {&"ok": false, &"error": "Missing 'file' in edit"}
|
|
|
|
path = _ensure_res_path(path)
|
|
|
|
if not FileAccess.file_exists(path):
|
|
return {&"ok": false, &"error": "File not found: " + path}
|
|
|
|
var spec_type: String = str(edit.get(&"type", "snippet_replace"))
|
|
if spec_type != "snippet_replace":
|
|
return {&"ok": false, &"error": "Only 'snippet_replace' type is supported"}
|
|
|
|
var old_snippet: String = str(edit.get(&"old_snippet", ""))
|
|
var new_snippet: String = str(edit.get(&"new_snippet", ""))
|
|
var context_before: String = str(edit.get(&"context_before", ""))
|
|
var context_after: String = str(edit.get(&"context_after", ""))
|
|
|
|
if old_snippet.is_empty():
|
|
return {&"ok": false, &"error": "Missing 'old_snippet' in edit"}
|
|
|
|
# Read current file content
|
|
var file := FileAccess.open(path, FileAccess.READ)
|
|
if not file:
|
|
return {&"ok": false, &"error": "Cannot read file: " + path}
|
|
var content := file.get_as_text()
|
|
file.close()
|
|
|
|
# Find and replace the snippet
|
|
var search_text := old_snippet
|
|
var pos := content.find(search_text)
|
|
|
|
# If not found directly, try with context
|
|
if pos == -1 and not context_before.is_empty():
|
|
var ctx_pos := content.find(context_before)
|
|
if ctx_pos != -1:
|
|
var after_ctx := ctx_pos + context_before.length()
|
|
var remaining := content.substr(after_ctx)
|
|
var snippet_pos := remaining.find(old_snippet)
|
|
if snippet_pos != -1:
|
|
pos = after_ctx + snippet_pos
|
|
|
|
if pos == -1:
|
|
return {&"ok": false, &"error": "Could not find old_snippet in file. Make sure old_snippet matches the file content exactly."}
|
|
|
|
# Check for multiple occurrences
|
|
var second_pos := content.find(search_text, pos + 1)
|
|
if second_pos != -1 and context_before.is_empty() and context_after.is_empty():
|
|
return {&"ok": false, &"error": "old_snippet appears multiple times. Add context_before or context_after for disambiguation."}
|
|
|
|
# Apply the replacement
|
|
var original_content := content
|
|
var new_content := content.substr(0, pos) + new_snippet + content.substr(pos + old_snippet.length())
|
|
|
|
# Write back
|
|
file = FileAccess.open(path, FileAccess.WRITE)
|
|
if not file:
|
|
return {&"ok": false, &"error": "Cannot write file: " + path}
|
|
file.store_string(new_content)
|
|
file.close()
|
|
|
|
# Count changes
|
|
var old_lines := old_snippet.split("\n")
|
|
var new_lines := new_snippet.split("\n")
|
|
var added := maxi(0, new_lines.size() - old_lines.size())
|
|
var removed := maxi(0, old_lines.size() - new_lines.size())
|
|
|
|
_refresh_filesystem()
|
|
|
|
return {
|
|
&"ok": true,
|
|
&"path": path,
|
|
&"added": added,
|
|
&"removed": removed,
|
|
&"auto_applied": true,
|
|
&"message": "Applied edit to %s (+%d -%d lines)" % [path, added, removed]
|
|
}
|
|
|
|
# =============================================================================
|
|
# validate_script
|
|
# =============================================================================
|
|
func validate_script(args: Dictionary) -> Dictionary:
|
|
var path: String = str(args.get(&"path", ""))
|
|
if path.strip_edges().is_empty():
|
|
return {&"ok": false, &"error": "Missing 'path'"}
|
|
|
|
path = _ensure_res_path(path)
|
|
|
|
if not FileAccess.file_exists(path):
|
|
return {&"ok": false, &"error": "File not found: " + path}
|
|
|
|
# Read the source text directly from disk so we validate the *current*
|
|
# file contents, not a stale resource-cache entry.
|
|
var file := FileAccess.open(path, FileAccess.READ)
|
|
if not file:
|
|
return {&"ok": false, &"error": "Cannot read file: " + path}
|
|
var source_code := file.get_as_text()
|
|
file.close()
|
|
|
|
# Create a fresh GDScript instance and assign the source for parsing.
|
|
var script := GDScript.new()
|
|
script.source_code = source_code
|
|
|
|
# reload() triggers the parser/compiler and returns OK or an error code.
|
|
var err := script.reload()
|
|
|
|
if err != OK:
|
|
# Try to extract useful details from the Godot output log.
|
|
var errors := _collect_recent_script_errors(path)
|
|
return {
|
|
&"ok": true,
|
|
&"valid": false,
|
|
&"path": path,
|
|
&"error_code": err,
|
|
&"errors": errors,
|
|
&"message": "Script has errors." + (" Details: " + "; ".join(errors) if errors.size() > 0 else " Check Godot console for details.")
|
|
}
|
|
|
|
if not script.can_instantiate():
|
|
return {
|
|
&"ok": true,
|
|
&"valid": false,
|
|
&"path": path,
|
|
&"message": "Script parsed but cannot be instantiated (may have dependency errors)"
|
|
}
|
|
|
|
return {
|
|
&"ok": true,
|
|
&"valid": true,
|
|
&"path": path,
|
|
&"message": "No syntax errors found"
|
|
}
|
|
|
|
func _collect_recent_script_errors(script_path: String) -> Array:
|
|
"""Grab recent SCRIPT ERROR / Parse Error lines from the editor Output panel
|
|
that mention the given script path. Best-effort — returns [] if the panel
|
|
cannot be accessed."""
|
|
var errors: Array = []
|
|
if not _editor_plugin:
|
|
return errors
|
|
|
|
# Find the editor's Output panel RichTextLabel
|
|
var base := _editor_plugin.get_editor_interface().get_base_control()
|
|
var editor_log := _find_node_by_class(base, "EditorLog")
|
|
if not editor_log:
|
|
return errors
|
|
var rtl := _find_child_rtl(editor_log)
|
|
if not rtl:
|
|
return errors
|
|
|
|
var text: String = rtl.get_parsed_text()
|
|
var short_path := script_path.get_file() # e.g. "player.gd"
|
|
|
|
for line: String in text.split("\n"):
|
|
line = line.strip_edges()
|
|
if line.is_empty():
|
|
continue
|
|
if short_path in line or script_path in line:
|
|
if line.begins_with("SCRIPT ERROR:") or line.begins_with("Parse Error:") \
|
|
or line.begins_with("ERROR:") or line.begins_with("at:"):
|
|
errors.append(line)
|
|
|
|
# Keep only the last 10 relevant lines
|
|
if errors.size() > 10:
|
|
errors = errors.slice(errors.size() - 10)
|
|
return errors
|
|
|
|
func _find_node_by_class(root: Node, cls_name: String) -> Node:
|
|
if root.get_class() == cls_name:
|
|
return root
|
|
for child: Node in root.get_children():
|
|
var found := _find_node_by_class(child, cls_name)
|
|
if found:
|
|
return found
|
|
return null
|
|
|
|
func _find_child_rtl(node: Node) -> RichTextLabel:
|
|
for child: Node in node.get_children():
|
|
if child is RichTextLabel:
|
|
return child
|
|
var found := _find_child_rtl(child)
|
|
if found:
|
|
return found
|
|
return null
|
|
|
|
# =============================================================================
|
|
# list_scripts
|
|
# =============================================================================
|
|
func list_scripts(args: Dictionary) -> Dictionary:
|
|
var scripts: Array = []
|
|
_collect_scripts("res://", scripts)
|
|
|
|
return {
|
|
&"ok": true,
|
|
&"scripts": scripts,
|
|
&"count": scripts.size()
|
|
}
|
|
|
|
func _collect_scripts(path: String, out: Array) -> void:
|
|
var dir := DirAccess.open(path)
|
|
if dir == null:
|
|
return
|
|
|
|
dir.list_dir_begin()
|
|
var name := dir.get_next()
|
|
while name != "":
|
|
if name.begins_with("."):
|
|
name = dir.get_next()
|
|
continue
|
|
|
|
var full_path := path.path_join(name)
|
|
if dir.current_is_dir():
|
|
_collect_scripts(full_path, out)
|
|
elif name.ends_with(".gd"):
|
|
out.append(full_path)
|
|
|
|
name = dir.get_next()
|
|
dir.list_dir_end()
|
|
|
|
# =============================================================================
|
|
# create_folder
|
|
# =============================================================================
|
|
func create_folder(args: Dictionary) -> Dictionary:
|
|
var path: String = str(args.get(&"path", ""))
|
|
if path.strip_edges().is_empty():
|
|
return {&"ok": false, &"error": "Missing 'path'"}
|
|
|
|
path = _ensure_res_path(path)
|
|
|
|
if DirAccess.dir_exists_absolute(path):
|
|
return {&"ok": true, &"path": path, &"message": "Directory already exists"}
|
|
|
|
var err := DirAccess.make_dir_recursive_absolute(path)
|
|
if err != OK:
|
|
return {&"ok": false, &"error": "Failed to create directory: " + str(err)}
|
|
|
|
_refresh_filesystem()
|
|
|
|
return {&"ok": true, &"path": path, &"message": "Directory created"}
|
|
|
|
# =============================================================================
|
|
# delete_file
|
|
# =============================================================================
|
|
func delete_file(args: Dictionary) -> Dictionary:
|
|
var path: String = str(args.get(&"path", ""))
|
|
var confirm: bool = bool(args.get(&"confirm", false))
|
|
var create_backup: bool = bool(args.get(&"create_backup", true))
|
|
|
|
if path.strip_edges().is_empty():
|
|
return {&"ok": false, &"error": "Missing 'path'"}
|
|
if not confirm:
|
|
return {&"ok": false, &"error": "Must set confirm=true to delete"}
|
|
|
|
path = _ensure_res_path(path)
|
|
|
|
if not FileAccess.file_exists(path):
|
|
return {&"ok": false, &"error": "File not found: " + path}
|
|
|
|
# Create backup
|
|
if create_backup:
|
|
var backup_path := path + ".bak"
|
|
DirAccess.copy_absolute(path, backup_path)
|
|
|
|
var err := DirAccess.remove_absolute(path)
|
|
if err != OK:
|
|
return {&"ok": false, &"error": "Failed to delete file: " + str(err)}
|
|
|
|
_refresh_filesystem()
|
|
|
|
return {&"ok": true, &"path": path, &"message": "File deleted" + (" (backup created)" if create_backup else "")}
|
|
|
|
# =============================================================================
|
|
# rename_file
|
|
# =============================================================================
|
|
func rename_file(args: Dictionary) -> Dictionary:
|
|
var old_path: String = str(args.get(&"old_path", ""))
|
|
var new_path: String = str(args.get(&"new_path", ""))
|
|
|
|
if old_path.strip_edges().is_empty():
|
|
return {&"ok": false, &"error": "Missing 'old_path'"}
|
|
if new_path.strip_edges().is_empty():
|
|
return {&"ok": false, &"error": "Missing 'new_path'"}
|
|
|
|
old_path = _ensure_res_path(old_path)
|
|
new_path = _ensure_res_path(new_path)
|
|
|
|
if not FileAccess.file_exists(old_path):
|
|
return {&"ok": false, &"error": "File not found: " + old_path}
|
|
if FileAccess.file_exists(new_path):
|
|
return {&"ok": false, &"error": "Target already exists: " + new_path}
|
|
|
|
# Ensure target directory exists
|
|
var dir_path := new_path.get_base_dir()
|
|
if not DirAccess.dir_exists_absolute(dir_path):
|
|
DirAccess.make_dir_recursive_absolute(dir_path)
|
|
|
|
var err := DirAccess.rename_absolute(old_path, new_path)
|
|
if err != OK:
|
|
return {&"ok": false, &"error": "Failed to rename: " + str(err)}
|
|
|
|
_refresh_filesystem()
|
|
|
|
return {&"ok": true, &"old_path": old_path, &"new_path": new_path,
|
|
&"message": "Renamed %s to %s" % [old_path, new_path]}
|