Fresh start: replace with naxIO/netfox-cs-sample foundation
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>
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
@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]}
|
||||
Reference in New Issue
Block a user